nros_node/executor/spin.rs
1//! Executor struct and core spin methods.
2
3use core::{marker::PhantomData, mem::MaybeUninit};
4#[cfg(feature = "param-store")]
5use nros_params::ParameterServerOps as _; // phase-515 R10 stage 2
6
7use nros_core::{RosMessage, RosService, ViewableMessage};
8use nros_rmw::{QoSProfile, ServiceInfo, Session, TopicInfo, TransportError};
9
10use crate::{
11 session,
12 timer::{TimerCallInfo, TimerDuration},
13};
14
15#[cfg(feature = "safety-e2e")]
16use super::arena::{
17 SubSafetyEntry, sub_safety_has_data, sub_safety_pre_sample, sub_safety_try_process,
18};
19#[cfg(feature = "rmw-cffi")]
20use super::types::ExecutorConfig;
21use super::types::{ENTRY_IDLE_BUDGET, ENTRY_POLL_BUDGET};
22// phase-427 — unconditional: `spin_forever` takes `SpinOptions` and is not
23// `alloc`-gated (a bare-metal entry is exactly the caller that needs it).
24use super::{
25 arena::{
26 BufferStrategy, CallbackMeta, EntryKind, GuardConditionEntry, ServiceClientCallbackEntry,
27 ServiceClientRawArenaEntry, ServiceClientSendHeader, SrvEntry, SrvRawEntry,
28 SubBufferedEntry, SubBufferedRawCEntry, SubBufferedRawEntry, SubBufferedRawInfoCEntry,
29 SubBufferedRawInfoEntry, SubBufferedRawMsgInfoEntry, SubBufferedTypedCEntry,
30 SubBufferedViewEntry, SubInfoEntry, SubInplaceEntry, TimerClockSource, TimerEntry,
31 TimerHeader, TimerOverrunPolicy, TraceName, always_ready, buffered_region_size, drop_entry,
32 guard_has_data, guard_try_process, no_pre_sample, service_client_callback_try_process,
33 service_client_raw_try_process, srv_has_data, srv_raw_has_data, srv_raw_try_process,
34 srv_try_process, srv_try_process_with_info, sub_buffered_has_data,
35 sub_buffered_raw_c_has_data, sub_buffered_raw_c_try_process, sub_buffered_raw_has_data,
36 sub_buffered_raw_info_c_has_data, sub_buffered_raw_info_c_try_process,
37 sub_buffered_raw_info_has_data, sub_buffered_raw_info_try_process,
38 sub_buffered_raw_msg_info_has_data, sub_buffered_raw_msg_info_try_process,
39 sub_buffered_raw_try_process, sub_buffered_try_process, sub_buffered_typed_c_has_data,
40 sub_buffered_typed_c_try_process, sub_buffered_view_has_data,
41 sub_buffered_view_try_process, sub_info_has_data, sub_info_pre_sample,
42 sub_info_try_process, sub_inplace_has_data, sub_inplace_try_process, timer_try_process,
43 },
44 node::NodeHandle,
45 spsc_ring::SpscRing,
46 triple_buffer::TripleBuffer,
47 types::{
48 ExecutorSemantics, GuardCondition, HandleId, InvocationMode, NodeError,
49 RawMessageDeserializeFn, RawResponseCallback, RawServiceCallback,
50 RawServiceRequestIdCallback, RawSubscriptionCallback, RawSubscriptionInfoCallback,
51 ReadinessSnapshot, SpinOnceResult, SpinOptions, SpinPeriodPollingResult, Trigger,
52 TypedSubscriptionCallback,
53 },
54};
55
56// ============================================================================
57// phase-456 W8 — one subscription registration, stated as one value
58// ============================================================================
59
60/// What a subscription registration STATES — phase-456 W8.
61///
62/// The thirteen `register_subscription_*` / `add_arena_subscription_*` entry
63/// points differ in their DELIVERY SHAPE (typed, raw, borrowed, with-info,
64/// validated) and used to differ in their LANGUAGE, and each re-derived the
65/// same prologue from positional parameters: node identity, topic info, the
66/// declared-depth check, the backend call. Twelve copies of it, and exactly
67/// ONE of them asked the backend whether it dispatches in place.
68///
69/// This is that prologue's argument, as one value. A struct rather than nine
70/// parameters because the fields are independently optional and a caller must
71/// be able to say "I state nothing here" without reordering a call — the same
72/// reason `DescriptorInputs` is a struct one layer up.
73///
74/// Two of its fields are the axes phase-456 W8 found under issue 1319's five
75/// rows: [`backend_hint`](Self::backend_hint) is "does this site know the
76/// type's bound", and [`in_place_capable`](Self::in_place_capable) is what
77/// lets `Executor::open_subscription` ask the third axis once, for every
78/// caller, in every language.
79pub(crate) struct SubscriptionRequest<'a> {
80 /// `None` is the legacy single-node path: identity comes from the
81 /// executor's own name and namespace and the session is slot 0.
82 pub(crate) node_id: Option<super::node_record::NodeId>,
83 pub(crate) topic_name: &'a str,
84 pub(crate) type_name: &'a str,
85 pub(crate) type_hash: &'a str,
86 pub(crate) qos: QoSProfile,
87 /// Bytes the CALL SITE says it expects to receive, for the BACKEND's
88 /// payload size class (phase-231 / RFC-0038, phase-402 W2).
89 ///
90 /// `None` — and, for a C caller whose ABI spells it `0`, a zero — means
91 /// "this site stated nothing", which is a different claim from "zero
92 /// bytes": `with_rx_buffer_hint(0)` would be the second.
93 pub(crate) backend_hint: Option<usize>,
94 /// Bytes ONE receive slot claims from the arena, already derived by the
95 /// caller that has the type.
96 ///
97 /// Not the same number as [`Self::backend_hint`] — the typed Rust path
98 /// hints the type's bound and sizes the slot from
99 /// `default_subscription_rx_bytes`, which may differ — so both travel.
100 /// Ignored entirely when [`SubscriptionOpen::in_place`] comes back true.
101 pub(crate) slot_bytes: usize,
102 /// Can THIS delivery shape dispatch out of the backend's own receive slot?
103 ///
104 /// A property of the shape, not of the backend: `process_raw_in_place`
105 /// hands the callback borrowed BYTES and nothing else, so a shape that
106 /// must also deliver a wire attachment (`_info`), an integrity status
107 /// (`_validated` / `_safety`) or a `MessageInfo` cannot use it however
108 /// capable the backend is. Stated per call site so an omission is a line
109 /// someone wrote rather than a question nobody asked — which is exactly
110 /// what the C path was before W8.
111 pub(crate) in_place_capable: bool,
112}
113
114/// The result of `Executor::open_subscription`: a claimed slot, a live backend
115/// subscriber, and the ONE answer to the in-place question.
116pub(crate) struct SubscriptionOpen {
117 /// The claimed entry slot. Claimed BEFORE the fallible work, per
118 /// `Executor::emplace_entry`'s rule.
119 pub(crate) slot: usize,
120 pub(crate) handle: session::RmwSubscriber,
121 /// **The only place in this crate that
122 /// `Subscription::supports_process_in_place` is read.**
123 ///
124 /// Before phase-456 W8 the read lived inside
125 /// `register_subscription_buffered_on`, which is the Rust typed path and
126 /// only that: every C and C++ subscription on zenoh or XRCE therefore
127 /// allocated a receive region the backend does not need, at the same order
128 /// as issue 1340's ~9.7 KiB per subscription. The irony phase-456 W8
129 /// measured is that the C callback is the BETTER candidate —
130 /// `RawSubscriptionCallback` is already `(const uint8_t*, size_t, void*)`,
131 /// a borrowed-bytes signature, where the Rust typed path has to produce an
132 /// owned `&M` from somewhere.
133 pub(crate) in_place: bool,
134 /// [`SubscriptionRequest::slot_bytes`], carried through so a caller's arena
135 /// arithmetic reads ONE local rather than re-deriving it.
136 pub(crate) slot_bytes: usize,
137 /// The QoS the backend was actually given — phase-454 W13 through
138 /// phase-456 W8.
139 ///
140 /// NOT the caller's `req.qos`. `declared_qos::honour` REWRITES the depth
141 /// when the system declared one, and the twelve prologues this function
142 /// replaced each passed that rewritten value on to the entry they built.
143 /// Returning it is what keeps that true with one honouring site: a caller
144 /// that stored `req.qos` instead would size its entry from a depth the
145 /// backend never saw, and nothing would fail — the subscription would
146 /// simply hold a different number of samples than the build reserved for
147 /// it.
148 pub(crate) qos: QoSProfile,
149}
150
151// ============================================================================
152// Phase 8 — callback registration event (paired stubs)
153// ============================================================================
154//
155// `docs/design/callback_tracing.rst`. Paired stubs (the `entry_tiers.rs`
156// idiom) so `emplace_entry` reads identically whether or not the feature is
157// on, and the `#[cfg]` lives in exactly one place.
158
159/// Emit nros_callback_register(handle, kind, name) for a newly installed
160/// executor entry.
161#[cfg(feature = "trace-callbacks")]
162#[inline]
163fn trace_register(slot: usize, kind: EntryKind, name: TraceName<'_>) {
164 super::callback_trace::register(slot, kind, name);
165}
166
167#[cfg(not(feature = "trace-callbacks"))]
168#[inline]
169fn trace_register(_slot: usize, _kind: EntryKind, _name: TraceName<'_>) {}
170
171// ============================================================================
172// Executor::open() factory method
173// ============================================================================
174
175/// phase-271 / phase-392 W6 — the `'static` `u64` backing the `alloc`
176/// convenience constructors borrow, sized to `sizing`.
177///
178/// **The named static first, the heap second.** phase-271 made this a
179/// `Box::leak`, and a leak has no symbol: `mem-report` reads an ELF's symbol
180/// table, so the largest single RAM consumer of every Rust image was the one
181/// thing the memory instrument could not see (RFC-0002 § 4.4b). It is served
182/// from `executor::backing::EXECUTOR_BACKING` now — same bytes, same
183/// executor-lifetime reservation, in `.bss` where the linker can print them and
184/// out of the allocator arena that used to pay for them.
185///
186/// The heap arm is still reached, and both cases are legitimate rather than
187/// degraded: a SECOND executor opened through an `alloc` convenience
188/// constructor, and an entry sized past the build-time default. A tiered boot's
189/// spawned tiers are NOT that second executor any more: they open over the
190/// entry's `TierExecutorBacking` slots (`open_with_session_slot`, issue 1571). Neither is an error, so neither
191/// warns — `report_arena_headroom` is where an over-provisioned executor gets
192/// told about itself, and a duplicate line here would just be noise.
193///
194/// `alloc`-only — no_std-no-alloc entries supply their own `static`/stack
195/// backing via `open_in` / `from_session_in`.
196#[cfg(feature = "alloc")]
197fn default_backing(sizing: super::storage::ExecutorSizing) -> &'static mut [MaybeUninit<u64>] {
198 let words = sizing.u64_len();
199 match super::backing::take(words) {
200 Some(backing) => backing,
201 None => alloc::boxed::Box::leak(alloc::boxed::Box::new_uninit_slice(words)),
202 }
203}
204
205#[cfg(feature = "rmw-cffi")]
206impl<'s> Executor<'s> {
207 /// phase-271 — open a new executor session over caller-supplied `backing`,
208 /// sized by `sizing` (per-entry sizing). The core, non-generic sized entry
209 /// point: the `alloc` [`open`](Self::open) convenience leaks a default
210 /// backing and delegates here, and the `nros::main!` macro emits a backing
211 /// sized to the entry's own entity count.
212 ///
213 /// Phase 115.M.4 — auto-registers the cffi vtable for whichever
214 /// backend the build was configured for, mirroring the C++ side's
215 /// `#ifdef NROS_RMW_<NAME>` fan-out in `<rclcpp/node.hpp>`. The
216 /// runtime's atomic vtable slot is idempotent: a re-call of any
217 /// backend's `register()` is a no-op, so the fan-out below is safe
218 /// to invoke on every `Executor::open` (cheaper than a `Once` and
219 /// doesn't pull in `std::sync` for no_std targets).
220 ///
221 /// Connects to the middleware at the locator specified in `config`.
222 ///
223 /// # Safety
224 /// `backing` must be ≥ `sizing.u64_len()` words, live for `'s`, and be
225 /// otherwise untouched while the executor lives (see
226 /// [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in)).
227 pub unsafe fn open_in(
228 config: &ExecutorConfig<'_>,
229 backing: &'s mut [MaybeUninit<u64>],
230 sizing: super::storage::ExecutorSizing,
231 ) -> Result<Self, NodeError> {
232 use nros_rmw::Rmw;
233
234 // Phase 128.A.3 / 249 P4b.1 — manifest-driven backend selection.
235 //
236 // Every linked backend self-registered via its `.init_array`
237 // ctor before `main` (RFC-0042 §D3.3), so the registry is
238 // already populated — no runtime section walk.
239 //
240 // 1. Honour `config.rmw` — the caller's explicit backend selection,
241 // mirroring ROS 2's `RMW_IMPLEMENTATION`. On a hosted build
242 // `nros::ExecutorConfigEnvExt::from_env` / `nros::env::resolve_hosted`
243 // fill it from `$NROS_RMW`; issue 0687 moved that read to the edge,
244 // because reading a process environment is what kept `std` in this
245 // crate and an RTOS image has no environment to read.
246 // 2. With no selector, pick the unique registered backend.
247 // Zero registered → `NoBackend`; more than one →
248 // `Ambiguous` (user must select one, or use
249 // `Executor::open_multi`).
250 let selector = config.rmw;
251 match nros_rmw_cffi::resolve_backend(selector.map(str::as_bytes)) {
252 nros_rmw_cffi::BackendResolution::Single(_) => {}
253 // Issue 0436 — these are SELECTION outcomes, not transport failures,
254 // and calling them `ConnectionFailed` actively misleads: a PX4 bridge
255 // that registered two backends reported "connection failed", which
256 // reads as a router/network problem and was chased as one. Nothing is
257 // connected at this point — the resolver has not chosen a backend yet.
258 //
259 // The variant still collapses into `NodeError::Transport` (the enum has
260 // no selection arm, and adding one is an ABI change across the C/C++
261 // seams), but `InvalidConfig` at least says "your configuration is
262 // unresolvable", and the std-gated line below names WHICH outcome.
263 other => {
264 {
265 let why: &str = match other {
266 nros_rmw_cffi::BackendResolution::NoBackend => {
267 "no RMW backend is registered"
268 }
269 nros_rmw_cffi::BackendResolution::Ambiguous => {
270 "more than one RMW backend is registered and no \
271 $NROS_RMW selector was set — name one (e.g. \
272 NROS_RMW=uorb), or open per-backend sessions"
273 }
274 nros_rmw_cffi::BackendResolution::Unknown => {
275 "$NROS_RMW names a backend that is not registered"
276 }
277 nros_rmw_cffi::BackendResolution::Single(_) => unreachable!(),
278 };
279 nros_log::log_error!(
280 nros_log::get_logger("nros"),
281 "cannot select an RMW backend — {why}"
282 );
283 }
284 let _ = other;
285 return Err(NodeError::Transport(TransportError::InvalidConfig));
286 }
287 }
288
289 // RFC-0109 D8 — the image's baked session properties (its bringup's
290 // own `rmw/<backend>` file, lowered at build time).
291 let session = super::session_props::with_session_properties(&[], |properties| {
292 let rmw_config = nros_rmw::RmwConfig {
293 locator: config.locator,
294 mode: config.mode,
295 domain_id: config.domain_id,
296 node_name: config.node_name,
297 namespace: config.namespace,
298 properties,
299 };
300 if let Some(name) = selector {
301 // Selector path: route to the specific named backend so
302 // the env-var-disambiguated outcome matches what the
303 // resolver above identified.
304 nros_rmw_cffi::CffiRmw::open_with_rmw(name, &rmw_config)
305 } else {
306 nros_rmw_cffi::CffiRmw.open(&rmw_config)
307 }
308 })
309 .map_err(|e| {
310 // Issue 0465 — do not relabel. This used to discard the backend's
311 // error and report `ConnectionFailed` for every open failure, so an
312 // exhausted session pool (`InvalidConfig`) and a router that is not
313 // there produced the same sentence. Same lesson as the selection
314 // arm above: say which failure happened.
315 nros_log::log_error!(
316 nros_log::get_logger("nros"),
317 "RMW session open failed — {e:?}"
318 );
319 NodeError::Transport(e)
320 })?;
321 // SAFETY: forwarded from this fn's contract — `backing`/`sizing` sized
322 // + alive for `'s`.
323 let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
324 {
325 // `config.clock_us` overrides the constructor's platform default
326 // (issue: assigning `None` here clobbered it and re-enabled the
327 // credit-the-requested-timeout fallback).
328 if let Some(clock) = config.clock_us {
329 executor.clock_us_fn = Some(clock);
330 executor.last_spin_end_us = Some(clock());
331 }
332 // issue 0671 — the SAME rule as `clock_us` directly above, which is
333 // why that one is guarded: a config that does not SPECIFY an epoch
334 // must not be read as "this target HAS no epoch". Assigning `None`
335 // here clobbered the constructor's platform default
336 // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
337 // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
338 // so EVERY hosted node built that way silently lost its wall clock.
339 // With no epoch, `Node::subscription` never attaches the age cell
340 // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
341 // `max_age_ms` contract becomes a silently-dead monitor — the exact
342 // outcome RFC-0052 says must never happen. The rate monitor rides
343 // the GUARDED `clock_us_fn` and kept working, which is why only the
344 // age half went quiet.
345 if let Some(epoch) = config.epoch_us {
346 executor.epoch_us_fn = Some(epoch);
347 }
348 }
349 executor.set_node_identity(config.node_name, config.namespace);
350 // Issue 0656 — beside the identity, for the same reason: an entity needs
351 // both to be addressable, and this half used to be dropped here.
352 executor.domain_id = config.domain_id;
353 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
354 executor.install_wake_signal_on_primary();
355 // Phase 277 W2.c — readiness marker for E2E harnesses. This is the
356 // single call-through `open()`/`open_sized()` share, so it fires on
357 // every platform that reaches here (native, freertos, zephyr,
358 // threadx, …) regardless of which RMW backend or board owns the
359 // boot path. It replaces the per-example synthetic
360 // `log::info!("Publishing messages")` markers W4 removes — those
361 // only proved a callback had fired at least once; this line proves
362 // the session itself is up, before any node/callback exists.
363 //
364 // STABILITY CONTRACT: the leading `"nros: session open"` text is
365 // load-bearing — test harnesses grep for it verbatim. Keep it
366 // stable even if the trailing `(rmw=...)` detail changes.
367 #[cfg(feature = "log")]
368 {
369 if let Some(name) = selector {
370 log::info!("nros: session open (rmw={name})");
371 } else {
372 log::info!("nros: session open");
373 }
374 }
375 Ok(executor)
376 }
377}
378
379#[cfg(all(feature = "rmw-cffi", feature = "alloc"))]
380impl Executor<'static> {
381 /// Open a new executor session using the active RMW backend, at the
382 /// build-time default sizing. Convenience over
383 /// [`open_in`](Self::open_in): leaks a default-sized backing (executor-
384 /// lifetime) so existing callers keep the zero-storage-arg signature.
385 /// Per-entry sizing goes through `open_in` / the `nros::main!` macro.
386 ///
387 /// # Example
388 ///
389 /// ```ignore
390 /// let config = ExecutorConfig::from_env().node_name("my_node");
391 /// let mut executor = Executor::open(&config)?;
392 /// ```
393 pub fn open(config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
394 Self::open_sized(config, super::storage::ExecutorSizing::DEFAULT)
395 }
396
397 /// phase-271 — like [`open`](Self::open) but sized to a caller-supplied
398 /// `sizing` (its own declared topology) instead of the build-time default.
399 /// The `alloc` entry point the `nros::main!` macro's native board path uses
400 /// to size a fat entry (>default `MAX_CBS` callbacks) without a
401 /// workspace-global `NROS_EXECUTOR_MAX_CBS`. Leaks a `sizing`-sized backing
402 /// (executor-lifetime); no-alloc entries use `open_in` with their own static.
403 pub fn open_sized(
404 config: &ExecutorConfig<'_>,
405 sizing: super::storage::ExecutorSizing,
406 ) -> Result<Self, NodeError> {
407 // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`,
408 // uniquely owned by the returned executor.
409 unsafe { Self::open_in(config, default_backing(sizing), sizing) }
410 }
411
412 /// Phase 128.F.1 — explicit per-backend session declaration for
413 /// bridge mode. `specs[0]` becomes the primary session; `specs[1..]`
414 /// open as extras keyed by RMW name. After construction, every
415 /// `create_node_on(name, rmw)` call dispatches to whichever
416 /// session was opened under that RMW name (or, when the rmw name
417 /// matches the primary, the primary session itself).
418 ///
419 /// Single-backend callers should keep using
420 /// [`open`](Self::open) — this entry costs an extra
421 /// `open_with_rmw` per spec and adds no value when only one
422 /// backend is linked.
423 ///
424 /// `$NROS_RMW` env is ignored: bridge mode wants explicit names.
425 ///
426 /// Default-sized `alloc` convenience over
427 /// [`open_multi_in`](crate::executor::ffi::ExecutorFfi::open_multi_in) (leaks a default backing).
428 #[cfg(feature = "rmw-cffi")]
429 pub fn open_multi(specs: &[SessionSpec<'_>]) -> Result<Self, NodeError> {
430 let sizing = super::storage::ExecutorSizing::DEFAULT;
431 // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
432 unsafe { Self::open_multi_in(specs, default_backing(sizing), sizing) }
433 }
434
435 /// Phase 104.C.1 — open the Executor against a specific RMW
436 /// backend by name. Selects from the named registry (Phase
437 /// 104.B.2). `rmw_name` must match one of the names a backend
438 /// registered under (`"zenoh"`, `"cyclonedds"`, `"xrce"`, …).
439 ///
440 /// Equivalent to [`Executor::open`] when the registry has exactly
441 /// one backend (the default-backend fast path). Use this entry
442 /// point in multi-backend builds where `Executor::open` would
443 /// pick the first-registered slot.
444 ///
445 /// Single-Executor multi-Node multi-RMW (the long-term Design X
446 /// from `docs/roadmap/phase-104-multi-backend-bridges.md`) is
447 /// follow-up work — Phase 104.C.2 + C.3.
448 ///
449 /// Default-sized `alloc` convenience over
450 /// [`open_with_rmw_in`](Self::open_with_rmw_in) (leaks a default backing).
451 #[cfg(feature = "rmw-cffi")]
452 pub fn open_with_rmw(rmw_name: &str, config: &ExecutorConfig<'_>) -> Result<Self, NodeError> {
453 let sizing = super::storage::ExecutorSizing::DEFAULT;
454 // SAFETY: the backing is exactly `sizing.u64_len()` words, `'static`.
455 unsafe { Self::open_with_rmw_in(rmw_name, config, default_backing(sizing), sizing) }
456 }
457}
458
459// phase-271 — no-alloc sized cores for the bridge/named open paths. In
460// `impl<'s>` (not the `'static` alloc block) so they stay available in
461// `rmw-cffi`-without-`alloc` builds (e.g. the `nros-bridge` no_std default),
462// which is where `open_multi`/`open_with_rmw` lived before.
463#[cfg(feature = "rmw-cffi")]
464impl<'s> Executor<'s> {
465 /// Per-entry-sized [`open_multi`](Self::open_multi): carves `backing` for
466 /// the executor's tables instead of leaking a default one.
467 ///
468 /// # Safety
469 /// `backing`/`sizing` as in [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in).
470 pub(crate) unsafe fn open_multi_in(
471 specs: &[SessionSpec<'_>],
472 backing: &'s mut [MaybeUninit<u64>],
473 sizing: super::storage::ExecutorSizing,
474 ) -> Result<Self, NodeError> {
475 // Phase 249 P4b.1 — backends self-registered via their
476 // `.init_array` ctor before `main`; no runtime section walk.
477 let primary = specs
478 .first()
479 .ok_or(NodeError::Transport(TransportError::ConnectionFailed))?;
480 let primary_session =
481 nros_rmw_cffi::CffiRmw::open_with_rmw(primary.rmw, &primary.to_rmw_config())
482 .map_err(NodeError::Transport)?;
483 // SAFETY: forwarded from this fn's contract.
484 let mut executor = unsafe { Self::from_session_in(primary_session, backing, sizing) };
485 executor.set_node_identity("", "/");
486 // Phase 156 — see `Executor::open` for primary-identity
487 // recording rationale.
488 let _ = executor.primary_rmw_name.push_str(primary.rmw);
489 let _ = executor.primary_locator.push_str(primary.locator);
490 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
491 executor.install_wake_signal_on_primary();
492
493 for spec in specs.iter().skip(1) {
494 let session = nros_rmw_cffi::CffiRmw::open_with_rmw(spec.rmw, &spec.to_rmw_config())
495 .map_err(NodeError::Transport)?;
496 executor
497 .extra_sessions
498 .push(session)
499 .map_err(|_| NodeError::NodeTableFull)?;
500 // Issue 0436 — record WHICH backend/locator this extra is, so
501 // `NodeBuilder::rmw(name)` can find it instead of opening a second
502 // session against the same (singleton) backend.
503 {
504 let mut rmw_s = heapless::String::<32>::new();
505 let _ = rmw_s.push_str(spec.rmw);
506 let mut loc_s = heapless::String::<128>::new();
507 let _ = loc_s.push_str(spec.locator);
508 let _ = executor.extra_session_ids.push((rmw_s, loc_s));
509 }
510 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
511 {
512 let idx = executor.extra_sessions.len() - 1;
513 executor.install_wake_signal_on_extra(idx);
514 }
515 }
516
517 Ok(executor)
518 }
519
520 /// Per-entry-sized [`open_with_rmw`](Self::open_with_rmw): carves `backing`
521 /// for the executor's tables instead of leaking a default one.
522 ///
523 /// # Safety
524 /// `backing`/`sizing` as in [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in).
525 pub unsafe fn open_with_rmw_in(
526 rmw_name: &str,
527 config: &ExecutorConfig<'_>,
528 backing: &'s mut [MaybeUninit<u64>],
529 sizing: super::storage::ExecutorSizing,
530 ) -> Result<Self, NodeError> {
531 if !nros_rmw_cffi::backend_registered() {
532 return Err(NodeError::Transport(TransportError::ConnectionFailed));
533 }
534
535 let session = super::session_props::with_session_properties(&[], |properties| {
536 nros_rmw_cffi::CffiRmw::open_with_rmw(
537 rmw_name,
538 &nros_rmw::RmwConfig {
539 locator: config.locator,
540 mode: config.mode,
541 domain_id: config.domain_id,
542 node_name: config.node_name,
543 namespace: config.namespace,
544 properties,
545 },
546 )
547 })
548 .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed))?;
549 // SAFETY: forwarded from this fn's contract.
550 let mut executor = unsafe { Self::from_session_in(session, backing, sizing) };
551 {
552 // `config.clock_us` overrides the constructor's platform default
553 // (issue: assigning `None` here clobbered it and re-enabled the
554 // credit-the-requested-timeout fallback).
555 if let Some(clock) = config.clock_us {
556 executor.clock_us_fn = Some(clock);
557 executor.last_spin_end_us = Some(clock());
558 }
559 // issue 0671 — the SAME rule as `clock_us` directly above, which is
560 // why that one is guarded: a config that does not SPECIFY an epoch
561 // must not be read as "this target HAS no epoch". Assigning `None`
562 // here clobbered the constructor's platform default
563 // (`Some(default_epoch_us)`), and `ExecutorConfig::new` — the path
564 // `nros::init_*` + `ctx.config()` takes — leaves `epoch_us: None`,
565 // so EVERY hosted node built that way silently lost its wall clock.
566 // With no epoch, `Node::subscription` never attaches the age cell
567 // (it needs `(STAMP_OFFSET, epoch)` both `Some`), so a baked
568 // `max_age_ms` contract becomes a silently-dead monitor — the exact
569 // outcome RFC-0052 says must never happen. The rate monitor rides
570 // the GUARDED `clock_us_fn` and kept working, which is why only the
571 // age half went quiet.
572 if let Some(epoch) = config.epoch_us {
573 executor.epoch_us_fn = Some(epoch);
574 }
575 }
576 executor.set_node_identity(config.node_name, config.namespace);
577 // Phase 156 — record primary identity for the session-
578 // cache hit path. See `Executor::open` for the rationale.
579 let _ = executor.primary_rmw_name.push_str(rmw_name);
580 let _ = executor.primary_locator.push_str(config.locator);
581 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
582 executor.install_wake_signal_on_primary();
583 Ok(executor)
584 }
585}
586
587/// Phase 128.F.1 — per-backend session declaration for
588/// [`Executor::open_multi`]. Each spec names an RMW backend (must
589/// match one a backend registered under via
590/// `nros_rmw_cffi_register_named`, which its self-registration calls) and
591/// the locator + domain id to open against it.
592#[cfg(feature = "rmw-cffi")]
593#[derive(Clone, Copy)]
594pub struct SessionSpec<'cfg> {
595 pub rmw: &'cfg str,
596 pub locator: &'cfg str,
597 pub domain_id: u32,
598 pub node_name: &'cfg str,
599 pub namespace: &'cfg str,
600 /// The session's backend-specific properties (`RmwConfig::properties`):
601 /// what its domain's RMW file lowers to (RFC-0109 D6, phase-514 W3b).
602 /// Empty: none — the image's baked table is the DEFAULT session's and is
603 /// never given to a spec.
604 pub properties: &'cfg [(&'cfg str, &'cfg str)],
605}
606
607#[cfg(feature = "rmw-cffi")]
608impl<'cfg> SessionSpec<'cfg> {
609 /// Minimal spec — just RMW name + locator. Domain id defaults to
610 /// 0; node name and namespace are empty.
611 pub const fn new(rmw: &'cfg str, locator: &'cfg str) -> Self {
612 Self {
613 rmw,
614 locator,
615 domain_id: 0,
616 node_name: "",
617 namespace: "",
618 properties: &[],
619 }
620 }
621
622 /// The session's backend-specific properties.
623 pub const fn properties(mut self, properties: &'cfg [(&'cfg str, &'cfg str)]) -> Self {
624 self.properties = properties;
625 self
626 }
627
628 pub const fn domain_id(mut self, domain_id: u32) -> Self {
629 self.domain_id = domain_id;
630 self
631 }
632
633 pub const fn node_name(mut self, name: &'cfg str) -> Self {
634 self.node_name = name;
635 self
636 }
637
638 pub const fn namespace(mut self, ns: &'cfg str) -> Self {
639 self.namespace = ns;
640 self
641 }
642
643 fn to_rmw_config(self) -> nros_rmw::RmwConfig<'cfg> {
644 nros_rmw::RmwConfig {
645 locator: self.locator,
646 mode: nros_rmw::SessionMode::Client,
647 domain_id: self.domain_id,
648 node_name: self.node_name,
649 namespace: self.namespace,
650 properties: self.properties,
651 }
652 }
653}
654
655// Phase 128.A.3 — selector for the single-backend resolution path.
656//
657// On hosted (`std`) builds, read `$NROS_RMW`; mirrors ROS 2's
658// `RMW_IMPLEMENTATION`. Returns the name as a byte vector so the
659// caller can pass it to `nros_rmw_cffi::resolve_backend` and (when
660// `Some`) to `CffiRmw::open_with_rmw`.
661//
662// On `no_std` / bare-metal builds, environment variables are not
663// available; resolution always falls through to the single-backend
664// or ambiguous path. Embedded users with multiple backends use the
665// bridge surface `Executor::open_multi` instead.
666// phase-359 W10 / issue 0687 — the private reader that stood here is gone, and
667// so is the shared one that briefly replaced it. `ExecutorConfig::rmw` carries
668// the selection instead: `nros::rmw_selector` reads the variable ONCE, at the
669// hosted edge, and every consumer takes the value.
670
671// ============================================================================
672// SessionStore — owned or borrowed session
673// ============================================================================
674
675/// Session storage: owned or borrowed via raw pointer.
676///
677/// The C API creates a session in `rclc_support_init()` before the
678/// executor. `Borrowed` lets the executor use that session without owning it.
679#[allow(clippy::large_enum_variant)]
680pub(crate) enum SessionStore {
681 Owned(session::ConcreteSession),
682 Borrowed(*mut session::ConcreteSession),
683}
684
685impl core::ops::Deref for SessionStore {
686 type Target = session::ConcreteSession;
687 fn deref(&self) -> &session::ConcreteSession {
688 match self {
689 SessionStore::Owned(s) => s,
690 SessionStore::Borrowed(ptr) => unsafe { &**ptr },
691 }
692 }
693}
694
695impl core::ops::DerefMut for SessionStore {
696 fn deref_mut(&mut self) -> &mut session::ConcreteSession {
697 match self {
698 SessionStore::Owned(s) => s,
699 SessionStore::Borrowed(ptr) => unsafe { &mut **ptr },
700 }
701 }
702}
703
704/// Phase 228.E — an opaque, `Send` handle to an [`Executor`]'s RMW session.
705///
706/// In the per-tier model the boot executor opens the one session and hands each
707/// spawned tier task a handle (not a borrow) so the task opens its own
708/// [`Executor`] over that *same* session across the RTOS task boundary. Wrapping
709/// the `pub(crate)` session pointer lets board crates (`nros-board-linux`,
710/// `nros-board-freertos`, …) name + move the handle without naming the session
711/// type. Obtain via [`Executor::session_handle`]; consume via
712/// [`Executor::open_with_session_handle`].
713#[cfg(any(has_rmw, test))]
714pub struct SessionHandle(*mut session::ConcreteSession);
715
716// SAFETY: the per-tier model deliberately shares one session across RTOS tasks;
717// concurrent access is serialized by the RMW backend's internal locks (the RTOS
718// targets build zenoh-pico `Z_FEATURE_MULTI_THREAD=1` — RFC-0032 §5.0). The
719// boot executor owns the session and outlives every tier task.
720#[cfg(any(has_rmw, test))]
721unsafe impl Send for SessionHandle {}
722
723#[cfg(any(has_rmw, test))]
724impl SessionHandle {
725 /// Phase 274.W1 — convert to an opaque `*mut c_void` for C/C++ FFI.
726 ///
727 /// The returned pointer encodes the session address and is valid as long as
728 /// the owning executor lives. Reconstruct via [`Self::from_raw`].
729 pub fn into_raw(self) -> *mut core::ffi::c_void {
730 self.0 as *mut core::ffi::c_void
731 }
732
733 /// Phase 274.W1 — reconstruct a `SessionHandle` from an opaque pointer
734 /// returned by [`Self::into_raw`].
735 ///
736 /// # Safety
737 /// `ptr` must be a pointer obtained from `into_raw()` on a `SessionHandle`
738 /// whose underlying session is still live and owned by its original executor.
739 pub unsafe fn from_raw(ptr: *mut core::ffi::c_void) -> Self {
740 Self(ptr as *mut session::ConcreteSession)
741 }
742}
743
744/// Phase 228.C — pure callback-group filter decision. `None` = wildcard (accept
745/// every group); `Some` = accept only listed groups. Backs
746/// [`Executor::group_active`]; split out so the logic is unit-testable without a
747/// live session.
748pub(crate) fn group_filter_accepts(
749 active: Option<&[super::storage::ActiveGroup]>,
750 node: u64,
751 group: &str,
752) -> bool {
753 match active {
754 None => true,
755 Some(v) => v.iter().any(|(n, g)| *n == node && g.as_str() == group),
756 }
757}
758
759/// Bytes the parameter store asks the platform heap for, in ONE allocation.
760///
761/// Issue 1706 -- the size of THIS build's store, which folds every knob that
762/// shapes it (`MAX_PARAMETERS`, which a contract's `params:` derives, and the
763/// board's string/array capacities). No formula and no measured constant: the
764/// compiler's own answer, so the number reported below cannot drift from the
765/// allocation it describes.
766#[cfg(all(feature = "param-store", feature = "alloc"))]
767pub const PARAMETER_STORE_BYTES: usize = core::mem::size_of::<nros_params::ParameterStorage>();
768
769/// Issue 1706 -- a TEST seam standing in for a platform heap that returns NULL
770/// for the store's request, on the current thread only (tests run in
771/// parallel). Not a test `#[global_allocator]`: `check-feature-contract`
772/// clause (e) allows exactly one, `nros-platform`'s.
773#[cfg(all(test, feature = "alloc", feature = "param-store"))]
774pub(crate) struct RefuseParameterStore;
775
776#[cfg(all(test, feature = "alloc", feature = "param-store"))]
777std::thread_local! {
778 static REFUSE_PARAMETER_STORE: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
779}
780
781#[cfg(all(test, feature = "alloc", feature = "param-store"))]
782impl RefuseParameterStore {
783 // Only `executor::tests` arms it, and that module is mock-session only.
784 #[cfg(not(feature = "rmw-cffi"))]
785 pub(crate) fn arm() -> Self {
786 REFUSE_PARAMETER_STORE.with(|c| c.set(true));
787 Self
788 }
789
790 fn armed() -> bool {
791 REFUSE_PARAMETER_STORE.with(core::cell::Cell::get)
792 }
793}
794
795#[cfg(all(test, feature = "alloc", feature = "param-store"))]
796impl Drop for RefuseParameterStore {
797 fn drop(&mut self) {
798 REFUSE_PARAMETER_STORE.with(|c| c.set(false));
799 }
800}
801
802/// Issue 2023 — create the executor's ONE `/parameter_events` publisher,
803/// `rcl_interfaces/msg/ParameterEvent`, announced under `node`.
804///
805/// The ONE creation site `check-infra-queryable-counts` holds
806/// [`PARAM_EVENT_PUBLISHERS`](crate::parameter_services::PARAM_EVENT_PUBLISHERS)
807/// to. A node with a record goes through the node's own publisher path (its
808/// session, QoS overrides and the declared-QoS check); an executor with no
809/// registered node — a direct-API image whose parameter services the
810/// reconcile serves under the executor's own identity — publishes under that
811/// same identity on the primary session, so the events and the six services
812/// name one node.
813#[cfg(feature = "param-services")]
814fn create_param_event_pub(
815 exec: &mut Executor<'_>,
816 node: super::node_record::NodeId,
817 qos: QoSProfile,
818) -> Result<session::RmwPublisher, NodeError> {
819 type Event = nros_rcl_interfaces::msg::ParameterEvent;
820 const TOPIC: &str = "/parameter_events";
821 if exec.nodes.get(node.index()).is_some() {
822 return exec.create_raw_publisher_handle_on(
823 node,
824 TOPIC,
825 <Event as RosMessage>::TYPE_NAME,
826 <Event as RosMessage>::TYPE_HASH,
827 qos,
828 );
829 }
830 let node_name = exec.node_name.clone();
831 let namespace = exec.namespace.clone();
832 let mut topic = TopicInfo::new(
833 TOPIC,
834 <Event as RosMessage>::TYPE_NAME,
835 <Event as RosMessage>::TYPE_HASH,
836 )
837 .with_domain(exec.domain_id)
838 .with_namespace(&namespace);
839 if !node_name.is_empty() {
840 topic = topic.with_node_name(&node_name);
841 }
842 let session = &mut *exec.session;
843 super::node::validate_qos_or_report(
844 &qos,
845 Session::supported_qos_policies(session),
846 "publisher",
847 TOPIC,
848 )?;
849 session
850 .create_publisher(&topic, qos)
851 .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
852}
853
854/// Issue 2023 — say that `/parameter_events` could not be created for a node
855/// whose parameter services did come up. Cold: it formats.
856#[cfg(feature = "param-services")]
857#[cold]
858#[inline(never)]
859fn report_parameter_events_refused(e: NodeError) {
860 nros_log::log_error!(
861 nros_log::get_logger("nros"),
862 "/parameter_events could not be created ({:?}); the parameter services are up, \
863 but no parameter change is published. A publisher slot the image did not size \
864 for is the usual cause (issue 2023).",
865 e
866 );
867}
868
869/// Issue 1706 -- say, once, that the parameter store did not fit.
870///
871/// The RFC-0100 D11 shape, one layer up from Cyclone's boot check: the
872/// failure is named where it is decided, with the number a reader needs to
873/// size the heap, instead of surfacing as an anonymous halt in the allocator.
874/// What it can name is the REQUEST; the heap's own report (Zephyr's `HEAP
875/// EXHAUSTED` line, the board's `nros: heap peak` line) carries the capacity.
876#[cfg(feature = "param-store")]
877#[cold]
878fn report_parameter_store_refused() {
879 // Issue 1812 -- with no heap there was no allocation to refuse: the
880 // backing carved no store, and that is the whole story.
881 #[cfg(not(feature = "alloc"))]
882 {
883 let logger = nros_log::get_logger("nros");
884 nros_log::log_error!(
885 logger,
886 "parameter store refused: this executor's backing carves none and the build has no heap"
887 );
888 nros_log::log_error!(
889 logger,
890 "every parameter declaration on this executor is refused (issue 1812)"
891 );
892 nros_log::log_error!(
893 logger,
894 "open it with ExecutorSizing::params > 0 (NROS_PARAM_STORE=1, NROS_MAX_PARAMETERS)"
895 );
896 }
897 #[cfg(feature = "alloc")]
898 report_parameter_heap_store_refused();
899}
900
901/// The `alloc` half of [`report_parameter_store_refused`]: the heap refused
902/// the store's one allocation (issue 1706).
903#[cfg(all(feature = "param-store", feature = "alloc"))]
904#[cold]
905fn report_parameter_heap_store_refused() {
906 let slots = nros_params::MAX_PARAMETERS;
907 // THREE short lines, not one sentence: a log backend truncates a long
908 // line (Zephyr's cut the first draft at its slot count), and the number
909 // is the one part that must survive.
910 let logger = nros_log::get_logger("nros");
911 nros_log::log_error!(
912 logger,
913 "parameter store refused: needs {} bytes in one allocation ({} slots x {})",
914 PARAMETER_STORE_BYTES,
915 slots,
916 PARAMETER_STORE_BYTES / if slots == 0 { 1 } else { slots }
917 );
918 nros_log::log_error!(
919 logger,
920 "every parameter declaration on this executor is refused (issue 1706)"
921 );
922 // phase-382 W3' -- the remedy that takes the store OUT of the heap comes
923 // first: a build that knows the image builds a store carves it from the
924 // executor backing instead (`nros_params::IMPLIED_STORE_SLOTS`).
925 nros_log::log_error!(
926 logger,
927 "declare the parameters in the contract (params:) or state NROS_PARAM_STORE=1"
928 );
929 nros_log::log_error!(
930 logger,
931 "and the store is carved from the executor backing; or raise the image heap"
932 );
933}
934
935/// `text` typed as `declared`'s type — phase-482 W5. `None` when it does not
936/// parse as that type, or the type takes no override yet (arrays).
937///
938/// A `NotSet` declaration has no type to follow, so the text decides: `true` /
939/// `false`, then an integer, then a double, then a string — the order rcl's
940/// YAML reading gives an unquoted scalar.
941#[cfg(all(feature = "param-store", feature = "alloc"))]
942fn typed_param_override(
943 text: &str,
944 declared: &nros_params::ParameterValue,
945) -> Option<nros_params::ParameterValue> {
946 let mut value = nros_params::ParameterValue::NotSet;
947 typed_param_override_into(text, declared, &mut value).then_some(value)
948}
949
950/// [`typed_param_override`], written into `out` IN PLACE — issue 2118.
951/// `false`, with `out` unspecified, where that returns `None`.
952///
953/// Returning the value cost every caller two: the `Option` the callee builds
954/// and the binding the caller moves it into (17 KiB on Cortex-M3 for one
955/// declaration). A caller that fills a slot it holds keeps one.
956#[cfg(all(feature = "param-store", feature = "alloc"))]
957fn typed_param_override_into(
958 text: &str,
959 declared: &nros_params::ParameterValue,
960 out: &mut nros_params::ParameterValue,
961) -> bool {
962 use nros_params::ParameterValue as V;
963 let boolean = |t: &str| match t {
964 "true" | "True" | "TRUE" => Some(true),
965 "false" | "False" | "FALSE" => Some(false),
966 _ => None,
967 };
968 // Issue 2104 -- the TYPE is decided first and the value built once. A
969 // chain of `Option<ParameterValue>`s (`.map(..).or_else(..)`) is one
970 // ~8 KiB temporary per link, in every caller this inlines into.
971 enum Typed {
972 Bool(bool),
973 Integer(i64),
974 Double(f64),
975 String,
976 }
977 let typed = match declared {
978 V::Bool(_) => match boolean(text) {
979 Some(b) => Typed::Bool(b),
980 None => return false,
981 },
982 V::Integer(_) => match text.parse::<i64>() {
983 Ok(i) => Typed::Integer(i),
984 Err(_) => return false,
985 },
986 V::Double(_) => match text.parse::<f64>() {
987 Ok(d) => Typed::Double(d),
988 Err(_) => return false,
989 },
990 V::String(_) => Typed::String,
991 V::NotSet => {
992 if let Some(b) = boolean(text) {
993 Typed::Bool(b)
994 } else if let Ok(i) = text.parse::<i64>() {
995 Typed::Integer(i)
996 } else if let Ok(d) = text.parse::<f64>() {
997 Typed::Double(d)
998 } else {
999 Typed::String
1000 }
1001 }
1002 _ => return false,
1003 };
1004 match typed {
1005 Typed::Bool(b) => *out = V::Bool(b),
1006 Typed::Integer(i) => *out = V::Integer(i),
1007 Typed::Double(d) => *out = V::Double(d),
1008 Typed::String => return out.assign_string(text),
1009 }
1010 true
1011}
1012
1013#[cfg(test)]
1014mod group_filter_tests {
1015 use super::group_filter_accepts;
1016 use crate::executor::storage::{ActiveGroup, node_identity_hash};
1017
1018 type Group = heapless::String<32>;
1019
1020 fn entry(node: &str, ns: &str, g: &str) -> ActiveGroup {
1021 let mut s = Group::new();
1022 s.push_str(g).unwrap();
1023 (node_identity_hash(node, ns), s)
1024 }
1025
1026 #[test]
1027 fn wildcard_accepts_all() {
1028 assert!(group_filter_accepts(
1029 None,
1030 node_identity_hash("n", "/"),
1031 "anything"
1032 ));
1033 }
1034
1035 #[test]
1036 fn set_accepts_only_listed_groups() {
1037 let active = [entry("n", "/", "ctrl")];
1038 let n = node_identity_hash("n", "/");
1039 assert!(group_filter_accepts(Some(&active[..]), n, "ctrl"));
1040 assert!(!group_filter_accepts(Some(&active[..]), n, "telem"));
1041 }
1042
1043 /// issue 1172 — the SAME group id on a DIFFERENT node is not this tier's.
1044 ///
1045 /// This is the case the whole change exists for: two nodes may legitimately
1046 /// declare `ctrl`, pinned to different tiers, and the old filter — keyed on
1047 /// the group name alone — admitted both onto whichever tier asked first.
1048 #[test]
1049 fn the_same_group_on_another_node_is_not_accepted() {
1050 let active = [entry("talker", "/", "ctrl")];
1051 assert!(group_filter_accepts(
1052 Some(&active[..]),
1053 node_identity_hash("talker", "/"),
1054 "ctrl"
1055 ));
1056 assert!(
1057 !group_filter_accepts(
1058 Some(&active[..]),
1059 node_identity_hash("listener", "/"),
1060 "ctrl"
1061 ),
1062 "another node's identically-named group must NOT be admitted"
1063 );
1064 }
1065
1066 /// The namespace is part of the identity, not decoration: two nodes may
1067 /// share a bare name under different namespaces, which is legal in ROS.
1068 #[test]
1069 fn the_same_name_under_another_namespace_is_a_different_node() {
1070 let active = [entry("talker", "/left", "ctrl")];
1071 assert!(!group_filter_accepts(
1072 Some(&active[..]),
1073 node_identity_hash("talker", "/right"),
1074 "ctrl"
1075 ));
1076 }
1077
1078 /// An empty namespace and `/` are the SAME node. Both ends normalise, and
1079 /// if only one did, a filter would never match what it named.
1080 #[test]
1081 fn an_empty_namespace_normalises_to_root() {
1082 assert_eq!(
1083 node_identity_hash("talker", ""),
1084 node_identity_hash("talker", "/")
1085 );
1086 }
1087
1088 /// The separator is load-bearing: without it `("ab","c")` and `("a","bc")`
1089 /// would hash alike, and a node name may contain any namespace character.
1090 #[test]
1091 fn the_name_namespace_split_cannot_be_forged() {
1092 assert_ne!(
1093 node_identity_hash("ab", "/c"),
1094 node_identity_hash("a", "b/c")
1095 );
1096 }
1097
1098 /// phase-409 — the wildcard and an EMPTY filter are different answers, and
1099 /// the carved table cannot tell them apart on its own (the old
1100 /// `Option<Vec>` could). `Executor::active_groups_filtering` is what keeps
1101 /// them apart; this pins the distinction at the decision itself.
1102 #[test]
1103 fn an_empty_filter_is_not_the_wildcard() {
1104 let empty: [ActiveGroup; 0] = [];
1105 let n = node_identity_hash("n", "/");
1106 assert!(!group_filter_accepts(Some(&empty[..]), n, "anything"));
1107 assert!(group_filter_accepts(None, n, "anything"));
1108 }
1109}
1110
1111// ============================================================================
1112// Executor
1113// ============================================================================
1114
1115/// Backend-agnostic executor that owns a session.
1116///
1117/// Provides `create_node()` for entity creation and `drive_io()` for polling.
1118///
1119/// # Callback Mode
1120///
1121/// The executor supports arena-based callback registration via the
1122/// `node_mut(id).subscription(t)` builder and
1123/// [`register_service()`](Self::register_service), with dispatch via
1124/// [`spin_once()`](Self::spin_once). No heap allocation is needed.
1125///
1126/// The sizes are set via `NROS_EXECUTOR_MAX_CBS` (default 4) and
1127/// `NROS_EXECUTOR_ARENA_SIZE` (default 4096) environment variables at build time.
1128///
1129/// Phase 124.B.2 — opaque context handed to the runtime wake
1130/// callback. Backends store the raw pointer + invoke the callback;
1131/// the callback decodes back to `&WakeCtx`.
1132///
1133/// `repr(align(64))` because an entity waker carries its slot in the low six
1134/// bits of a pointer to this struct (phase-397 W2, `entity_wake`).
1135#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1136#[repr(align(64))]
1137pub(crate) struct WakeCtx {
1138 pub(crate) flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1139 /// The wake primitive. Phase 130.3 added it beside a `std::sync::Condvar`
1140 /// pair and noted that "a future migration to a single primitive flips one
1141 /// branch instead of two"; phase-359 W10 is that migration, and this is the
1142 /// one that survived.
1143 pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1144 /// phase-397 W2 — one bit per entry slot an attributed arrival has marked
1145 /// since the executor last took the word. Set by an entity waker
1146 /// (`entity_wake`), never by the session-scoped callback, which cannot
1147 /// name an entry. W3 reads it instead of scanning.
1148 /// A `usize`, not a `u64`: 32-bit targets without 64-bit atomics have no
1149 /// `AtomicU64`, so the word names `usize::BITS` slots (`entity_wake::SLOTS`)
1150 /// and an entry above that keeps the session wake.
1151 pub(crate) ready: portable_atomic::AtomicUsize,
1152 /// phase-509 P9b item 3 (RFC-0108 D7) -- the band workers' wake objects
1153 /// (`Executor::attach_band_worker`), a list of `'static` waiters that is
1154 /// only ever pushed to. An attributed arrival wakes the worker whose band
1155 /// holds its slot; an unattributed one wakes every worker.
1156 pub(crate) band_waiters: portable_atomic::AtomicPtr<super::scheduler::BandWaiter>,
1157}
1158
1159#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1160impl WakeCtx {
1161 pub(crate) fn new(
1162 flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
1163 node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
1164 ) -> Self {
1165 Self {
1166 flag,
1167 node_wake,
1168 ready: portable_atomic::AtomicUsize::new(0),
1169 band_waiters: portable_atomic::AtomicPtr::new(core::ptr::null_mut()),
1170 }
1171 }
1172
1173 /// phase-509 P9b item 3 -- the wake for an arrival nothing attributes:
1174 /// the executor's own worker, and every band worker, since any of them
1175 /// may own the slot it is for.
1176 pub(crate) fn signal(&self) {
1177 self.signal_own();
1178 let mut w = self.band_waiters.load(portable_atomic::Ordering::Acquire);
1179 while !w.is_null() {
1180 // SAFETY: only `'static` waiters are linked, never unlinked.
1181 let waiter = unsafe { &*w };
1182 waiter.signal();
1183 w = waiter.next.load(portable_atomic::Ordering::Acquire);
1184 }
1185 }
1186
1187 /// The executor's own worker's wake: flag, then the primitive. Shared by
1188 /// the session callback and the entity waker so the two cannot drift.
1189 pub(crate) fn signal_own(&self) {
1190 // Phase 141.B.2 — capture T0 at cb entry. No-op when the
1191 // probe feature is off or no cycle reader is installed.
1192 #[cfg(feature = "wake-latency-probe")]
1193 super::wake_probe::on_wake();
1194 // Lock-free: the `flag` store is SeqCst and therefore happens-before any
1195 // subsequent acquire in the waiter, so a wake cannot be missed even though
1196 // nothing is held here.
1197 self.flag.store(true, core::sync::atomic::Ordering::SeqCst);
1198 // phase-359 W10 — ONE primitive. This used to signal both a
1199 // `std::sync::Condvar` and (since phase 130.3) the `NodeWake`, "so the cb
1200 // keeps working whichever wait primitive spin_once is using". `spin_once`
1201 // now has only one, so this has only one to signal.
1202 if let Some(nw) = self.node_wake.as_ref() {
1203 nw.signal();
1204 }
1205 }
1206
1207 /// phase-397 W2 — an attributed arrival on entry `slot`: its bit, then the
1208 /// wake. The bit is set FIRST, so a waiter that sees the flag sees the bit.
1209 pub(crate) fn mark_ready(&self, slot: usize) {
1210 self.ready
1211 .fetch_or(1usize << slot, core::sync::atomic::Ordering::SeqCst);
1212 // phase-509 P9b item 3 (RFC-0108 D7) -- only the worker whose band
1213 // holds the slot; the executor's own worker when no band does.
1214 let bit = 1usize << slot;
1215 let mut w = self.band_waiters.load(portable_atomic::Ordering::Acquire);
1216 while !w.is_null() {
1217 // SAFETY: only `'static` waiters are linked, never unlinked.
1218 let waiter = unsafe { &*w };
1219 if waiter.slots.load(portable_atomic::Ordering::SeqCst) & bit != 0 {
1220 waiter.signal();
1221 return;
1222 }
1223 w = waiter.next.load(portable_atomic::Ordering::Acquire);
1224 }
1225 self.signal_own();
1226 }
1227}
1228
1229/// Phase 124.B.2 — runtime wake callback.
1230///
1231/// RT-context contract:
1232///
1233/// * **Thread-safe**: callable from any thread. The cb is lock-free
1234/// — it sets `wake_flag` and signals the executor's `NodeWake`, a
1235/// binary semaphore, which holds a signal sent before the waiter
1236/// blocks, so no wakeup is lost. (A condvar pair guarded it before
1237/// phase-359 W10: the waiter checked `wake_flag` under ~~`wake_mu`~~.)
1238/// * **NOT async-signal-safe on POSIX**: `pthread_cond_signal`
1239/// isn't on the POSIX async-signal-safe function list. For POSIX
1240/// signal handler wake, use a `signalfd` + select pattern in a
1241/// thread that owns the wake duty — that pattern is Linux-only
1242/// (`signalfd`/`eventfd` are Linux syscalls, not POSIX), which is
1243/// why the `WakeSignalFd` worker is gated `target_os = "linux"`.
1244/// * **RTOS ISR**: per-RTOS platform layer wraps the cv with an
1245/// ISR-safe primitive (`xSemaphoreGiveFromISR`,
1246/// `tx_event_flags_set` from ISR, `k_sem_give` from ISR on
1247/// Zephyr). Backend's ISR caller routes through the platform's
1248/// `signal_from_isr` API instead of this cb directly.
1249/// * **Bounded execution time**: O(1) — atomic store + cv notify.
1250/// No allocation, no contended lock.
1251///
1252/// The cb is the symbol backends invoke from their async wake path
1253/// (datagram arrival, worker-thread enqueue, etc.). It does
1254/// flag-write + condvar-signal in that order, lock-free.
1255#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1256pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb(ctx: *mut core::ffi::c_void) {
1257 if ctx.is_null() {
1258 return;
1259 }
1260 // SAFETY: ctx points at a `WakeCtx` owned by an Executor still
1261 // alive at the time of the call. `Executor::clear_wake_signal`
1262 // discharges that obligation — `set_wake_callback(None, _)` on the
1263 // primary and every extra session — from `Executor::close` (before
1264 // the session closes) and from the end of `Executor::drop` (before
1265 // `wake_ctx` is dropped). Issue 1385: until then this comment named
1266 // an "`install_wake_*` teardown path" that did not exist, and
1267 // `set_wake_callback(None` occurred nowhere in the tree but here.
1268 let wake = unsafe { &*(ctx as *const WakeCtx) };
1269 wake.signal();
1270}
1271
1272/// phase-436 — the callback a backend holds when several executors share its
1273/// session: signal every member of the [`WakeFanout`](super::wake_fanout)
1274/// whose address is `ctx`. The entry is a `static`, so `ctx` never dangles.
1275#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1276pub(crate) unsafe extern "C" fn nros_rmw_fanout_wake_cb(ctx: *mut core::ffi::c_void) {
1277 if ctx.is_null() {
1278 return;
1279 }
1280 // SAFETY: `ctx` is `WakeFanout::as_ctx` of a `static` pool entry.
1281 let fanout = unsafe { &*(ctx as *const super::wake_fanout::WakeFanout) };
1282 fanout.fire(nros_rmw_runtime_wake_cb);
1283}
1284
1285/// phase-467 Row 8 — the symbol a backend invokes when the ROS GRAPH changes.
1286///
1287/// `rmw_event_callback_t`'s shape, so it fits the ABI slot unchanged:
1288/// `user_data` plus a coalesced count, and no payload. The count is IGNORED on
1289/// purpose — a guard condition is a level, not a queue, so N changes observed
1290/// before the executor spins are one dispatch. That is the same collapse
1291/// `guard_try_process` already performs on a flag several `trigger()`s set.
1292///
1293/// RT-context contract: identical to [`nros_rmw_runtime_wake_cb`] and for the
1294/// same reason — a relaxed-store-plus-wake, O(1), no allocation, no lock. Both
1295/// are called from a transport or worker thread.
1296///
1297/// # Safety
1298///
1299/// `user_data` must be the pointer `Executor::install_graph_change_guard`
1300/// passed: a `GuardCondition` in a box that executor owns. The obligation is
1301/// discharged by `clear_graph_change_signal`, which retires the pointer from
1302/// the backend BEFORE the box is freed, from `close()` and from `drop`. Issue
1303/// 1385 is what the other ordering costs.
1304#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1305pub(crate) unsafe extern "C" fn nros_rmw_runtime_graph_change_cb(
1306 user_data: *const core::ffi::c_void,
1307 _count: usize,
1308) {
1309 if user_data.is_null() {
1310 return;
1311 }
1312 // SAFETY: see this function's `# Safety`.
1313 let guard = unsafe { &*(user_data as *const GuardCondition) };
1314 guard.trigger();
1315}
1316
1317/// Phase 124.B.7.c — Linux signalfd worker.
1318///
1319/// Owns a Linux `eventfd` plus a worker task that `read()`s the
1320/// fd and forwards it as a wake. The eventfd
1321/// write side is async-signal-safe per the kernel contract
1322/// (`write(2)` to an eventfd is permitted from signal handlers),
1323/// closing the gap that `pthread_cond_signal` leaves open on POSIX.
1324///
1325/// Lifecycle:
1326/// * Constructed lazily in `Executor::signal_fd()` on first
1327/// caller request.
1328/// * `Drop` writes a shutdown sentinel + joins the worker.
1329///
1330/// Caller flow (signal handler):
1331/// 1. Get fd via `Executor::signal_fd()` before installing the
1332/// handler.
1333/// 2. Handler does `eventfd_write(fd, 1)` (equivalently,
1334/// `write(fd, &1u64, 8)`).
1335/// 3. Worker thread reads the fd, signals wake_cv. spin_once
1336/// blocked in cv.wait_timeout_while sees flag=true and exits.
1337///
1338/// phase-359 W10 — the worker is a PLATFORM TASK, not a `std::thread`, and it
1339/// forwards through the same [`NodeWake`](super::node_wake::NodeWake) the
1340/// runtime wake callback uses rather than a `std::sync::Condvar`. The eventfd
1341/// itself is still Linux — that is what makes the write async-signal-safe — so
1342/// this stays `target_os = "linux"`; what it no longer is, is std-only.
1343#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1344pub struct WakeSignalFd {
1345 fd: core::ffi::c_int,
1346 ctx: portable_atomic_util::Arc<SignalFdCtx>,
1347 task: Option<nros_platform_api::task::PlatformTask>,
1348}
1349
1350/// What the signalfd worker task needs, reachable through one pointer.
1351#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1352struct SignalFdCtx {
1353 fd: core::ffi::c_int,
1354 shutdown: portable_atomic::AtomicBool,
1355 /// The executor's wake state — the same `WakeCtx` the runtime callback
1356 /// decodes, reached as an address so the context is plainly `Send`.
1357 wake_ctx: usize,
1358}
1359
1360/// Worker entry: read the eventfd, forward as a wake, until shut down.
1361///
1362/// # Safety
1363/// `arg` must point at a live [`SignalFdCtx`] whose `wake_ctx` addresses a
1364/// `WakeCtx` that outlives this task.
1365#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1366unsafe extern "C" fn signal_fd_worker(arg: *mut core::ffi::c_void) -> *mut core::ffi::c_void {
1367 // SAFETY: the spawn site passes `Arc::as_ptr` of a ctx it keeps alive until
1368 // after the join in `Drop`.
1369 let ctx = unsafe { &*(arg as *const SignalFdCtx) };
1370 loop {
1371 let mut buf = [0u8; 8];
1372 // SAFETY: reading 8 bytes from an eventfd into an 8-byte buffer.
1373 let n = unsafe { libc::read(ctx.fd, buf.as_mut_ptr() as *mut core::ffi::c_void, 8) };
1374 if ctx.shutdown.load(portable_atomic::Ordering::Acquire) {
1375 return core::ptr::null_mut();
1376 }
1377 if n <= 0 {
1378 // EINTR / EOF — shutdown was re-checked above, so loop.
1379 continue;
1380 }
1381 // Same effect as `nros_rmw_runtime_wake_cb`, reached directly because
1382 // the shutdown check above plus `Drop`'s join is what guarantees the
1383 // `WakeCtx` is still alive.
1384 // SAFETY: `wake_ctx` addresses the executor's `WakeCtx`, which outlives
1385 // this task by that same guarantee.
1386 unsafe {
1387 let w = &*(ctx.wake_ctx as *const WakeCtx);
1388 w.flag.store(true, portable_atomic::Ordering::SeqCst);
1389 if let Some(wake) = w.node_wake.as_ref() {
1390 wake.signal();
1391 }
1392 }
1393 }
1394}
1395
1396#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1397impl WakeSignalFd {
1398 /// Spawn the worker. `wake_ctx_ptr` is the `*const WakeCtx`
1399 /// produced by `Executor::wake_ctx_ptr` — same value the
1400 /// runtime wake cb decodes.
1401 ///
1402 /// phase-359 W10 — returns `NodeError` rather than `std::io::Error`. The
1403 /// only caller is `Executor::signal_fd`, and the errno detail it used to
1404 /// carry had no consumer: the one failure a caller can act on is "this
1405 /// platform would not give me the worker", which is what
1406 /// `NotInitialized` says — the eventfd and the task are both subsystems
1407 /// this capability requires and neither is guaranteed.
1408 fn new(wake_ctx_ptr: *const WakeCtx) -> Result<Self, NodeError> {
1409 // SAFETY: `eventfd(2)` with a valid flag; returns -1 on failure.
1410 let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) };
1411 if fd < 0 {
1412 return Err(NodeError::NotInitialized);
1413 }
1414 let ctx = portable_atomic_util::Arc::new(SignalFdCtx {
1415 fd,
1416 shutdown: portable_atomic::AtomicBool::new(false),
1417 wake_ctx: wake_ctx_ptr as usize,
1418 });
1419 let arg = portable_atomic_util::Arc::as_ptr(&ctx) as *mut core::ffi::c_void;
1420 // SAFETY: `arg` points at a ctx this struct owns and keeps alive until
1421 // after the join in `Drop`.
1422 let Some(task) = (unsafe {
1423 nros_platform_api::task::PlatformTask::spawn(
1424 signal_fd_worker,
1425 arg,
1426 // A read(2) loop and one atomic store — the smallest stack any
1427 // port will honour is plenty.
1428 8192,
1429 c"nros-wakefd".as_ptr(),
1430 )
1431 }) else {
1432 // SAFETY: nothing else holds the fd — the task never started.
1433 unsafe { libc::close(fd) };
1434 return Err(NodeError::NotInitialized);
1435 };
1436 Ok(Self {
1437 fd,
1438 ctx,
1439 task: Some(task),
1440 })
1441 }
1442
1443 /// Returns the writable eventfd. The caller (typically a POSIX
1444 /// signal handler) writes any non-zero 8-byte value to trigger
1445 /// a wake. `write(2)` on an eventfd is async-signal-safe per
1446 /// `eventfd(2)` man page.
1447 pub fn fd(&self) -> core::ffi::c_int {
1448 self.fd
1449 }
1450}
1451
1452#[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
1453impl Drop for WakeSignalFd {
1454 fn drop(&mut self) {
1455 self.ctx
1456 .shutdown
1457 .store(true, portable_atomic::Ordering::Release);
1458 // Wake the worker so it re-checks shutdown: it is blocked in `read`,
1459 // which only this write can release.
1460 let one: u64 = 1;
1461 // SAFETY: an 8-byte write to our own eventfd.
1462 unsafe {
1463 libc::write(self.fd, &one as *const u64 as *const core::ffi::c_void, 8);
1464 }
1465 if let Some(task) = self.task.take() {
1466 task.join();
1467 }
1468 // SAFETY: the worker has exited, so nothing else touches the fd.
1469 unsafe { libc::close(self.fd) };
1470 }
1471}
1472
1473/// Phase 124.B.7.b — ISR / interrupt-context wake callback.
1474///
1475/// Same semantics as [`nros_rmw_runtime_wake_cb`] but constrained to
1476/// async-signal-safe / ISR-safe primitives.
1477///
1478/// Per-platform routing:
1479///
1480/// * **POSIX (std)**: `pthread_cond_signal` is NOT on the POSIX
1481/// async-signal-safe function list. Calling from a SIGUSR1
1482/// handler is technically UB. Real fix (Phase 124.B.7.c) routes
1483/// via `signalfd`/`eventfd` + a runtime worker thread — Linux
1484/// only, since neither syscall is POSIX; until that lands,
1485/// signal-handler callers MUST use
1486/// `rclc_guard_condition_trigger` from a **separate thread** (not
1487/// from the handler itself), OR set the wake_flag and rely on
1488/// the next poll deadline. This cb currently aliases the regular
1489/// `wake_cb` and is safe only from non-signal-handler ISR-like
1490/// contexts (e.g. timer thread, kernel callback).
1491///
1492/// * **RTOS no_std (Zephyr/FreeRTOS/ThreadX)**: routes through the
1493/// platform-cffi `condvar_signal_from_isr` slot. Each backend
1494/// uses its ISR-safe variant — `xSemaphoreGiveFromISR`,
1495/// `tx_semaphore_put`, `k_condvar_signal`.
1496///
1497/// `ctx` semantics identical to [`nros_rmw_runtime_wake_cb`].
1498#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
1499#[allow(dead_code)] // Public exposure pending B.7.c signalfd worker.
1500pub(crate) unsafe extern "C" fn nros_rmw_runtime_wake_cb_from_isr(ctx: *mut core::ffi::c_void) {
1501 // Today: alias regular wake_cb. POSIX signal-handler safety
1502 // pending B.7.c (signalfd worker-thread forward). Documented in
1503 // the contract above so callers know the boundary.
1504 unsafe { nros_rmw_runtime_wake_cb(ctx) };
1505}
1506
1507/// Phase 258 (Track 2, 2a) — executor-owned component tick slot.
1508///
1509/// The layering-clean half of the W0-B `install` seam's tick fix
1510/// (phase-257 D2). A `nros`-layer `install`/`register_node_borrowed`
1511/// builds an `Arc<ComponentCell>` (the typed/poll-driven component
1512/// state) and enrolls it here via [`Executor::enroll_component`]; the
1513/// executor then drives `tick` on every enrolled slot at the tail of
1514/// each [`spin_once`](Executor::spin_once) — so `install`'d nodes
1515/// (C, C++, **and Rust owned-spin**) tick, closing the
1516/// service-client/action poll gap that the callback-`Arc`-only
1517/// lifetime left open.
1518///
1519/// The executor only sees raw pointers + `extern "C"` fn pointers (no
1520/// `nros` dep — `nros-node` is the lower layer):
1521///
1522/// * `state` — a *leaked* `Arc<ComponentCell>` (via `Arc::into_raw`),
1523/// re-borrowed by the `nros`-side `tick`/`drop` fns. Unlike a
1524/// pub/sub/timer component (kept alive by the executor's per-entity
1525/// callback `Arc` clones), a poll-only component has no callbacks, so
1526/// the slot must own a clone of the cell — hence the paired `drop`.
1527/// * `tick` — `nros`-side `extern "C"` fn that casts `state` back to
1528/// `&ComponentCell`, casts `exec_ctx` back to `*mut Executor`, and
1529/// runs that one cell's tick (mirrors `ExecutorNodeRuntime::run_ticks`).
1530/// * `drop` — `nros`-side `extern "C"` fn run on `Executor::drop` that
1531/// reconstitutes + drops the leaked `Arc`, so the executor owns the
1532/// cell's lifetime.
1533#[derive(Clone, Copy)]
1534pub struct ComponentSlot {
1535 /// Leaked `Arc<ComponentCell>` (opaque to the executor). Owned by
1536 /// this slot — dropped via `drop` on `Executor::drop`.
1537 pub state: *mut core::ffi::c_void,
1538 /// `nros`-side tick trampoline: `(state, exec_ctx)` where `exec_ctx`
1539 /// is `*mut Executor`. Drives one component's `tick`.
1540 pub tick: unsafe extern "C" fn(state: *mut core::ffi::c_void, exec_ctx: *mut core::ffi::c_void),
1541 /// `nros`-side drop trampoline: reconstitutes + drops the leaked
1542 /// `Arc<ComponentCell>` at `state`. Run once on `Executor::drop`.
1543 pub drop: unsafe extern "C" fn(state: *mut core::ffi::c_void),
1544 /// Issue 2020 -- the level that registered the component
1545 /// (`Executor::set_registering_level` at enrol time), whose pass runs its
1546 /// `tick`; `NO_OWNING_LEVEL` on an executor whose levels do not own
1547 /// their slots, where the executor's own worker ticks every component.
1548 pub level: u8,
1549 /// phase-521 W6 — whether the component's `tick` does any work. A tick
1550 /// runs once per spin and is how a component polls a service reply or
1551 /// completes an action goal, so an executor holding one keeps the spin
1552 /// cadence ([`Executor::entry_spin_budget`]). `false` only for a
1553 /// component that states it has no tick.
1554 pub ticks: bool,
1555}
1556
1557// SAFETY: two raw pointers + two extern "C" fn pointers. The `state` pointer's Send/Sync matches the owning
1558// `Executor` (`unsafe impl Send for Executor`); the fn pointers are
1559// Send/Sync by definition.
1560unsafe impl Send for ComponentSlot {}
1561unsafe impl Sync for ComponentSlot {}
1562
1563/// phase-271 — fixed capacity of the per-spin ready-sets (FIFO bitmap + EDF
1564/// heap) and the dispatch loop's upper bound. The executor's callback index is
1565/// carried in a `u64` active-mask (`1u64 << i`), so a callback table can hold at
1566/// most 64 entries regardless of per-entry sizing; the ready-sets are sized to
1567/// this ceiling (stack-transient) so any entry slice up to 64 dispatches
1568/// correctly. `EdfReadySet`'s presence bitmap independently asserts `N <= 64`.
1569pub(crate) const MAX_CALLBACK_SLOTS: usize = 64;
1570
1571/// phase-509 P3 (RFC-0108 D2, its deferred re-probe question) -- whether a
1572/// slot whose backend cannot attribute an arrival (XRCE, uORB) is probed after
1573/// every callback ([`Executor::set_reprobe_each_callback`]) or once per pass.
1574///
1575/// Measured on the host (`reprobe_cost_on_an_unattributed_executor`, release
1576/// build, x86-64, `NROS_EXECUTOR_MAX_CBS=32`): 32 unattributed subscriptions,
1577/// 4 ready per pass, empty callbacks. Once per pass 380-385 ns per pass;
1578/// after every callback 565-577 ns, i.e. +185-191 ns per pass, +46-48 ns per
1579/// callback, 1.65-1.71 ns per extra `has_data` (a mock's: no FFI, no
1580/// transport). The cost is one probe per unsettled slot per CALLBACK, so it
1581/// grows with slots x callbacks, and a real XRCE or uORB probe crosses the
1582/// rmw C ABI on an MCU several times slower than this host. Decided: once
1583/// per pass by default (an XRCE / uORB image keeps today's probe cost), with
1584/// the per-callback probe an opt-in. Pushed readiness (zenoh, which
1585/// attributes every arrival) joins mid-pass whatever this says, for one
1586/// atomic swap per callback.
1587pub const REPROBE_EACH_CALLBACK_DEFAULT: bool = false;
1588
1589/// Issue 1667 — the arena's allocation grain. Every region the arena hands out
1590/// starts on it and is a multiple of it, so a released region is always big
1591/// enough, and aligned enough, to hold its own free-list node.
1592pub(crate) const ARENA_GRAIN: usize = core::mem::align_of::<u64>();
1593
1594/// Issue 1667 — the smallest region the arena hands out: one free-list node
1595/// (`next`, `len`), rounded to the grain.
1596pub(crate) const ARENA_MIN_REGION: usize =
1597 (2 * core::mem::size_of::<usize>()).next_multiple_of(ARENA_GRAIN);
1598
1599/// Issue 1667 — the end of the free list.
1600const ARENA_FREE_NONE: usize = usize::MAX;
1601
1602/// Issue 1496 — one arena region, as an allocation hands it out.
1603#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1604pub(crate) struct FreedRegion {
1605 pub(crate) offset: usize,
1606 pub(crate) len: usize,
1607}
1608
1609/// Phase 305 W3 (issue 0255) — the slots of one executor's RAM remap table
1610/// (across all its nodes).
1611///
1612/// Issue 1971 (RFC-0107 step 1) -- the launch file's rules no longer live
1613/// here: they are one `const` table in flash every executor reads
1614/// ([`super::launch_remap`]). This table holds the RUNTIME rules: `--ros-args
1615/// -r` rules (`crate::ros_args::install_argv_remaps`) and rules a hand-written
1616/// caller declares with [`Executor::declare_remap`]. Its default is
1617/// [`crate::config::RUNTIME_REMAPS`]: 16 on a target with process arguments,
1618/// 0 on bare metal (no argv; `NROS_EXECUTOR_RUNTIME_REMAPS` restores slots).
1619pub const MAX_REMAPS: usize = crate::config::RUNTIME_REMAPS;
1620
1621/// Why [`Executor::declare_remap`] refused a rule -- never a silently dropped
1622/// routing rule.
1623#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1624pub enum RemapRefused {
1625 /// This executor has no runtime remap table: bare metal sizes it 0, since
1626 /// the launch's rules are the flash table and there is no argv. Restore
1627 /// slots with `NROS_EXECUTOR_RUNTIME_REMAPS`, or let codegen emit the rule
1628 /// into the launch table.
1629 NoRuntimeTable,
1630 /// The runtime table is full (`ExecutorSizing::remaps` slots, shared with
1631 /// `--ros-args -r` rules).
1632 TableFull,
1633 /// A string is longer than its slot (node name / namespace 64 bytes,
1634 /// `from` / `to` `MAX_RESOLVED_NAME_LEN`).
1635 TooLong,
1636}
1637
1638impl core::fmt::Display for RemapRefused {
1639 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1640 match self {
1641 Self::NoRuntimeTable => f.write_str(
1642 "declare_remap: this executor has no runtime remap table (0 slots: bare \
1643 metal takes launch remaps from the flash table, nros_cpp_set_launch_remaps); \
1644 set NROS_EXECUTOR_RUNTIME_REMAPS to restore runtime slots",
1645 ),
1646 Self::TableFull => f.write_str(
1647 "declare_remap: the runtime remap table is full (NROS_EXECUTOR_RUNTIME_REMAPS \
1648 slots, shared with --ros-args -r rules)",
1649 ),
1650 Self::TooLong => f.write_str(
1651 "declare_remap: a name is longer than its slot (node name and namespace 64 \
1652 bytes, from/to MAX_RESOLVED_NAME_LEN)",
1653 ),
1654 }
1655 }
1656}
1657
1658/// Which of [`crate::names::resolve_name_layered`]'s two tiers a stored rule
1659/// belongs to.
1660#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1661pub(crate) enum RemapTier {
1662 /// Projected from the launch file (`declare_remap`, which the generated
1663 /// entry calls). AUTHORITATIVE, scoped to one `(name, namespace)`.
1664 Launch,
1665 /// Parsed from `--ros-args` (`crate::ros_args::install_argv_remaps`).
1666 /// FALLBACK — consulted only when no launch rule matched. Scoped to every
1667 /// node, or to the nodes of one NAME (rcl matches a `node:` prefix
1668 /// against the name alone).
1669 Argv,
1670}
1671
1672/// Phase 305 W3 (issue 0255) — one remap rule. `from`/`to` are stored RAW (as
1673/// written); the lookup in [`Executor::resolve_entity_name_for`] expands both
1674/// sides against the owning node's identity via `crate::names` (exact-FQN
1675/// match, no wildcards).
1676///
1677/// The table holds BOTH tiers, so each rule records which one it belongs to.
1678/// The tier is still decided at resolution, by
1679/// [`crate::names::resolve_name_layered`]'s two parameters, never by where a
1680/// rule sits in this table — `tier_rules` splits the table into those two
1681/// arguments. That is what keeps declaration order from deciding between the
1682/// channels: an argv rule installed before a launch rule still loses to it.
1683pub(crate) struct RemapRule {
1684 /// For [`RemapTier::Launch`]: the owning node. For [`RemapTier::Argv`]:
1685 /// the node name a `name:from:=to` rule was restricted to, or EMPTY for a
1686 /// rule that applies to every node.
1687 pub(crate) node_name: heapless::String<64>,
1688 /// For [`RemapTier::Launch`] only (normalised, `/` for root). Unused by
1689 /// argv rules, whose node prefix carries no namespace.
1690 pub(crate) namespace: heapless::String<64>,
1691 pub(crate) from: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1692 pub(crate) to: heapless::String<{ crate::names::MAX_RESOLVED_NAME_LEN }>,
1693 pub(crate) tier: RemapTier,
1694}
1695
1696impl RemapRule {
1697 /// Does this rule belong to `tier` and apply to the node `(node_name, ns)`?
1698 /// `ns` must already be normalised (`/` for root).
1699 fn applies(&self, tier: RemapTier, node_name: &str, ns: &str) -> bool {
1700 self.tier == tier
1701 && match tier {
1702 RemapTier::Launch => {
1703 self.node_name.as_str() == node_name && self.namespace.as_str() == ns
1704 }
1705 RemapTier::Argv => {
1706 self.node_name.is_empty() || self.node_name.as_str() == node_name
1707 }
1708 }
1709 }
1710}
1711
1712/// The rules of one tier that apply to one node, in declaration order, as the
1713/// `(from, to)` pairs [`crate::names::resolve_name_layered`] takes. The ONE
1714/// place a stored table is split into the two tiers — the executor's resolver,
1715/// `NodeHandle`, and the Rust component road (through
1716/// `crate::ros_args::argv_fallback`) all read through it.
1717pub(crate) fn tier_rules<'r>(
1718 table: &'r [Option<RemapRule>],
1719 tier: RemapTier,
1720 node_name: &'r str,
1721 namespace: &'r str,
1722) -> impl Iterator<Item = (&'r str, &'r str)> + 'r {
1723 let ns = if namespace.is_empty() { "/" } else { namespace };
1724 table
1725 .iter()
1726 .flatten()
1727 .filter(move |r| r.applies(tier, node_name, ns))
1728 .map(|r| (r.from.as_str(), r.to.as_str()))
1729}
1730
1731/// Issue 1971 -- the two places an executor's remap rules live, read as the
1732/// two tiers of [`crate::names::resolve_name_layered`]:
1733///
1734/// * AUTHORITATIVE: the launch table in flash (`launch`) and, after it, any
1735/// rule a hand-written caller declared at run time (`RemapTier::Launch`
1736/// rows of `runtime`);
1737/// * FALLBACK: the `--ros-args -r` rules (`RemapTier::Argv` rows of
1738/// `runtime`).
1739///
1740/// The tier is decided by which parameter a rule is passed in, never by
1741/// where it sits, so an argv rule installed before a launch rule still loses.
1742#[derive(Clone, Copy)]
1743pub(crate) struct RemapView<'r> {
1744 pub(crate) launch: &'r [super::launch_remap::LaunchRemap],
1745 pub(crate) runtime: &'r [Option<RemapRule>],
1746}
1747
1748impl<'r> RemapView<'r> {
1749 /// The authoritative rules for one node: the flash table's, then the
1750 /// runtime-declared ones.
1751 pub(crate) fn launch_rules<'a>(
1752 self,
1753 node_name: &'a str,
1754 ns: &'a str,
1755 ) -> impl Iterator<Item = (&'a str, &'a str)> + 'a
1756 where
1757 'r: 'a,
1758 {
1759 super::launch_remap::launch_rules_for(self.launch, node_name, ns).chain(tier_rules(
1760 self.runtime,
1761 RemapTier::Launch,
1762 node_name,
1763 ns,
1764 ))
1765 }
1766
1767 /// The fallback (`--ros-args -r`) rules for one node.
1768 pub(crate) fn argv_rules<'a>(
1769 self,
1770 node_name: &'a str,
1771 ns: &'a str,
1772 ) -> impl Iterator<Item = (&'a str, &'a str)> + 'a
1773 where
1774 'r: 'a,
1775 {
1776 tier_rules(self.runtime, RemapTier::Argv, node_name, ns)
1777 }
1778
1779 /// `resolve_name_layered` over both tiers.
1780 pub(crate) fn resolve(
1781 self,
1782 source: &str,
1783 node_name: &str,
1784 ns: &str,
1785 ) -> Result<crate::names::ResolvedName, ()> {
1786 crate::names::resolve_name_layered(
1787 source,
1788 node_name,
1789 ns,
1790 self.launch_rules(node_name, ns),
1791 self.argv_rules(node_name, ns),
1792 )
1793 }
1794}
1795
1796/// The executor's halt flag, in the one shape each configuration can hold.
1797///
1798/// Issue 1177. With an allocator it is refcounted, because `halt_flag()` hands
1799/// a caller its own handle to set from a signal handler or another thread.
1800/// Without one there is no handle to hand out -- a C caller reaches the
1801/// executor through its own pointer and calls `cancel()` directly -- so a plain
1802/// atomic is the whole requirement.
1803///
1804/// `Arc<AtomicBool>` derefs to `AtomicBool`, so every `load`/`store` below is
1805/// written once and only construction and the getter are cfg'd.
1806#[cfg(feature = "alloc")]
1807pub(crate) type HaltFlag = portable_atomic_util::Arc<portable_atomic::AtomicBool>;
1808/// See [`HaltFlag`] above -- the no-allocator shape.
1809#[cfg(not(feature = "alloc"))]
1810pub(crate) type HaltFlag = portable_atomic::AtomicBool;
1811
1812pub struct Executor<'s> {
1813 /// Issue 0656 — the ROS domain this executor's entities belong to.
1814 ///
1815 /// Retained because it was NOT: `open_in` passed `config.domain_id` into
1816 /// `RmwConfig` and dropped it, so every entity built from the executor
1817 /// (rather than from a `Node`, which keeps its own) declared on domain 0
1818 /// whatever `ROS_DOMAIN_ID` said. The value was read, printed, and then not
1819 /// used where it counts — issue 0161's shape.
1820 pub(crate) domain_id: u32,
1821 pub(crate) session: SessionStore,
1822 /// phase-271 (issue 0110) — the six sized tables are no longer inline
1823 /// arrays baked to `nros-node`'s build-time consts; they borrow
1824 /// caller-owned, per-entry-sized storage (`&'s mut` slices carved from a
1825 /// raw `[MaybeUninit<u64>]` backing by [`super::storage::carve`]). Lets a
1826 /// fat native entry and a lean embedded entry in one shared-target
1827 /// workspace each size to its own topology. `Executor` stays non-generic
1828 /// (lifetime only) so the C/C++ FFI keeps wrapping one concrete type.
1829 pub(crate) arena: &'s mut [MaybeUninit<u8>],
1830 pub(crate) arena_used: usize,
1831 /// Issue 1496 / 1667 — the head of the FREE LIST: arena regions given back
1832 /// by a released entry, reused first-fit by the next allocation that fits.
1833 ///
1834 /// The list is INTRUSIVE — each free region holds its own node
1835 /// (`next`, `len`) in its first bytes — and kept in address order, so a
1836 /// release coalesces with both neighbours and nothing bounds how many holes
1837 /// there are. 1496's form was a fixed table of 8 that dropped a region when
1838 /// full and dropped any split tail under 64 B, and those bytes were gone
1839 /// for good: measured under a churning workload (issue 1667), three live
1840 /// entries leaked the whole 74 KiB arena inside 20 000 create/release
1841 /// rounds. A hole that ends at `arena_used` is given back to the bump
1842 /// pointer instead of being listed.
1843 pub(crate) arena_free_head: usize,
1844 /// Issue 1631 — the region the most recent ENTRY allocation handed out,
1845 /// waiting for `emplace_entry` to record it in the entry's
1846 /// `CallbackMeta::arena_len`. Every registration allocates its entry and
1847 /// then emplaces it, so the pair is adjacent; a registration that fails
1848 /// between them leaves a stale value that the next allocation overwrites.
1849 pub(crate) pending_region: Option<FreedRegion>,
1850 pub(crate) entries: &'s mut [Option<CallbackMeta>],
1851 /// Phase 110.B — registered scheduling contexts. Slot 0 is
1852 /// auto-populated with a `Fifo` SC at construction; every entry
1853 /// without an explicit binding maps to it via
1854 /// `sched_context_bindings`.
1855 pub(crate) sched_contexts: &'s mut [Option<super::sched_context::SchedContext>],
1856 /// Per-entry SC binding parallel to `entries`. Defaults to
1857 /// `SchedContextId(0)` (the auto-created Fifo SC).
1858 pub(crate) sched_context_bindings: &'s mut [super::sched_context::SchedContextId],
1859 /// Phase 110.E — user-space sporadic-server budget state per
1860 /// Sporadic-class SC. Slot indices match `sched_contexts`; non-
1861 /// Sporadic slots stay `None`.
1862 pub(crate) sporadic_states: &'s mut [Option<super::sched_context::SporadicState>],
1863 /// Phase 110.E.b — atomic sporadic state + opaque platform-timer
1864 /// handle for ISR-driven refill. Populated by
1865 /// `register_sporadic_timer`; dropped on Executor `Drop` via the
1866 /// stored `destroy_fn`.
1867 #[cfg(feature = "alloc")]
1868 pub(crate) sporadic_atomic_states: &'s mut [Option<(
1869 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
1870 OpaqueTimerHandle,
1871 )>],
1872 /// Phase 110.G — major-frame length for time-triggered dispatch.
1873 /// `0` (default) disables the TT gate entirely; non-zero enables
1874 /// gating per
1875 /// `SchedContext.tt_window_offset_us / tt_window_duration_us`.
1876 pub(crate) major_frame_us: u32,
1877 pub(crate) trigger: Trigger,
1878 pub(crate) semantics: ExecutorSemantics,
1879 /// Node name for entities the executor registers on the node's behalf.
1880 /// Empty means unset — no liveliness tokens will be declared.
1881 pub(crate) node_name: heapless::String<64>,
1882 /// Phase 228.C — per-tier callback-group filter. Wildcard (register every
1883 /// callback — the single-tier degenerate case + today's behaviour) until
1884 /// [`set_active_groups`](Self::set_active_groups) names a non-empty set;
1885 /// after that this tier's executor accepts only callbacks whose
1886 /// `.callback_group()` is in it, and skips the others at registration.
1887 ///
1888 /// phase-409 — CARVED, and the wildcard is a separate flag rather than an
1889 /// `Option` around the table, because the table itself no longer lives in
1890 /// the value. "Filtering with an empty set" and "not filtering" stay
1891 /// distinguishable, which is the whole content of the old `Option`.
1892 pub(crate) active_groups: super::storage::CarvedVec<'s, super::storage::ActiveGroup>,
1893 /// Whether [`active_groups`](Self::active_groups) is a filter at all.
1894 /// `false` = wildcard (the old `None`).
1895 pub(crate) active_groups_filtering: bool,
1896 /// Node namespace (default: "/").
1897 pub(crate) namespace: heapless::String<64>,
1898 /// Phase 104.C.2 — rclcpp-style `add_node` table. Holds the
1899 /// per-Node metadata (name, namespace, rmw, locator, default
1900 /// SchedContext) for every Node attached to this Executor. The
1901 /// implicit "primary" Node (NodeId(0)) mirrors `node_name` +
1902 /// `namespace` above and is auto-populated on first use.
1903 ///
1904 /// phase-409 — CARVED. `NodeId` IS an index into this table, so the
1905 /// carved vector must keep push order and never gain a `swap_remove`.
1906 pub(crate) nodes: super::storage::CarvedVec<'s, super::node_record::NodeRecord>,
1907 /// Phase 272 (RFC-0047) — config-seeded node → sched-context bindings, keyed by the node's
1908 /// fully-qualified `(name, namespace)` pair. `NodeBuilder::build` consults this table to set a
1909 /// node's `default_sched` when no explicit `.sched()` was given. Empty ⇒ every node stays
1910 /// `SchedContextId(0)` (byte-identical to pre-272 behaviour). Sized by `MAX_NODES` (at most
1911 /// one tier per node — RFC-0047 OQ1).
1912 ///
1913 /// phase-409 — CARVED.
1914 pub(crate) node_sched_table: super::storage::CarvedVec<'s, super::storage::NodeSchedEntry>,
1915 /// Phase 273 (RFC-0047) — config-seeded per-callback-group sched bindings, keyed by the node's
1916 /// fully-qualified `(name, namespace)` pair PLUS the callback-group name. Overrides the node
1917 /// default for a callback created in that group. Empty ⇒ no per-group binding (node default
1918 /// stands). Sized by `MAX_CBS` — an upper bound on distinct callback-group bindings (you can
1919 /// never have more distinct group bindings than max callbacks).
1920 ///
1921 /// phase-409 (issue 0961) — CARVED, and it is the reason the phase exists:
1922 /// at ~168 B per slot this was `MAX_CBS` * 168 bytes INSIDE the value, so
1923 /// raising the handle limit from 14 to 36 — a fix for an unrelated failure —
1924 /// added ~3.7 KiB to the stack frame of every function that moves an
1925 /// `Executor`, and overflowed a 320 KiB part's main thread.
1926 pub(crate) group_sched_table: super::storage::CarvedVec<'s, super::storage::GroupSchedEntry>,
1927 /// Phase 305 W3 (issue 0255) — launch-baked per-node remap rules, keyed by
1928 /// the declaring node's `(name, namespace)` identity. Entries store the
1929 /// rule RAW (as written in launch); [`Self::resolve_entity_name`] expands
1930 /// both sides against the owning node's identity at lookup, via the shared
1931 /// `crate::names` seam (the same semantics the Rust `ExecutorSink` path
1932 /// applies). Declaration order is match order (first rule wins).
1933 /// Issue 0563 — CARVED, not inline. As a `heapless::Vec<RemapRule,
1934 /// MAX_REMAPS>` this one field was 6664 bytes of an 11632-byte `Executor`
1935 /// (57%), which is what made building an executor a ~9.3 KB stack
1936 /// temporary and overflowed the Zephyr Cortex-M main stack (issue 0552).
1937 /// It is the seventh sized table; phase-271 moved the other six here.
1938 /// `remap_len` is the fill cursor — occupied slots are `[0, remap_len)`.
1939 pub(crate) remap_table: &'s mut [Option<RemapRule>],
1940 pub(crate) remap_len: usize,
1941 /// Issue 1971 -- this executor's own launch remap table, when one was set
1942 /// on it ([`Self::set_launch_remaps`]); `None` reads the image's
1943 /// ([`super::launch_remap::launch_remaps`]), which the generated entry
1944 /// installs once before any tier is set up. A borrow of `.rodata`, never
1945 /// a copy.
1946 pub(crate) launch_remaps: Option<&'static [super::launch_remap::LaunchRemap]>,
1947 /// Phase 258 (Track 2, 2a) — executor-owned component tick registry.
1948 /// Enrolled by [`Executor::enroll_component`] (from `nros`'s
1949 /// `install`/`register_node_borrowed`); each slot's `tick` runs at the
1950 /// tail of [`spin_once`](Self::spin_once); each slot's `drop` runs on
1951 /// `Executor::drop`. Bounded `MAX_NODES` (matches `nodes`).
1952 ///
1953 /// phase-409 — CARVED.
1954 pub(crate) component_slots: super::storage::CarvedVec<'s, ComponentSlot>,
1955 /// Issue 2146 -- callback slots the host DEFERS (`set_deferred`): the
1956 /// spin queues them instead of running them. One bit per slot.
1957 pub(crate) deferred_mask: u64,
1958 /// Issue 2146 -- deferred slots queued by a spin and not yet run by
1959 /// `run_deferred` / `run_deferred_handle`.
1960 pub(crate) deferred_pending: u64,
1961 /// Issue 2146 -- the host's `(notifier, ctx)`, called once per queueing.
1962 pub(crate) defer_notify: Option<(super::types::DeferNotify, *mut core::ffi::c_void)>,
1963 /// Phase 104.C.3 — extra sessions opened by `node_builder.rmw()`
1964 /// calls that named a backend different from the Executor's
1965 /// primary session. Indexed by `NodeRecord.session_idx`
1966 /// (1..=N maps to `extra_sessions[N-1]`; idx 0 is the primary
1967 /// `self.session`). Sized by `NROS_EXECUTOR_MAX_NODES` since one
1968 /// extra session per Node is the worst case.
1969 ///
1970 /// phase-409 — CARVED, and the biggest single win: `ConcreteSession` is
1971 /// 524 B on the island, so this table alone was ~3.1 KiB of the value.
1972 /// Declared AFTER `session` so field-order drop still closes the primary
1973 /// session before the extras (`CarvedVec` owns its elements' drop).
1974 pub(crate) extra_sessions: super::storage::CarvedVec<'s, session::ConcreteSession>,
1975 /// Issue 0436 — `(rmw_name, locator)` for each entry of `extra_sessions`,
1976 /// the extras' equivalent of `primary_rmw_name` / `primary_locator`.
1977 ///
1978 /// Without it an extra session is ANONYMOUS, and
1979 /// `NodeBuilder::resolve_session_slot` could only recognise one by finding a
1980 /// previously-registered `NodeRecord` bound to it. Sessions opened by
1981 /// `open_multi*` have no such Node yet, so the FIRST `.rmw("zenoh")` node fell
1982 /// through to "open a new session" — a SECOND zenoh session, with an empty
1983 /// locator, which fails (see `primary_rmw_name`'s note: zenoh-pico's global
1984 /// state is a process singleton). That is why the PX4 bridge could open both
1985 /// sessions and then fail to bind its outward Node.
1986 ///
1987 /// Written unconditionally by `open_multi*`, but the only READER is
1988 /// `NodeBuilder::resolve_session_slot`, which is `rmw-cffi`-gated — so
1989 /// without that feature the field is genuinely unread and the
1990 /// workspace's `-D dead_code` is right to say so. Allow it exactly
1991 /// there rather than blanket-allowing a field that must stay live in
1992 /// every configuration that can reach the reader.
1993 ///
1994 /// phase-409 — CARVED.
1995 #[cfg_attr(not(feature = "rmw-cffi"), allow(dead_code))]
1996 pub(crate) extra_session_ids: super::storage::CarvedVec<'s, super::storage::ExtraSessionId>,
1997 /// Phase 156 — primary session's rmw name + locator, captured
1998 /// at `open*` time so `NodeBuilder::resolve_session_slot`'s
1999 /// cache lookup can detect when a `.rmw(name).locator(loc)`
2000 /// matches the primary (slot 0) instead of falling through to
2001 /// `CffiRmw::open_with_rmw` and trying to open a SECOND
2002 /// session against the same backend. zenoh-pico's global state
2003 /// is a process singleton; opening twice fails. Empty when
2004 /// constructed via `from_session(_ptr)` without `open*`
2005 /// recording the metadata; in that case the cache check
2006 /// degrades to "always miss" (today's behaviour).
2007 pub(crate) primary_rmw_name: heapless::String<32>,
2008 pub(crate) primary_locator: heapless::String<128>,
2009 // phase-359 W3 — `portable_atomic_util::Arc`, not `std::sync::Arc`. Same
2010 // atomically-refcounted pointer, available without `std`, and it compiles on
2011 // std too. W3 left the GATE on `std` because the public `halt_flag()` getter
2012 // was std-gated; W10 split that impl, so the field joined `wake_flag` on
2013 // `alloc`.
2014 //
2015 // Issue 1177 — the ALLOCATOR was never the requirement of the FLAG, only of
2016 // SHARING it. The flag is an `AtomicBool`; what needs an allocator is
2017 // `halt_flag()`, the getter that hands a caller its own refcounted handle to
2018 // set from a signal handler or another thread. Gating the flag on `alloc`
2019 // therefore gated the ability to STOP a no_std executor on a feature it does
2020 // not need -- and `nros-c`'s spin loop calls `cancel`/`is_halted`/
2021 // `enter_spin_loop`/`exit_spin_loop`/`is_spinning` unconditionally, so an
2022 // embedded image did not merely lose cancellation, it failed to compile.
2023 //
2024 // `HaltFlag` is the one type name that differs, so every reader below is
2025 // written once: `Arc<AtomicBool>` derefs to `AtomicBool`, so `load`/`store`
2026 // are identical on both sides and only construction and the getter are
2027 // cfg'd.
2028 pub(crate) halt_flag: HaltFlag,
2029 /// Is a spin loop running right now? — the observable behind
2030 /// `is_spinning()` (phase-417 W4.c, rclcpp's `Executor::is_spinning`).
2031 ///
2032 /// A plain atomic, not refcounted like `halt_flag`: nothing hands this one
2033 /// out. `halt_flag()` is a public getter that gives a caller its own
2034 /// handle to set from another thread; `spinning` is only ever set by the
2035 /// executor's own loops through `&self`, so it needs no shared ownership.
2036 ///
2037 /// Issue 1177 — this used to ride `alloc` "for the same reason the impl
2038 /// that reads it does". It never needed an allocator itself; it was gated
2039 /// by ASSOCIATION with `halt_flag`, and when that association turned out to
2040 /// be wrong this went with it.
2041 pub(crate) spinning: portable_atomic::AtomicBool,
2042 /// Phase 104.C.6 — shared executor wake flag. Any source of work
2043 /// (foreign thread handing off a callback, signal handler, future
2044 /// per-session vtable wake hook) sets this; `spin_once` swaps it to
2045 /// `false` on entry and, if it was `true`, polls every session with
2046 /// a 0-ms timeout instead of blocking. Lets one notification wake
2047 /// the executor regardless of which session the user is currently
2048 /// blocked on (the multi-RMW bridge case).
2049 // phase-359 W3 — ONE wake flag, both flavours. W2 had to leave this pair
2050 // alone because `wake_handle()` hands it out as a `std::sync::Arc`;
2051 // converting that signature is what lets the two collapse. Gated on
2052 // `alloc` (which `std` implies) because the Arc needs an allocator.
2053 #[cfg(feature = "alloc")]
2054 pub(crate) wake_flag: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
2055 /// Phase 124.B.2 — wake condvar paired with `wake_flag`. The
2056 /// runtime-supplied wake callback (`nros_rmw_runtime_wake_cb` in
2057 /// nros-rmw-cffi) writes `wake_flag = true` AND signals the executor's
2058 /// `NodeWake` (a condvar pair, ~~`wake_cv`~~ / ~~`wake_mu`~~, before
2059 /// phase-359 W10). `spin_once` blocks on it with a deadline instead of
2060 /// calling `drive_io` with the user's timeout — sub-poll-period wake
2061 /// latency.
2062 ///
2063 /// Poll-only backends (NULL `set_wake_callback` slot) leave the
2064 /// cb uninstalled; the cv wait still fires on its deadline,
2065 /// then drive_io(0) drains whatever the backend's internal
2066 /// poll has buffered.
2067 /// Phase 130.3 — Zephyr+std uses `nros_platform_wake_*` (k_sem)
2068 /// instead of `std::sync::Condvar` because Zephyr's libc
2069 /// `pthread_cond_timedwait` hangs past its deadline. `None`
2070 /// when the platform provider didn't link a wake primitive
2071 /// (e.g. test builds with `rmw-cffi` but no `platform-*`
2072 /// feature); spin_once falls back to driving the transport
2073 /// for the full timeout in that case.
2074 // phase-359 W2 — ONE field, not one per flavour. `portable_atomic_util::Arc`
2075 // compiles on std too, and `std` implies `alloc`, so the std and alloc arms
2076 // were two spellings of the same thing. The inner `NodeWake` was already
2077 // shared; only the Arc differed.
2078 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2079 pub(crate) node_wake: Option<portable_atomic_util::Arc<super::node_wake::NodeWake>>,
2080 /// Phase 130.4 — true when at least one session's backend
2081 /// installed the wake callback. Drives whether `spin_once`
2082 /// uses the wake-primitive wait (`NodeWake` / `Condvar`) or
2083 /// just `drive_io(timeout_ms)`. Poll-only backends
2084 /// (XRCE-DDS-Client, the dust-DDS shim) leave this `false`;
2085 /// Cyclone sets it — it fills the slot and backs it with a real
2086 /// `dds_set_listener` listener; the wait then becomes a no-op sleep that
2087 /// starves reliable retransmission (Phase 127.C.4 root
2088 /// cause: server's `send_response` flushes 100 ms once, then a
2089 /// blind `wait_ms(100)` sleeps with zero session activity, so
2090 /// the agent's ACK arrives into a stalled session and reliable
2091 /// redelivery never fires).
2092 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2093 pub(crate) has_async_wake: bool,
2094 /// phase-397 W2 — one bit per entry slot whose handle accepted an entity
2095 /// waker (`arm_entity_wake`): its arrivals mark [`WakeCtx::ready`] and wake
2096 /// this executor alone. A clear bit is an entry whose backend cannot
2097 /// attribute an arrival, which the session wake still covers. W3 needs
2098 /// both halves: the ready bits are only a complete answer when every live
2099 /// entry that can have data is attributed.
2100 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2101 pub(crate) entity_attributed: u64,
2102 /// phase-397 W3 — attributed slots the NEXT spin must ask `has_data`
2103 /// about, although no arrival marked them: each one that was ready on
2104 /// this spin (dispatched or not, since a dispatch may leave data behind
2105 /// and a failed trigger dispatches nothing), one held mid-dispatch when
2106 /// its arrival came, and one just armed (its data may predate the
2107 /// registration, which the ABI says the caller closes with one
2108 /// `has_data`). A slot drops out the first spin its `has_data` is false.
2109 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2110 pub(crate) entity_recheck: u64,
2111 /// Issue 2060 — attributed SUBSCRIPTION slots whose `has_data` the scan
2112 /// asks EVERY pass, arrival or not, because a status event is polled
2113 /// there. Every backend that carries subscription status events polls
2114 /// them from `has_data` (zenoh's deadline / liveliness / matched walk,
2115 /// Cyclone's `subscription_take_event` drain through the cffi adapter),
2116 /// and those are the events about NOTHING ARRIVING — the last publisher
2117 /// left, a deadline passed. A pushed slot is otherwise asked only after
2118 /// an arrival, so once the last sample was taken nothing asked again and
2119 /// the matched count never fell.
2120 ///
2121 /// Set by [`Executor::subscription_handle_mut`], the one road by which a
2122 /// status-event callback reaches an arena subscription's backend handle;
2123 /// cleared with the entry. A slot with no status callback is never in it,
2124 /// so an idle image that registered none still makes no probe at all —
2125 /// the phase-397 W3 property `readiness_probe_count` pins. No wake is
2126 /// added either: the cadence is the spin's own (one park per pass), and
2127 /// each backend rate-limits its own walk inside `has_data`.
2128 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2129 pub(crate) entity_status_polled: u64,
2130 /// phase-397 W3 — `has_data` calls the readiness scan has made, for the
2131 /// test that proves a pushed spin makes none. Wrapping; read only through
2132 /// [`Executor::readiness_probe_count`].
2133 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2134 pub(crate) readiness_probes: u32,
2135 /// phase-509 P3 (RFC-0108 D2) -- after every callback the dispatcher
2136 /// picks again from the ready set as it is now. Pushed readiness (an
2137 /// attributed slot's arrival) joins mid-pass always; a slot whose backend
2138 /// cannot attribute (XRCE, uORB, a mock) joins mid-pass only when this is
2139 /// set, by a `has_data` probe after each callback. Unset, such a slot is
2140 /// probed once per pass, at the scan. See
2141 /// [`Executor::set_reprobe_each_callback`] for the measured cost.
2142 pub(crate) reprobe_each_callback: bool,
2143 /// Phase 124.B.2 — opaque context Arc handed to backends via
2144 /// `set_wake_callback`. Lazy-allocated on first install; stays
2145 /// alive for the Executor's lifetime so the raw pointer stored
2146 /// in backends remains valid.
2147 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2148 pub(crate) wake_ctx: Option<portable_atomic_util::Arc<WakeCtx>>,
2149 /// Issue 1385 — has THIS executor handed `wake_ctx`'s pointer to a
2150 /// backend? The predicate `clear_wake_signal` reads at teardown.
2151 ///
2152 /// Not `wake_ctx.is_some()`: the ctx is also allocated by `signal_fd()`
2153 /// and by every guard condition, neither of which installs anything on a
2154 /// session. Clearing on those would clear a callback this executor never
2155 /// owned. (Since phase-436 a tier over a BORROWED session does install: it
2156 /// joins the session's wake fan-out, and its teardown is leaving it.)
2157 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2158 pub(crate) wake_cb_installed: bool,
2159 /// phase-436 — this executor's place in the wake fan-out of its PRIMARY
2160 /// session, when it has one. Every executor over a session joins the same
2161 /// entry, so an arrival wakes all of them, not only the one that happened
2162 /// to install into the backend's single slot. See `wake_fanout`.
2163 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2164 pub(crate) wake_membership: Option<super::wake_fanout::Membership>,
2165 /// phase-467 Row 8 — the graph-change target the BACKEND holds a pointer
2166 /// to, owned here.
2167 ///
2168 /// This box is the whole answer to issue 1385 on this path. The backend
2169 /// fires the graph-change edge from a transport or worker thread, so
2170 /// whatever it dereferences must outlive every such call — and the thing
2171 /// the caller has in hand is a `rcl_guard_condition_t` in ITS storage,
2172 /// which the caller may free or reuse at any time. Handing that pointer
2173 /// over would BE 1385. So the executor keeps its own handle to the same
2174 /// arena flag, at a heap address that does not move when the executor
2175 /// does, and [`clear_graph_change_signal`] retires the backend's pointer
2176 /// BEFORE this box drops.
2177 ///
2178 /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
2179 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2180 pub(crate) graph_change_target: Option<alloc::boxed::Box<GuardCondition>>,
2181 /// Phase 124.B.7.c — lazily-allocated Linux signalfd worker.
2182 /// Owned by the Executor; spawned on first `signal_fd()` call.
2183 /// Drop joins the worker thread and closes the fd.
2184 #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
2185 pub(crate) signal_fd: Option<WakeSignalFd>,
2186 /// Issue 1812 — CARVED from the backing's [`ParamHome`](super::storage::ParamHome)
2187 /// when the sizing carves a store; with `alloc`, one heap allocation
2188 /// otherwise. Without `alloc` there is no heap arm, and an executor whose
2189 /// backing carves no home refuses parameters by name instead.
2190 #[cfg(feature = "param-store")]
2191 pub(crate) params: Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>>,
2192 /// phase-382 W3' — the parameter store's slots, CARVED from this
2193 /// executor's backing and still uninitialised. Taken (left empty) the
2194 /// first time something builds the store; empty from the start when the
2195 /// sizing carved none, in which case the store comes from the heap.
2196 #[cfg(feature = "param-store")]
2197 pub(crate) param_slots: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
2198 /// Issue 1812 — the services' home, CARVED beside the store's slots and
2199 /// still uninitialised. Taken with them; empty when the sizing carved no
2200 /// store.
2201 #[cfg(feature = "param-store")]
2202 pub(crate) param_home: &'s mut [MaybeUninit<super::storage::ParamHome>],
2203 /// Issue 1706 -- the parameter store's one allocation was refused by the
2204 /// platform heap. Recorded so the refusal is REPORTED once and not retried
2205 /// on every declaration: a retry cannot succeed on a heap that only shrinks
2206 /// from here, and on Zephyr each attempt prints its own `HEAP EXHAUSTED`.
2207 #[cfg(feature = "param-store")]
2208 pub(crate) param_store_refused: bool,
2209 /// phase-446 W6 -- the parameters each node's contract DECLARES, baked by
2210 /// the entry from the SystemModel. Empty (nothing checked) until an entry
2211 /// hands one over with [`Self::set_declared_params`].
2212 #[cfg(feature = "param-store")]
2213 pub(crate) declared_params: nros_params::DeclaredParams,
2214 /// phase-425 W3b — the `/clock` subscription this image installed, if any.
2215 /// `None` means no time source; the value is the handle so it can be
2216 /// cancelled when `use_sim_time` goes false.
2217 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2218 pub(crate) sim_time_source: Option<HandleId>,
2219 /// phase-425 W3b — the LAST REQUESTED state of `use_sim_time`, which is not
2220 /// the same thing as the installed state: a request can arrive before any
2221 /// node exists (`nros::main!` declares parameters BEFORE it registers
2222 /// components), and there is no node to hang a subscription on until then.
2223 /// `reconcile_ros_time_source` closes the gap on each spin.
2224 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2225 pub(crate) sim_time_requested: bool,
2226 /// Whether `use_sim_time` was ever stated to THIS executor.
2227 ///
2228 /// `sim_time_requested` alone cannot answer it: `false` is both "told to
2229 /// turn it off" and "never told anything", and the two license opposite
2230 /// actions on a PROCESS-GLOBAL gate. Without this, every executor that
2231 /// never heard of the parameter wrote `set_active(false)` on its first
2232 /// spin -- because the gate defaults to TRUE, so `false != is_active()`
2233 /// held -- and switched off a simulated clock somebody else installed.
2234 /// One `cargo test` process is exactly that situation: 344 sibling tests
2235 /// spin an executor each, and the sim-time test failed 3 runs of 3 while
2236 /// passing alone.
2237 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2238 pub(crate) sim_time_stated: bool,
2239 /// phase-430 W1 — the one-shot that says "`use_sim_time` is on and `/clock`
2240 /// has never spoken", so a MISCONFIGURED image is distinguishable from a
2241 /// merely PAUSED one. Armed when the source attaches, disarmed by the first
2242 /// sample or by the report. The policy, and the argument for its unit, are
2243 /// in [`crate::time_source::SilenceWatch`].
2244 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2245 pub(crate) sim_time_silence: crate::time_source::SilenceWatch,
2246 #[cfg(feature = "lifecycle-services")]
2247 pub(crate) lifecycle:
2248 Option<alloc::boxed::Box<crate::lifecycle_services::LifecycleRuntimeState>>,
2249 /// Wall-clock instant at which the previous `spin_once` exited. The
2250 /// timer delta on the next call is measured from this point so any
2251 /// time the caller spent between `spin_once` invocations (e.g. an
2252 /// explicit `thread::sleep`) counts toward timer accumulation just
2253 /// like time spent inside `drive_io`.
2254
2255 /// Monotonic clock endpoint for no_std timer accounting.
2256 // phase-359 W4 — ONE field. The std twin held an `Instant`; both now hold
2257 // µs from the single `now_us()` read.
2258 pub(crate) last_spin_end_us: Option<u64>,
2259 /// The executor's monotonic µs clock: `ExecutorConfig::clock_us` when the
2260 /// caller supplied one, else [`default_clock_us_fn`].
2261 ///
2262 /// phase-359 W10 — no longer `no_std`-only. The std build read an
2263 /// `Instant` through a separate field instead, which is how "what time is
2264 /// it" had two answers in one crate.
2265 pub(crate) clock_us_fn: Option<fn() -> u64>,
2266
2267 /// Consecutive `drive_io` failures on the PRIMARY session (issue 0324).
2268 ///
2269 /// Reset to 0 by any successful drive. A session that has died — router
2270 /// gone, lease expired, socket closed — used to keep returning `Ok(())`
2271 /// from `spin()` forever: the node looked alive, publishes went nowhere,
2272 /// and no callback fired. Nothing in the crate could report it; a
2273 /// `git grep` for a health surface found none, so every such
2274 /// investigation started from packet captures (issue 0268 burned days
2275 /// this way).
2276 ///
2277 /// A COUNTER rather than propagating the error out of `spin_once`,
2278 /// deliberately: `drive_io` returns `Err` for any non-OK backend code, and
2279 /// whether a benign poll timeout maps to one is backend-specific. Aborting
2280 /// the spin on a single failure would risk turning a transient into a dead
2281 /// node. A counter cannot regress behaviour and still makes the condition
2282 /// observable via [`Executor::session_io_failures`].
2283 pub(crate) consecutive_io_failures: u32,
2284 /// RFC-0052 W3b.2 — wall-clock (epoch µs) source for age monitors.
2285 pub(crate) epoch_us_fn: Option<fn() -> u64>,
2286 /// phase-483 W3 — the component registration frame the `nros` facade
2287 /// installs for the duration of a component's `register`, so the
2288 /// declarative entity constructors on [`Node`](super::node::Node) can reach
2289 /// the sink that owns the component's cell. Opaque here: nros-node sits
2290 /// below the crate that defines the frame. `None` outside a registration.
2291 pub(crate) component_frame: Option<core::ptr::NonNull<()>>,
2292 /// RFC-0052 W3b.4 — baked contract-monitor table (empty = uncontracted
2293 /// image; every monitor path below folds away).
2294 pub(crate) monitor_table: &'static [super::monitor::MonitorSpec],
2295 pub(crate) monitor_states: [super::monitor::MonitorState; super::monitor::MAX_MONITORS],
2296 /// Alive supervision, one record per SchedContext: dispatches are counted
2297 /// in the dispatch path where the SC index is already resolved, and read
2298 /// once per monitor tick. CARVED from the backing like `sporadic_states`.
2299 /// It began as an inline `[_; MAX_SC]` pair on the claim that `MAX_SC` is a
2300 /// build-time constant; it is a SIZING KNOB (`ExecutorSizing::sc`), so the
2301 /// header grew with it and `the_executor_value_does_not_scale_with_the_knobs`
2302 /// caught it (issue 0961).
2303 pub(crate) alive_slots: &'s mut [super::monitor::AliveSlot],
2304 /// phase-476 W0 — per-callback-slot lifetime tags, parallel to
2305 /// `entries`: the generation a handle must match, and the owning node.
2306 pub(crate) slot_tags: &'s mut [super::types::SlotTag],
2307 /// W3b.5 — baked subscriber age-contract table (empty = none).
2308 pub(crate) age_table: &'static [super::monitor::AgeMonitorSpec],
2309 pub(crate) age_states: [super::monitor::AgeState; super::monitor::MAX_AGE_MONITORS],
2310 /// phase-462 W3b — baked hazard table (empty = no contracted hazard). Its
2311 /// state lives in the rows' own `static` cells, so a hazard costs this
2312 /// value nothing.
2313 pub(crate) hazard_table: &'static [super::monitor::HazardSpec],
2314 /// phase-462 W4 -- the baked mode variants of the two monitor tables,
2315 /// index = mode id (0 = the base). Empty for an image with no modes.
2316 pub(crate) mode_tables: &'static [super::monitor::ModeTables],
2317 /// phase-462 W4 -- the last mode request this executor saw, and the mode
2318 /// whose tables it judges by. They differ only after a request for a mode
2319 /// this executor has no table for.
2320 pub(crate) mode_seen: u16,
2321 /// phase-462 W4c -- monotonic ms of the previous mode check, the start
2322 /// of the window a request seen at this one could have waited in.
2323 pub(crate) mode_checked_ms: Option<u32>,
2324 pub(crate) mode_applied: u16,
2325 /// W3b.5 — hook invoked on `DeadlineAction::Fault` (panic when unset).
2326 pub(crate) fault_fn: Option<fn(&super::monitor::Violation)>,
2327 /// phase-503 — the reaction hook and its context: called when a
2328 /// supervised route passes its deadline (`route-deadline-runtime`), and on
2329 /// `DeadlineAction::Fault` when no `fault_fn` is set.
2330 pub(crate) reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
2331 /// phase-509 P9b (RFC-0108 "As built in P9b") -- the lock every path that
2332 /// touches this executor takes once a second thread can reach it (one
2333 /// worker per level over one executor). `None` -- every one-level image --
2334 /// makes each guard a no-op. Read through a raw pointer by
2335 /// [`super::exec_lock::ExecGuard::acquire`], so it is written only while
2336 /// no second thread exists.
2337 pub(crate) exec_lock: Option<super::exec_lock::ExecLock>,
2338 /// phase-509 P9b item 2 -- the callbacks running in an open window, one
2339 /// record per window, each in its pass's frame (`exec_lock::Running`).
2340 /// Read and written only under the lock; null while no window is open.
2341 pub(crate) running: *mut super::exec_lock::Running,
2342 /// Issue #515 — the spin cadence audit runs once, on the first spin
2343 /// that carries a non-zero timeout (which is where the tier's
2344 /// declared spin period becomes visible to the executor).
2345 pub(crate) spin_quantization_checked: bool,
2346 /// phase-436 W1 — the cadence the quantization audit (issue 0515) actually
2347 /// ran against. Recorded because the audit only LOGS: without this a
2348 /// regression that fed it the wrong number is invisible to a test, and
2349 /// capping the park by the timer deadline is exactly such a regression
2350 /// waiting to happen.
2351 pub(crate) spin_quantization_us: u64,
2352 /// Release jitter: worst observed lateness of a `spin_period` wake
2353 /// against its own nominal schedule, in microseconds.
2354 ///
2355 /// Issue #515 added a STATIC audit for one cause of cadence error --
2356 /// a period that is not a multiple of the spin period. Nothing measured
2357 /// the actual release instants, and `audit_spin_quantization` says so in
2358 /// as many words: "the rate is preserved and no activation is dropped,
2359 /// so every runtime rule is (correctly) silent -- the jitter stays
2360 /// invisible until someone measures cadence on target."
2361 ///
2362 /// This is that measurement, and it is the one `cyclictest` exists to
2363 /// report: the deviation between a timer's programmed wake-up and the
2364 /// instant the task actually resumed, whose MAXIMUM is the figure of
2365 /// merit for a real-time system.
2366 ///
2367 /// `spin_period` already holds both numbers -- `next_us` is the nominal
2368 /// deadline and `now_us()` the actual -- and when the loop is late it
2369 /// skips the sleep and discards the difference. That difference is the
2370 /// jitter.
2371 pub(crate) max_release_jitter_us: u64,
2372 /// Clock reading at the previous `spin_once` entry, for the interval the
2373 /// jitter is measured over. `None` until the first spin, and reset by
2374 /// `clear_release_jitter_stats` so a window starts clean.
2375 pub(crate) last_spin_entry_us: Option<u64>,
2376 /// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with
2377 /// no declared cadence. See [`ReleaseGrid`].
2378 pub(crate) grid: ReleaseGrid,
2379 /// Jitter high-water at the previous `release-jitter-runtime` check, so
2380 /// the rule reports the DELTA. Same shape as `overruns_reported` on a
2381 /// timer header, and for the same reason: a maximum that has not moved
2382 /// is the fault already reported, not a new one.
2383 pub(crate) jitter_reported_us: u64,
2384 /// phase-509 P6 (RFC-0108 D3) -- this executor's one worker: its band
2385 /// (every dispatch level), its park (RFC-0106) and its stack record. The
2386 /// fields moved here from the executor unchanged; `Executor` is the
2387 /// one-level facade over the scheduler / worker split.
2388 pub(crate) worker: super::scheduler::Worker,
2389 /// The pacing quantum the spin loop was last driven at, in microseconds.
2390 /// This is the bound the jitter rule judges against -- the caller's own
2391 /// declared cadence, so nothing further has to be declared.
2392 pub(crate) spin_nominal_us: u64,
2393 /// Cadence DECLARED by the driver, in microseconds; `0` = not declared.
2394 ///
2395 /// Takes precedence over the `spin_once` timeout when judging release
2396 /// jitter, because the two are not the same quantity. The timeout is how
2397 /// long a spin may BLOCK waiting for work; the cadence is how often the
2398 /// loop intends to come round. A driver that blocks up to 10 ms and then
2399 /// sleeps 30 ms has a 30 ms cadence and a 10 ms timeout, and judging it
2400 /// against the timeout reports a loop that is late on every single wake.
2401 pub(crate) spin_nominal_declared_us: u64,
2402 /// phase-436 A2b -- the declared cadence is met by the `spin_once` WAIT
2403 /// itself (`set_spin_nominal_wait_paced_us`), not by a sleep the caller
2404 /// adds between spins (`set_spin_nominal_us`). A wait-paced cadence runs
2405 /// on the release grid and is worth one park granule; a self-paced one is
2406 /// judged from entry to entry and is worth a microsecond.
2407 pub(crate) spin_nominal_wait_paced: bool,
2408 /// Wakes that were already past their nominal deadline, and wakes total.
2409 ///
2410 /// The maximum alone cannot distinguish one bad wake from a loop that is
2411 /// late every single cycle, and those are different faults: the first is
2412 /// a glitch, the second means the period cannot be met at all.
2413 pub(crate) late_wakes: u32,
2414 pub(crate) total_wakes: u32,
2415 /// phase-436 A2c -- late wakes whose lateness exceeds the granularity the
2416 /// measurement was made at (`release_jitter_granularity_us`). On a loop
2417 /// paced by a tick-granular park nearly every wake is late by a fraction
2418 /// of a tick, by design; `late_wakes` counts those, this does not.
2419 pub(crate) late_wakes_beyond_granule: u32,
2420 /// Issue 0514 / 1635 / phase-474 I1 -- what happens to a detected contract
2421 /// violation: the log switch, the drain-and-report hook, the image's sink,
2422 /// and the ring (CARVED, phase-409) with its total and dropped counters.
2423 /// See [`super::monitor::ViolationChannel`].
2424 pub(crate) violations: super::monitor::ViolationChannel<'s>,
2425 /// Issue 0790 — hooks that run BEFORE the session is closed, while every
2426 /// entity still works. The load-bearing half: a device releasing a bus or
2427 /// parking an actuator has to publish its final state / answer its last
2428 /// request from HERE, because after teardown it cannot.
2429 ///
2430 /// `[Option<_>; N]` rather than a `heapless::Vec`, deliberately: the handle
2431 /// a caller holds IS the slot index, so a removal must leave every other
2432 /// index where it was. A `Vec`'s `swap_remove` would silently re-point one
2433 /// live handle at a different callback, and its `remove` would re-point all
2434 /// of them. Clearing a slot to `None` also lets the next registration reuse
2435 /// it, which a fill cursor could not.
2436 pub(crate) pre_shutdown_hooks:
2437 [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2438 /// Issue 0790 — hooks that run AFTER the session is closed. rclcpp's
2439 /// `add_on_shutdown_callback` / `rclcpp::on_shutdown`. See
2440 /// [`Self::pre_shutdown_hooks`] for why this is an array.
2441 pub(crate) on_shutdown_hooks:
2442 [Option<super::types::ShutdownHook>; crate::config::MAX_SHUTDOWN_CBS],
2443}
2444
2445impl<'s> Executor<'s> {
2446 /// phase-271 — assemble an executor over already-carved, caller-owned
2447 /// storage (`slices`, from [`super::storage::carve`]). Fills every
2448 /// non-storage field and reserves SC slot 0 for the default Fifo SC (carve
2449 /// left it `None`). The single builder shared by every constructor path.
2450 fn assemble(session: SessionStore, slices: super::storage::ExecutorSlices<'s>) -> Self {
2451 let super::storage::ExecutorSlices {
2452 arena,
2453 entries,
2454 sched_contexts,
2455 sched_context_bindings,
2456 sporadic_states,
2457 #[cfg(feature = "alloc")]
2458 sporadic_atomic_states,
2459 remaps,
2460 nodes,
2461 extra_sessions,
2462 extra_session_ids,
2463 node_sched_table,
2464 component_slots,
2465 active_groups,
2466 group_sched_table,
2467 monitor_violations,
2468 alive_slots,
2469 slot_tags,
2470 params: param_slots,
2471 param_home,
2472 } = slices;
2473 // phase-382 W3' — only an executor that can build a store keeps the
2474 // carved slots; without `param-services` the region (sized by the
2475 // build, not by a feature, so the layout never depends on one) is
2476 // simply never initialised. The services' home (issue 1812) is
2477 // zero-sized without the feature.
2478 #[cfg(not(feature = "param-store"))]
2479 let _ = (param_slots, param_home);
2480 // Slot 0 = the auto-created default Fifo SC (see field doc). carve
2481 // initialised the whole table to `None`; populate the reserved slot.
2482 if let Some(slot0) = sched_contexts.first_mut() {
2483 *slot0 = Some(super::sched_context::SchedContext::default());
2484 }
2485 // phase-412 -- stamp the self-report before anything can fail. `init`
2486 // is idempotent, so an image with two executors need not decide which
2487 // one owns the record, and `note_arena_capacity` records the slice this
2488 // executor was actually handed rather than the compiled constant: the
2489 // arena's placement is the caller's choice (issue 0900), so the two can
2490 // legitimately differ and the difference is worth seeing.
2491 crate::boot_report::init();
2492 crate::boot_report::note_arena_capacity(arena.len());
2493 crate::boot_report::checkpoint(crate::boot_report::Stage::ExecutorReady);
2494 Self {
2495 // `assemble` is reached from session-only entry points that have no
2496 // config, so 0 is the floor rather than a choice; `open_in` and any
2497 // binding that knows better overwrite it via `set_domain_id`.
2498 domain_id: 0,
2499 session,
2500 arena,
2501 arena_used: 0,
2502 arena_free_head: ARENA_FREE_NONE,
2503 pending_region: None,
2504 entries,
2505 sched_contexts,
2506 sched_context_bindings,
2507 sporadic_states,
2508 #[cfg(feature = "alloc")]
2509 sporadic_atomic_states,
2510 major_frame_us: 0,
2511 trigger: Trigger::Any,
2512 semantics: ExecutorSemantics::RclcppExecutor,
2513 node_name: heapless::String::new(),
2514 active_groups,
2515 active_groups_filtering: false,
2516 nodes,
2517 node_sched_table,
2518 group_sched_table,
2519 remap_table: remaps,
2520 remap_len: 0,
2521 launch_remaps: None,
2522 component_slots,
2523 deferred_mask: 0,
2524 deferred_pending: 0,
2525 defer_notify: None,
2526 extra_sessions,
2527 extra_session_ids,
2528 primary_rmw_name: heapless::String::new(),
2529 primary_locator: heapless::String::new(),
2530 namespace: {
2531 let mut ns = heapless::String::new();
2532 let _ = ns.push_str("/");
2533 ns
2534 },
2535 // Issue 1177 -- the only cfg the halt flag still needs. `HaltFlag`
2536 // is refcounted with an allocator and plain without one, and
2537 // nothing below this line can tell the difference.
2538 #[cfg(feature = "alloc")]
2539 halt_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2540 #[cfg(not(feature = "alloc"))]
2541 halt_flag: portable_atomic::AtomicBool::new(false),
2542 spinning: portable_atomic::AtomicBool::new(false),
2543 #[cfg(feature = "alloc")]
2544 wake_flag: portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false)),
2545 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2546 node_wake: super::node_wake::NodeWake::new().map(portable_atomic_util::Arc::new),
2547 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2548 wake_ctx: None,
2549 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2550 wake_cb_installed: false,
2551 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2552 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2553 wake_membership: None,
2554 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2555 graph_change_target: None,
2556 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2557 has_async_wake: false,
2558 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2559 entity_attributed: 0,
2560 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2561 entity_recheck: 0,
2562 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2563 entity_status_polled: 0,
2564 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2565 readiness_probes: 0,
2566 // Phase 141.A.3 — alloc-mode wake state init. Constructed
2567 // eagerly (NodeWake allocation) so the runtime cb can be
2568 // installed lazily on first session without a fallible
2569 // alloc inside spin_once. `None` when the platform
2570 // provider reports the primitive unavailable (matches
2571 // the std-RTOS path's `node_wake: Option<...>`).
2572 #[cfg(all(feature = "signal-fd-wake", target_os = "linux"))]
2573 signal_fd: None,
2574 #[cfg(feature = "param-store")]
2575 params: None,
2576 #[cfg(feature = "param-store")]
2577 param_slots,
2578 #[cfg(feature = "param-store")]
2579 param_home,
2580 #[cfg(feature = "param-store")]
2581 param_store_refused: false,
2582 #[cfg(feature = "param-store")]
2583 declared_params: nros_params::DeclaredParams::EMPTY,
2584 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2585 sim_time_source: None,
2586 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2587 sim_time_requested: false,
2588 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2589 sim_time_stated: false,
2590 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
2591 sim_time_silence: crate::time_source::SilenceWatch::new(),
2592 #[cfg(feature = "lifecycle-services")]
2593 lifecycle: None,
2594 // Initialise the spin endpoint to construction time so the
2595 // very first `spin_once` credits time the caller spent
2596 // *before* it (e.g. setup, an explicit pre-spin sleep) just
2597 // like time spent between later calls.
2598 // One field, one seed now (phase-359 W10): the clock is absolute
2599 // on every flavour, so it is READ here rather than assumed to be
2600 // zero at construction. The std arm used to seed `Some(0)` because
2601 // its epoch WAS construction; with one provider that special case
2602 // is gone.
2603 last_spin_end_us: default_clock_us_fn().map(|clock| clock()),
2604 clock_us_fn: default_clock_us_fn(),
2605 consecutive_io_failures: 0,
2606 // RFC-0052 W3b.5 — a build with a wall clock gets one by default so
2607 // age monitors activate without extra wiring; a build with neither a
2608 // platform port nor a host gets `None`, and its board installs
2609 // `config.epoch_us` in `from_session_in`/`open`.
2610 //
2611 // phase-359 W10 — the cfg pair that used to be here moved into
2612 // `default_epoch_us_fn`, beside the `default_clock_us_fn` it
2613 // mirrors. (The old comment pointed at "the `not(std)` blocks
2614 // above" for the board install; those blocks are no longer
2615 // cfg-gated — the config override applies on every flavour now.)
2616 epoch_us_fn: super::types::default_epoch_us_fn(),
2617 component_frame: None,
2618 monitor_table: &[],
2619 monitor_states: [super::monitor::MonitorState::default(); super::monitor::MAX_MONITORS],
2620 age_table: &[],
2621 age_states: [super::monitor::AgeState::default(); super::monitor::MAX_AGE_MONITORS],
2622 hazard_table: &[],
2623 mode_tables: &[],
2624 mode_seen: 0,
2625 mode_checked_ms: None,
2626 mode_applied: 0,
2627 fault_fn: None,
2628 reaction_hook: None,
2629 exec_lock: None,
2630 running: core::ptr::null_mut(),
2631 spin_quantization_checked: false,
2632 reprobe_each_callback: REPROBE_EACH_CALLBACK_DEFAULT,
2633 spin_quantization_us: 0,
2634 max_release_jitter_us: 0,
2635 last_spin_entry_us: None,
2636 grid: ReleaseGrid {
2637 period_us: 0,
2638 next_us: 0,
2639 skipped: 0,
2640 },
2641 jitter_reported_us: 0,
2642 worker: super::scheduler::Worker::one_level(),
2643 spin_nominal_us: 0,
2644 spin_nominal_declared_us: 0,
2645 spin_nominal_wait_paced: false,
2646 late_wakes: 0,
2647 total_wakes: 0,
2648 late_wakes_beyond_granule: 0,
2649 violations: super::monitor::ViolationChannel::new(monitor_violations),
2650 alive_slots,
2651 slot_tags,
2652 // Issue 0790 — both phase tables start empty. An image that
2653 // registers nothing pays these `None`s and a two-slot scan at
2654 // teardown, and nothing else.
2655 pre_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2656 on_shutdown_hooks: [None; crate::config::MAX_SHUTDOWN_CBS],
2657 }
2658 }
2659
2660 /// Create an owning executor over caller-supplied `backing`, sized by
2661 /// `sizing`. The core, non-generic, per-entry entry point (the `alloc`
2662 /// [`from_session`](Self::from_session) convenience leaks a default backing
2663 /// and calls this; the macro / C FFI pass an entry-sized backing).
2664 ///
2665 /// # Safety
2666 /// `backing` must be ≥ `sizing.u64_len()` words, stay alive for `'s`, and
2667 /// not be otherwise accessed while the executor lives (it aliases it).
2668 /// `sizing.cbs` must be ≤ 64 (the `u64` ready-set bitmask ceiling).
2669 pub(crate) unsafe fn from_session_in(
2670 session: session::ConcreteSession,
2671 backing: &'s mut [MaybeUninit<u64>],
2672 sizing: super::storage::ExecutorSizing,
2673 ) -> Self {
2674 let slices = unsafe { super::storage::carve(backing, sizing) };
2675 Self::assemble(SessionStore::Owned(session), slices)
2676 }
2677
2678 /// Create a borrowing executor over caller-supplied `backing`, sized by
2679 /// `sizing`. Counterpart to [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in) for
2680 /// the per-tier / C model (the session is borrowed, not owned).
2681 ///
2682 /// # Safety
2683 /// - `session_ptr` must point to a valid session that outlives the executor
2684 /// and is not moved/dropped while it exists.
2685 /// - `backing` obligations as in [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in).
2686 pub(crate) unsafe fn from_session_ptr_in(
2687 session_ptr: *mut session::ConcreteSession,
2688 backing: &'s mut [MaybeUninit<u64>],
2689 sizing: super::storage::ExecutorSizing,
2690 ) -> Self {
2691 let slices = unsafe { super::storage::carve(backing, sizing) };
2692 Self::assemble(SessionStore::Borrowed(session_ptr), slices)
2693 }
2694}
2695
2696impl Executor<'static> {
2697 /// Create an executor from an already-opened session, using the build-time
2698 /// default sizing (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`). Convenience for
2699 /// std/alloc callers that don't size per-entry: it leaks a default-sized
2700 /// backing (executor-lifetime, one-time) and calls
2701 /// [`from_session_in`](crate::executor::ffi::ExecutorFfi::from_session_in). Per-entry sizing goes through
2702 /// the macro / `open_in` instead.
2703 #[cfg(feature = "alloc")]
2704 pub fn from_session(session: session::ConcreteSession) -> Self {
2705 let sizing = super::storage::ExecutorSizing::DEFAULT;
2706 // SAFETY: the backing is exactly `sizing.u64_len()` words,
2707 // `'static`, and uniquely owned by this executor.
2708 unsafe { Self::from_session_in(session, default_backing(sizing), sizing) }
2709 }
2710
2711 /// [`from_session`](Self::from_session) with an [`ExecutorConfig`], so a
2712 /// caller that brings its own session can also bring its own clock.
2713 ///
2714 /// issue 0709 / issue 0687 — `from_session` takes no config, and that is
2715 /// the path the no-port population uses: it accepts any `Session`, so a
2716 /// consumer with a non-cffi backend reaches the executor through it and had
2717 /// NO way to install `clock_us`. phase-359 W10 argued the `std`-without-a-
2718 /// port clock fallbacks could go because "a caller with a clock installs it
2719 /// through `ExecutorConfig::clock_us`" — true for `open`, false here, which
2720 /// is half of why that deletion was reverted.
2721 ///
2722 /// Only the timing sources are read from `config`; identity (locator,
2723 /// domain, names) belongs to the session the caller already opened. As in
2724 /// [`open_in`](Self::open_in), a `None` field does NOT clobber the
2725 /// platform default — it means "not specified" (the bug issue 0671
2726 /// records).
2727 #[cfg(feature = "alloc")]
2728 pub fn from_session_with(
2729 session: session::ConcreteSession,
2730 config: &super::types::ExecutorConfig<'_>,
2731 ) -> Self {
2732 let mut executor = Self::from_session(session);
2733 if let Some(clock) = config.clock_us {
2734 executor.clock_us_fn = Some(clock);
2735 executor.last_spin_end_us = Some(clock());
2736 }
2737 if let Some(epoch) = config.epoch_us {
2738 executor.epoch_us_fn = Some(epoch);
2739 }
2740 executor
2741 }
2742
2743 /// Create an executor from a borrowed session pointer, default-sized. The
2744 /// `alloc` convenience wrapper over
2745 /// [`from_session_ptr_in`](Self::from_session_ptr_in) — leaks a default
2746 /// backing so existing callers keep the zero-storage-arg signature.
2747 ///
2748 /// # Safety
2749 /// - `session_ptr` must point to a valid, initialized session that lives at
2750 /// least as long as this executor.
2751 /// - The caller must not move or drop the session while the executor exists.
2752 #[cfg(feature = "alloc")]
2753 pub(crate) unsafe fn from_session_ptr(session_ptr: *mut session::ConcreteSession) -> Self {
2754 let sizing = super::storage::ExecutorSizing::DEFAULT;
2755 // SAFETY: the backing as in `from_session`; session_ptr contract
2756 // forwarded to `from_session_ptr_in`.
2757 unsafe { Self::from_session_ptr_in(session_ptr, default_backing(sizing), sizing) }
2758 }
2759}
2760
2761/// phase-474 I11 -- the release grid of a `spin_once(timeout)` loop with no
2762/// declared cadence: the period it was built for (`0` = no grid yet), the next
2763/// scheduled release, and the releases skipped because the loop came back a
2764/// whole period or more after they were due (the timer `Skip` policy: the
2765/// latest due point is served once, the older ones are dropped and counted,
2766/// never released in a burst). The park is bounded by `next_us`, so a loop is
2767/// released on `anchor + k * period` instead of `period` after its previous
2768/// spin ended. Scales with no knob; named in the header-size budget.
2769#[derive(Clone, Copy, Debug, Default)]
2770pub(crate) struct ReleaseGrid {
2771 pub(crate) period_us: u64,
2772 pub(crate) next_us: u64,
2773 pub(crate) skipped: u32,
2774}
2775
2776/// The two cadences a `period_us` timer alternates between under a `spin_us`
2777/// spin period, or `None` when the audit (issue #515) has nothing to say.
2778///
2779/// Split out of `audit_spin_quantization` so the decision can be asserted:
2780/// the audit only LOGS, so a regression in what it decides is otherwise
2781/// invisible to a test.
2782///
2783/// `None` for a period of zero, for a period that divides the spin period —
2784/// and, since issue 1321, for a timer whose activations are NOT paced by the
2785/// spin period at all. The warning's arithmetic is wall microseconds on both
2786/// sides; a `Ros` timer's activations are quantised by the `/clock` STEP, so
2787/// the sentence it would print ("activations will alternate between X and Y
2788/// us") is about nothing.
2789pub(crate) fn spin_quantization_span(
2790 clock_source: TimerClockSource,
2791 period_us: u64,
2792 spin_us: u64,
2793) -> Option<(u64, u64)> {
2794 if spin_us == 0 || period_us == 0 || period_us.is_multiple_of(spin_us) {
2795 return None;
2796 }
2797 // phase-521 W6 — an entry's idle budget is no cadence: its park ends at
2798 // each timer's own deadline, so nothing quantizes.
2799 if spin_us >= ENTRY_IDLE_BUDGET.as_micros() as u64 {
2800 return None;
2801 }
2802 if !clock_source.remaining_is_wall_time() {
2803 return None;
2804 }
2805 let early_us = (period_us / spin_us) * spin_us;
2806 Some((early_us, early_us.saturating_add(spin_us)))
2807}
2808
2809/// phase-436 — which deadline source bounded the last park.
2810///
2811/// Attribution is what turns "why did we wake" from a guess into a number.
2812/// Recording it costs one byte per spin and is the difference between a
2813/// schedulability argument you can audit and one you have to trust.
2814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2815#[repr(u8)]
2816pub enum WakeSourceId {
2817 /// The `spin_once` caller's own timeout — always a member of the source
2818 /// set, which is why the park is never unbounded.
2819 CallerBudget,
2820 /// A registered timer's next expiry (issue 1192).
2821 Timer,
2822 /// The backend's next internal event (lease keepalive, heartbeat,
2823 /// ACK-NACK). Folded into the same `min` as everything else rather than
2824 /// capping the budget separately upstream, so one rule decides the park
2825 /// and one attribution explains it.
2826 Session,
2827 /// A source the PLATFORM registered: a `k_poll` event set, an `epoll`
2828 /// group, a FreeRTOS queue set, a ThreadX event-flags group, or a
2829 /// bare-metal device ISR. The core never names an RTOS; it only asks each
2830 /// registered source when it will next need the executor.
2831 Platform(u8),
2832 /// phase-503 — the earliest deadline of a pending supervised route: the
2833 /// park ends there so `route-deadline-runtime` judges it on time.
2834 RouteDeadline,
2835}
2836
2837/// How many platform sources one executor can carry. A plain const, not a
2838/// sizing knob: the table is inline, so no allocator is required on the no_std
2839/// targets this seam mainly exists for.
2840pub const MAX_WAKE_SOURCES: usize = 8;
2841
2842/// phase-521 W3 -- the most `ReadinessSource::Waitable` handles one park
2843/// multiplexes: one per session of a tier, and the POSIX port's own cap.
2844#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2845pub(crate) const MAX_WAITABLE_HANDLES: usize = 8;
2846
2847/// When this source will next need the executor, in the executor's clock,
2848/// as microseconds FROM NOW.
2849///
2850/// `u64::MAX` means "nothing pending" — the source does not shorten the park.
2851/// That is how an ASYNC source spells itself: a socket or a DDS listener
2852/// contributes no deadline and instead breaks the park by signalling the wake
2853/// primitive, so async and deadline sources compose without a second
2854/// mechanism.
2855pub type NextDeadlineFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void) -> u64;
2856
2857/// Park until `deadline_us` from now, or until something signals, whichever
2858/// comes first.
2859///
2860/// Returns [`PARK_SIGNALLED`] (`0`) when woken by an event, [`PARK_EXPIRED`]
2861/// (`1`) when the deadline expired, [`PARK_YIELDED`] (`2`) when the slot did
2862/// not wait but handed the wait to its framework's scheduler, and a negative
2863/// value when this build cannot park at all.
2864///
2865/// phase-521 W8 (c) (RFC-0113 D9, ruling 2026-11-03) -- the YIELDED answer is
2866/// how a tier that a framework schedules waits: a PX4 tier's slot arms its
2867/// work item's `ScheduleDelayed(deadline_us)` (or `ScheduleClear()` when the
2868/// bound is the caller's budget alone) and returns at once, and `spin_once`
2869/// then returns to the work queue instead of blocking. An arrival reaches the
2870/// tier through `ScheduleNow()`. No executor API widens for it: the bound the
2871/// slot receives is the one every park receives.
2872///
2873/// A DEADLINE, not a duration, and that distinction is what makes bare metal
2874/// work: `nros-baremetal-common`'s own note says `wfi` with no pending
2875/// interrupt deadlocks, so a board must arm a timer compare BEFORE waiting.
2876/// A `sleep(duration)` seam cannot express that — the sleeper does not know
2877/// whether anyone armed an interrupt. A `ParkUntilFn` does, because the
2878/// deadline is exactly what the compare is armed to.
2879pub type ParkUntilFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void, deadline_us: u64) -> i8;
2880
2881/// Issue 2225 (RFC-0113 D9) -- the wake half of a framework's park slot:
2882/// called after every signal of the executor's wake object, from the
2883/// signalling context. A framework whose tier is a scheduled item rather than
2884/// a parked thread (a PX4 work item) re-schedules the tier here. Must be
2885/// thread-safe, non-blocking and O(1).
2886pub type WakeHookFn = unsafe extern "C" fn(ctx: *mut core::ffi::c_void);
2887
2888/// [`ParkUntilFn`]: woken by an event (`NROS_PLATFORM_PARK_SIGNALLED`).
2889pub const PARK_SIGNALLED: i8 = 0;
2890/// [`ParkUntilFn`]: the deadline expired (`NROS_PLATFORM_PARK_EXPIRED`).
2891pub const PARK_EXPIRED: i8 = 1;
2892/// [`ParkUntilFn`]: the slot handed the wait to its framework's scheduler and
2893/// returned without waiting (`NROS_PLATFORM_PARK_YIELDED`); `spin_once`
2894/// returns without dispatching. See [`ParkUntilFn`].
2895pub const PARK_YIELDED: i8 = 2;
2896
2897/// A registered deadline contributor.
2898#[derive(Clone, Copy)]
2899pub(crate) struct WakeSourceSlot {
2900 pub(crate) next: NextDeadlineFn,
2901 pub(crate) ctx: *mut core::ffi::c_void,
2902}
2903
2904impl<'s> Executor<'s> {
2905 /// Phase 228.B (RFC-0015) — construct a tier task's executor that **shares**
2906 /// a session opened once by the orchestration `main()`.
2907 ///
2908 /// In the per-tier execution model `main()` opens one RMW session, then
2909 /// spawns one RTOS task per priority tier; each task calls this to get an
2910 /// [`Executor`] over the *same* session (the `Borrowed` session store — this
2911 /// executor neither owns nor closes it), registers its tier's callback
2912 /// groups, and spins. Thin alias over [`ExecutorFfi::from_session_ptr`](crate::executor::ffi::ExecutorFfi::from_session_ptr).
2913 ///
2914 /// # Safety
2915 /// `session` must outlive every executor/task built from it (the
2916 /// orchestration `main()` holds it and never returns / WFIs), and must not
2917 /// be mutated except through these executors' spin calls.
2918 #[cfg(feature = "alloc")]
2919 pub unsafe fn open_with_session(session: *mut session::ConcreteSession) -> Executor<'static> {
2920 #[allow(unused_mut)]
2921 let mut exec = unsafe { Executor::<'static>::from_session_ptr(session) };
2922 // phase-436 — join the session's wake fan-out, so this tier is woken
2923 // by its arrivals instead of sleeping out its whole bound.
2924 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2925 exec.install_wake_signal_on_primary();
2926 exec
2927 }
2928
2929 /// phase-271 — per-tier borrowed-session constructor over caller-supplied,
2930 /// per-tier-sized `backing`. The sized counterpart to
2931 /// [`open_with_session`](Self::open_with_session): each RTOS tier task owns
2932 /// its own backing so tiers size independently.
2933 ///
2934 /// # Safety
2935 /// `session` obligations as in [`open_with_session`](Self::open_with_session);
2936 /// `backing`/`sizing` as in [`from_session_ptr_in`](Self::from_session_ptr_in).
2937 pub(crate) unsafe fn open_with_session_in(
2938 session: *mut session::ConcreteSession,
2939 backing: &'s mut [MaybeUninit<u64>],
2940 sizing: super::storage::ExecutorSizing,
2941 ) -> Self {
2942 #[allow(unused_mut)]
2943 let mut exec = unsafe { Self::from_session_ptr_in(session, backing, sizing) };
2944 // phase-436 — as `open_with_session`.
2945 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2946 exec.install_wake_signal_on_primary();
2947 exec
2948 }
2949
2950 /// Raw pointer to this executor's RMW session, for the per-tier model:
2951 /// the boot task opens the one session via [`Executor::open`] (the RMW
2952 /// session is a process-wide singleton — opening twice fails), then hands
2953 /// this pointer to each spawned tier task's
2954 /// [`Executor::open_with_session`]. The boot task's executor owns the
2955 /// session and outlives every borrower, so the pointer stays valid for the
2956 /// program's life. Works for both `Owned` and `Borrowed` stores.
2957 ///
2958 /// # Safety
2959 /// The returned pointer aliases `self.session`. Callers must keep `self`
2960 /// alive (not moved/dropped) for as long as any tier executor uses the
2961 /// pointer, and must only touch the session through executor spin calls
2962 /// (the RMW backend serializes concurrent access through its own locks).
2963 pub fn session_ptr(&mut self) -> *mut session::ConcreteSession {
2964 let ptr = &mut *self.session as *mut session::ConcreteSession;
2965 // phase-436 — the owner of an OWNED session claimed its wake fan-out
2966 // while this executor value could still move (inside `open*`). The
2967 // address handed out here is the one every tier is opened over, so
2968 // the entry is keyed by it from now on.
2969 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2970 if let (SessionStore::Owned(_), Some(m)) = (&self.session, self.wake_membership) {
2971 m.fanout().rekey(ptr as *mut core::ffi::c_void);
2972 }
2973 ptr
2974 }
2975
2976 /// Opaque, `Send` form of [`session_ptr`](Self::session_ptr) — the per-tier
2977 /// model hands this to each spawned tier task (it can cross the RTOS task /
2978 /// thread boundary, which a bare `*mut` cannot). See [`SessionHandle`].
2979 ///
2980 /// # Safety
2981 /// Same contract as [`session_ptr`](Self::session_ptr): `self` (the session
2982 /// owner) must outlive every executor built from the handle.
2983 pub fn session_handle(&mut self) -> SessionHandle {
2984 SessionHandle(self.session_ptr())
2985 }
2986
2987 /// Open an [`Executor`] over the session a [`SessionHandle`] refers to (the
2988 /// `Borrowed` store — neither owns nor closes it). The tier-task counterpart
2989 /// to [`session_handle`](Self::session_handle).
2990 ///
2991 /// # Safety
2992 /// The handle's session must still be alive (its owning executor not moved
2993 /// or dropped); access only through executor spin calls.
2994 #[cfg(feature = "alloc")]
2995 pub unsafe fn open_with_session_handle(handle: SessionHandle) -> Executor<'static> {
2996 unsafe { Executor::<'static>::open_with_session(handle.0) }
2997 }
2998
2999 /// phase-271 — sized counterpart to
3000 /// [`open_with_session_handle`](Self::open_with_session_handle) (per-tier
3001 /// backing).
3002 ///
3003 /// # Safety
3004 /// As [`open_with_session_handle`](Self::open_with_session_handle) +
3005 /// [`from_session_ptr_in`](Self::from_session_ptr_in).
3006 pub(crate) unsafe fn open_with_session_handle_in(
3007 handle: SessionHandle,
3008 backing: &'s mut [MaybeUninit<u64>],
3009 sizing: super::storage::ExecutorSizing,
3010 ) -> Self {
3011 unsafe { Self::open_with_session_in(handle.0, backing, sizing) }
3012 }
3013
3014 /// issue 1571 — a SPAWNED tier's executor over its slot of the entry's
3015 /// [`TierExecutorBacking`](super::TierExecutorBacking) — the road every
3016 /// board's `run_tiers` takes for each tier but the boot one.
3017 ///
3018 /// The slot's TYPE is the size: it is `ExecutorSizing::DEFAULT.u64_len()`
3019 /// words and this opens with `ExecutorSizing::DEFAULT`, so the pairing is
3020 /// exact by construction and lives here, once, rather than at five boards.
3021 /// [`open_with_session`](Self::open_with_session) is the heap twin this
3022 /// replaced on those roads.
3023 ///
3024 /// # Safety
3025 /// `session` obligations as in [`open_with_session`](Self::open_with_session).
3026 #[cfg(feature = "alloc")]
3027 pub(crate) unsafe fn open_with_session_slot(
3028 session: *mut session::ConcreteSession,
3029 slot: &'s mut super::TierExecutorBackingSlot,
3030 ) -> Self {
3031 unsafe {
3032 Self::open_with_session_in(
3033 session,
3034 &mut slot[..],
3035 super::storage::ExecutorSizing::DEFAULT,
3036 )
3037 }
3038 }
3039
3040 /// issue 1571 — [`open_with_session_slot`](Self::open_with_session_slot)
3041 /// over a [`SessionHandle`], for the boards whose tier tasks carry one.
3042 ///
3043 /// # Safety
3044 /// As [`open_with_session_handle`](Self::open_with_session_handle).
3045 #[cfg(feature = "alloc")]
3046 pub(crate) unsafe fn open_with_session_handle_slot(
3047 handle: SessionHandle,
3048 slot: &'s mut super::TierExecutorBackingSlot,
3049 ) -> Self {
3050 unsafe { Self::open_with_session_slot(handle.0, slot) }
3051 }
3052
3053 /// Phase 228.C — set this tier executor's active callback-group filter. The
3054 /// generated per-tier task calls this before registering nodes; afterwards
3055 /// only callbacks whose `.callback_group()` is in `groups` register here.
3056 /// An empty slice (or never calling it) leaves the wildcard — register all
3057 /// callbacks (the single-tier degenerate case + today's behaviour).
3058 pub fn set_active_groups(
3059 &mut self,
3060 groups: &[(&str, &str, &str)],
3061 ) -> Result<(), super::types::BindError> {
3062 // phase-409 — the table is CARVED and reused, so clear before refilling;
3063 // the old `Option<heapless::Vec>` got a fresh empty vector each call.
3064 self.active_groups.clear();
3065 if groups.is_empty() {
3066 self.active_groups_filtering = false;
3067 return Ok(());
3068 }
3069 for (name, ns, group) in groups {
3070 // issue 1172 — this used to be `if s.push_str(g).is_ok()`, which
3071 // DROPPED an oversized group name and left the tier filtering on a
3072 // set that silently lacked it. It fails closed (the entity is never
3073 // created), so the symptom is a missing entity with nothing to read
3074 // that says why. Same contract as `declare_remap` below: too long,
3075 // or table full, is the caller's error.
3076 let key = super::storage::node_identity_hash(name, ns);
3077 let mut s = heapless::String::new();
3078 let why = if s.push_str(group).is_err() {
3079 Some(super::types::BindError::NameTooLong)
3080 } else if self.active_groups.push((key, s)).is_err() {
3081 Some(super::types::BindError::TableFull)
3082 } else {
3083 None
3084 };
3085 if let Some(why) = why {
3086 // FAIL CLOSED, and say so. Returning here with a PARTIAL table
3087 // would leave the tier filtering on a subset — the same silent
3088 // narrowing, one step later. An empty table with filtering ON
3089 // accepts NOTHING (`group_filter_accepts` matches on an empty
3090 // slice), so the tier registers no entities at all: visible in
3091 // behaviour, where a missing entry is not. The `Err` is for the
3092 // caller that can report it; the state is safe for the one that
3093 // cannot.
3094 self.active_groups.clear();
3095 self.active_groups_filtering = true;
3096 return Err(why);
3097 }
3098 }
3099 self.active_groups_filtering = true;
3100 Ok(())
3101 }
3102
3103 /// The current callback-group filter, or `None` for the wildcard.
3104 fn active_group_filter(&self) -> Option<&[super::storage::ActiveGroup]> {
3105 self.active_groups_filtering
3106 .then(|| self.active_groups.as_slice())
3107 }
3108
3109 /// Phase 228.C — whether a callback in `group` should register in this
3110 /// executor under the current filter. The wildcard accepts everything.
3111 pub fn group_active(&self, node_name: &str, node_namespace: &str, group: &str) -> bool {
3112 group_filter_accepts(
3113 self.active_group_filter(),
3114 super::storage::node_identity_hash(node_name, node_namespace),
3115 group,
3116 )
3117 }
3118
3119 /// Set the node name and namespace used for liveliness tokens.
3120 ///
3121 /// Called by `open()` to propagate config values. When the executor
3122 /// creates entities, these values are attached to the
3123 /// Phase 156 — record the primary session's backend identity
3124 /// (rmw name + locator) so `NodeBuilder::resolve_session_slot`
3125 /// can detect when a `.rmw(name)` matches the primary instead
3126 /// of opening a SECOND backend session against the same
3127 /// singleton (zenoh-pico's `g_session` is process-wide;
3128 /// opening twice fails). `Executor::open*` calls this
3129 /// automatically; the C surface (`rclc_executor_init`) calls
3130 /// it manually because it constructs via `from_session_ptr`
3131 /// which doesn't know the open metadata. Empty strings = "no
3132 /// primary identity tracked"; the cache check degrades to
3133 /// always-miss.
3134 pub(crate) fn set_primary_identity(&mut self, rmw_name: &str, locator: &str) {
3135 self.primary_rmw_name.clear();
3136 let _ = self.primary_rmw_name.push_str(rmw_name);
3137 self.primary_locator.clear();
3138 let _ = self.primary_locator.push_str(locator);
3139 }
3140
3141 /// `TopicInfo`/`ServiceInfo` so the zenoh backend can declare liveliness.
3142 /// Issue 0656 — set the ROS domain for entities this executor declares.
3143 ///
3144 /// For bindings that build an executor from an existing session
3145 /// (`from_session_ptr_in`), where no `ExecutorConfig` is available and the
3146 /// domain would otherwise stay at its 0 floor.
3147 pub fn set_domain_id(&mut self, domain_id: u32) {
3148 self.domain_id = domain_id;
3149 }
3150
3151 /// The ROS domain this executor declares entities on.
3152 pub fn domain_id(&self) -> u32 {
3153 self.domain_id
3154 }
3155
3156 pub fn set_node_identity(&mut self, node_name: &str, namespace: &str) {
3157 self.node_name.clear();
3158 let _ = self.node_name.push_str(node_name);
3159 if !namespace.is_empty() {
3160 self.namespace.clear();
3161 let _ = self.namespace.push_str(namespace);
3162 }
3163 }
3164
3165 // =========================================================================
3166 // Phase 305 W3 (issue 0255) — per-node launch remap table
3167 // =========================================================================
3168
3169 /// Record one launch `<remap from= to=/>` rule for the node identified by
3170 /// `(node_name, namespace)`. Rules are matched in declaration order (first
3171 /// wins) by [`Self::resolve_entity_name_for`]. Errors when a string
3172 /// overflows its slot or the table is at `MAX_REMAPS` — callers surface
3173 /// this rather than silently dropping a routing rule.
3174 ///
3175 /// **This declares an AUTHORITATIVE rule.** Declaration order settles
3176 /// which of two launch rules for one name wins; it is not how an argv rule
3177 /// is ranked against a launch rule. The `--ros-args` rules share this table
3178 /// but are tagged as the fallback tier, and resolution reads both tiers
3179 /// through [`crate::names::resolve_name_layered`] — so an argv rule
3180 /// installed BEFORE a launch rule still loses to it.
3181 ///
3182 /// Issue 1971 -- a GENERATED entry no longer calls this: its launch rules
3183 /// are the flash table ([`super::launch_remap::set_launch_remaps`]). This
3184 /// writes the RUNTIME table, for hand-written callers; on bare metal that
3185 /// table is 0 slots by default and the call is refused by name
3186 /// ([`RemapRefused::NoRuntimeTable`]).
3187 pub fn declare_remap(
3188 &mut self,
3189 node_name: &str,
3190 namespace: &str,
3191 from: &str,
3192 to: &str,
3193 ) -> Result<(), RemapRefused> {
3194 if self.remap_table.is_empty() {
3195 return Err(RemapRefused::NoRuntimeTable);
3196 }
3197 let mut rule = RemapRule {
3198 node_name: heapless::String::new(),
3199 namespace: heapless::String::new(),
3200 from: heapless::String::new(),
3201 to: heapless::String::new(),
3202 tier: RemapTier::Launch,
3203 };
3204 let too_long = |_| RemapRefused::TooLong;
3205 rule.node_name.push_str(node_name).map_err(too_long)?;
3206 let ns = if namespace.is_empty() { "/" } else { namespace };
3207 rule.namespace.push_str(ns).map_err(too_long)?;
3208 rule.from.push_str(from).map_err(too_long)?;
3209 rule.to.push_str(to).map_err(too_long)?;
3210 // Same contract as the `heapless::Vec::push` this replaces: full table
3211 // is an error the caller surfaces, never a silently dropped rule.
3212 let slot = self
3213 .remap_table
3214 .get_mut(self.remap_len)
3215 .ok_or(RemapRefused::TableFull)?;
3216 *slot = Some(rule);
3217 self.remap_len += 1;
3218 Ok(())
3219 }
3220
3221 /// Resolve a source-level entity name for the node identified by
3222 /// `(node_name, namespace)`: ROS 2 name expansion (`~`/relative → FQN)
3223 /// plus this node's declared remap rules (exact-FQN match, first rule
3224 /// wins). Nodes with no rules still get expansion. Errors on an
3225 /// unexpandable name (see `crate::names::expand_name`).
3226 ///
3227 /// Two tiers, through [`crate::names::resolve_name_layered`]: the rules
3228 /// [`Self::declare_remap`] recorded for this node are AUTHORITATIVE (the
3229 /// generated entry declares the launch projection there), and the
3230 /// `--ros-args` rules `crate::ros_args::install_argv_remaps` put in the
3231 /// same table are the FALLBACK, consulted only when no launch rule
3232 /// matched. rcl does the same with its local and global arguments
3233 /// (`rcl_remap_topic_name`, Humble `rcl/remap.h`).
3234 #[allow(clippy::result_unit_err)]
3235 pub fn resolve_entity_name_for(
3236 &self,
3237 node_name: &str,
3238 namespace: &str,
3239 source: &str,
3240 ) -> Result<crate::names::ResolvedName, ()> {
3241 let ns = if namespace.is_empty() { "/" } else { namespace };
3242 self.remap_view().resolve(source, node_name, ns)
3243 }
3244
3245 /// Issue 1971 -- give this executor its own launch remap table instead of
3246 /// the image's ([`super::launch_remap::set_launch_remaps`]). A borrow of
3247 /// `.rodata`; nothing is copied.
3248 pub fn set_launch_remaps(&mut self, table: &'static [super::launch_remap::LaunchRemap]) {
3249 self.launch_remaps = Some(table);
3250 }
3251
3252 /// Both remap sources this executor resolves through: the launch table
3253 /// (flash) and the runtime table (RAM).
3254 pub(crate) fn remap_view(&self) -> RemapView<'_> {
3255 RemapView {
3256 launch: self
3257 .launch_remaps
3258 .unwrap_or_else(super::launch_remap::launch_remaps),
3259 runtime: &self.remap_table[..self.remap_len],
3260 }
3261 }
3262
3263 /// [`Self::resolve_entity_name_for`] against the executor's CURRENT node
3264 /// identity (`set_node_identity`) — the nros-c registration sites set that
3265 /// identity per node immediately before registering each entity.
3266 #[allow(clippy::result_unit_err)]
3267 pub fn resolve_entity_name(&self, source: &str) -> Result<crate::names::ResolvedName, ()> {
3268 self.resolve_entity_name_for(self.node_name.as_str(), self.namespace.as_str(), source)
3269 }
3270
3271 // =========================================================================
3272 // Phase 272 (RFC-0047) — node-name → sched-context table
3273 // =========================================================================
3274
3275 /// Seed a config-resolved tier binding by `(name, namespace)` before the
3276 /// node is built. `NodeBuilder::build` consults this table when no
3277 /// explicit `.sched()` override is given — the table entry then wins over
3278 /// the `SchedContextId(0)` default (precedence: explicit > table > 0).
3279 ///
3280 /// Call BEFORE `node_builder(name).build()`. An existing entry for the
3281 /// same `(name, namespace)` key is overwritten (last-write wins). Overflow
3282 /// past `MAX_NODES` is an `Err`, never a dropped binding — issue 1172. An
3283 /// empty `namespace` is normalised
3284 /// to `"/"` to match what `NodeBuilder::build` computes for a root-NS node.
3285 pub fn bind_node_name_sched(
3286 &mut self,
3287 name: &str,
3288 namespace: &str,
3289 sc: super::sched_context::SchedContextId,
3290 ) -> Result<(), super::types::BindError> {
3291 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3292 // Overwrite if there is already an entry for this (name, ns) pair.
3293 for entry in self.node_sched_table.iter_mut() {
3294 if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
3295 entry.2 = sc;
3296 return Ok(());
3297 }
3298 }
3299 self.push_node_entry(name, norm_ns, sc, 0)
3300 }
3301
3302 /// phase-509 P2 (RFC-0108 D2) -- seed the contract rank of the node
3303 /// `(name, namespace)` before it is built: 0 is the most urgent, as codegen
3304 /// normalises the derivation's ranks. Every callback the node registers
3305 /// then dispatches at that rank inside its sched context's bucket
3306 /// ([`Priority::dispatch_level`](super::sched_context::Priority::dispatch_level)).
3307 /// A node with no seeded rank holds 0.
3308 ///
3309 /// Shares the node-name table with [`Self::bind_node_name_sched`]: one entry
3310 /// per node carries both, so neither call disturbs the other's half. Same
3311 /// error contract: a binding that does not fit is refused, never dropped.
3312 pub fn bind_node_rank(
3313 &mut self,
3314 name: &str,
3315 namespace: &str,
3316 rank: u8,
3317 ) -> Result<(), super::types::BindError> {
3318 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3319 for entry in self.node_sched_table.iter_mut() {
3320 if entry.0.as_str() == name && entry.1.as_str() == norm_ns {
3321 entry.3 = rank;
3322 return Ok(());
3323 }
3324 }
3325 self.push_node_entry(name, norm_ns, super::sched_context::SchedContextId(0), rank)
3326 }
3327
3328 /// The rank seeded for `(name, namespace)` by [`Self::bind_node_rank`], 0
3329 /// when none was.
3330 pub(super) fn lookup_node_rank(&self, name: &str, namespace: &str) -> u8 {
3331 self.node_sched_table
3332 .iter()
3333 .find(|e| e.0.as_str() == name && e.1.as_str() == namespace)
3334 .map_or(0, |e| e.3)
3335 }
3336
3337 /// A new node-name table entry.
3338 fn push_node_entry(
3339 &mut self,
3340 name: &str,
3341 norm_ns: &str,
3342 sc: super::sched_context::SchedContextId,
3343 rank: u8,
3344 ) -> Result<(), super::types::BindError> {
3345 // New entry. Too long, or table full, is an ERROR the caller surfaces
3346 // — never a dropped binding. A binding that vanishes leaves the node on
3347 // the wrong sched context with nothing to read that says so.
3348 let mut name_s = heapless::String::<64>::new();
3349 let mut ns_s = heapless::String::<64>::new();
3350 name_s
3351 .push_str(name)
3352 .map_err(|_| super::types::BindError::NameTooLong)?;
3353 ns_s.push_str(norm_ns)
3354 .map_err(|_| super::types::BindError::NameTooLong)?;
3355 self.node_sched_table
3356 .push((name_s, ns_s, sc, rank))
3357 .map_err(|_| super::types::BindError::TableFull)?;
3358 Ok(())
3359 }
3360
3361 /// Look up the seeded sched-context for `(name, namespace)`. Returns
3362 /// `None` when the table has no entry for this pair (unseed → default 0).
3363 /// `pub(super)` — visible only within the `executor` module (sibling
3364 /// `node_record` calls it from `NodeBuilder::build`).
3365 pub(super) fn lookup_node_sched(
3366 &self,
3367 name: &str,
3368 namespace: &str,
3369 ) -> Option<super::sched_context::SchedContextId> {
3370 for entry in self.node_sched_table.iter() {
3371 if entry.0.as_str() == name && entry.1.as_str() == namespace {
3372 return Some(entry.2);
3373 }
3374 }
3375 None
3376 }
3377
3378 // =========================================================================
3379 // Phase 273 (RFC-0047) — per-callback-group → sched-context table
3380 // =========================================================================
3381
3382 /// Seed a config-resolved tier binding by `(name, namespace, group)` before
3383 /// entities are registered. `apply_node_default_sched` consults this table
3384 /// first (group table > node default > `SchedContextId(0)`).
3385 ///
3386 /// Call BEFORE entity creation. An existing entry for the same
3387 /// `(name, namespace, group)` key is overwritten (last-write wins). Overflow
3388 /// past `MAX_CBS` is an `Err`, never a dropped binding — issue 1172. An
3389 /// empty `namespace` is normalised to
3390 /// `"/"` to match `NodeBuilder::build`. Mirror of `bind_node_name_sched`.
3391 pub fn bind_group_sched(
3392 &mut self,
3393 name: &str,
3394 namespace: &str,
3395 group: &str,
3396 sc: super::sched_context::SchedContextId,
3397 ) -> Result<(), super::types::BindError> {
3398 let norm_ns = if namespace.is_empty() { "/" } else { namespace };
3399 // Overwrite if there is already an entry for this (name, ns, group).
3400 for entry in self.group_sched_table.iter_mut() {
3401 if entry.0.as_str() == name && entry.1.as_str() == norm_ns && entry.2.as_str() == group
3402 {
3403 entry.3 = sc;
3404 return Ok(());
3405 }
3406 }
3407 // Same contract as the two above.
3408 let mut name_s = heapless::String::<64>::new();
3409 let mut ns_s = heapless::String::<64>::new();
3410 let mut grp_s = heapless::String::<32>::new();
3411 name_s
3412 .push_str(name)
3413 .map_err(|_| super::types::BindError::NameTooLong)?;
3414 ns_s.push_str(norm_ns)
3415 .map_err(|_| super::types::BindError::NameTooLong)?;
3416 grp_s
3417 .push_str(group)
3418 .map_err(|_| super::types::BindError::NameTooLong)?;
3419 self.group_sched_table
3420 .push((name_s, ns_s, grp_s, sc))
3421 .map_err(|_| super::types::BindError::TableFull)?;
3422 Ok(())
3423 }
3424
3425 /// Look up the seeded sched-context for `(name, namespace, group)`. Returns
3426 /// `None` when the table has no entry for this triple.
3427 fn lookup_group_sched(
3428 &self,
3429 name: &str,
3430 namespace: &str,
3431 group: &str,
3432 ) -> Option<super::sched_context::SchedContextId> {
3433 for entry in self.group_sched_table.iter() {
3434 if entry.0.as_str() == name
3435 && entry.1.as_str() == namespace
3436 && entry.2.as_str() == group
3437 {
3438 return Some(entry.3);
3439 }
3440 }
3441 None
3442 }
3443
3444 // =========================================================================
3445 // Phase 110.B — SchedContext API
3446 // =========================================================================
3447
3448 /// Identifier of the auto-created default `Fifo`-class scheduling
3449 /// context. Every callback registered without an explicit
3450 /// [`Self::bind_handle_to_sched_context`] binds to this SC.
3451 pub fn default_sched_context_id(&self) -> super::sched_context::SchedContextId {
3452 super::sched_context::SchedContextId(0)
3453 }
3454
3455 /// Register a new scheduling context. Returns a [`SchedContextId`](crate::executor::sched_context::SchedContextId)
3456 /// callers pass to [`Self::bind_handle_to_sched_context`] to attach
3457 /// callbacks. Phase 110.B.
3458 pub fn create_sched_context(
3459 &mut self,
3460 sc: super::sched_context::SchedContext,
3461 ) -> Result<super::sched_context::SchedContextId, NodeError> {
3462 // Slot 0 is reserved for the default Fifo SC; search 1..MAX_SC.
3463 for (i, slot) in self.sched_contexts.iter_mut().enumerate().skip(1) {
3464 if slot.is_none() {
3465 *slot = Some(sc);
3466 // Phase 110.E — Sporadic-class SCs get a sibling
3467 // `SporadicState` entry that the spin_once dispatch
3468 // path consults each cycle to refill the budget at
3469 // period boundaries and skip dispatch when budget
3470 // is exhausted.
3471 if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
3472 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3473 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
3474 self.sporadic_states[i] =
3475 Some(super::sched_context::SporadicState::new(budget, period));
3476 }
3477 return Ok(super::sched_context::SchedContextId(i as u8));
3478 }
3479 }
3480 Err(NodeError::NoSchedContextSlot)
3481 }
3482
3483 /// RFC-0052 W3b.2 — wall-clock µs since the UNIX epoch, when this
3484 /// target has an epoch source (config `epoch_us`, defaulted from
3485 /// `SystemTime` on hosted configs). `None` = no wall clock; age
3486 /// monitors must not have been baked (the emitter refuses).
3487 pub fn epoch_now_us(&self) -> Option<u64> {
3488 self.epoch_us_fn.map(|f| f())
3489 }
3490
3491 /// RFC-0052 W3b.4 — install the baked contract-monitor table. Call
3492 /// BEFORE entity creation so `create_publisher` can attach each
3493 /// contracted endpoint's counter cell. Mirrors `set_qos_overrides`:
3494 /// `&'static`, codegen-baked, empty by default.
3495 ///
3496 /// phase-496 W1 -- the executor watches at most
3497 /// [`MAX_MONITORS`](super::monitor::MAX_MONITORS) rows
3498 /// (`NROS_EXECUTOR_MAX_MONITORS`). A generated entry cannot reach this with
3499 /// more: its table carries a compile-time assertion that names the knob,
3500 /// and the C++ installer refuses at runtime. A hand-written longer table is
3501 /// a debug-build panic naming the knob, because in release the spin loop
3502 /// would inspect only the first `MAX_MONITORS` and leave the rest silently
3503 /// unwatched.
3504 pub fn set_monitor_table(&mut self, table: &'static [super::monitor::MonitorSpec]) {
3505 if let Err(full) = super::monitor::check_table_capacity(table.len(), 0)
3506 && cfg!(debug_assertions)
3507 {
3508 panic!("set_monitor_table: {full}");
3509 }
3510 self.monitor_table = table;
3511 }
3512
3513 /// phase-496 W1 -- install BOTH baked monitor tables, or neither.
3514 ///
3515 /// The refusing installer: a table longer than this executor watches
3516 /// (`MAX_MONITORS` / `MAX_AGE_MONITORS`) is `Err`, and its `Display` names
3517 /// the knob to raise, before anything is installed. Nothing is truncated:
3518 /// the spin loop inspects only the first `MAX_*` specs, so accepting a
3519 /// longer table would boot with its tail silently unwatched. The C ABI's
3520 /// `nros_cpp_install_monitors` refuses on the same check.
3521 pub fn try_set_monitor_tables(
3522 &mut self,
3523 table: &'static [super::monitor::MonitorSpec],
3524 ages: &'static [super::monitor::AgeMonitorSpec],
3525 ) -> Result<(), super::monitor::MonitorTableFull> {
3526 super::monitor::check_table_capacity(table.len(), ages.len())?;
3527 self.monitor_table = table;
3528 self.age_table = ages;
3529 Ok(())
3530 }
3531
3532 /// The installed monitor table (empty unless the entry set one).
3533 #[must_use]
3534 pub fn monitor_table(&self) -> &'static [super::monitor::MonitorSpec] {
3535 self.monitor_table
3536 }
3537
3538 /// Issue 1676 — the counter cell a publisher on `topic_name` bumps, by
3539 /// exact topic match against the installed table (`None` = uncontracted).
3540 pub(crate) fn publisher_monitor_cell(
3541 &self,
3542 topic_name: &str,
3543 ) -> Option<&'static super::monitor::PubMonitorCell> {
3544 self.monitor_table
3545 .iter()
3546 .find(|m| m.topic == topic_name)
3547 .map(|m| m.cell)
3548 }
3549
3550 /// W3b.5 — install the baked subscriber age-contract table. Call
3551 /// BEFORE entity creation so `create_subscription` can attach each
3552 /// contracted endpoint's age cell (needs an epoch source — see
3553 /// `ExecutorConfig::epoch_us`; without one the take path records
3554 /// nothing and age monitors stay silent).
3555 ///
3556 /// phase-496 W1 -- at most
3557 /// [`MAX_AGE_MONITORS`](super::monitor::MAX_AGE_MONITORS) rows
3558 /// (`NROS_EXECUTOR_MAX_AGE_MONITORS`), on the same terms as
3559 /// [`Self::set_monitor_table`].
3560 pub fn set_age_table(&mut self, table: &'static [super::monitor::AgeMonitorSpec]) {
3561 if let Err(full) = super::monitor::check_table_capacity(0, table.len())
3562 && cfg!(debug_assertions)
3563 {
3564 panic!("set_age_table: {full}");
3565 }
3566 self.age_table = table;
3567 }
3568
3569 /// The installed age table (empty unless the entry set one).
3570 #[must_use]
3571 pub fn age_table(&self) -> &'static [super::monitor::AgeMonitorSpec] {
3572 self.age_table
3573 }
3574
3575 /// phase-462 W3b — install the baked hazard table. Each row's guards name
3576 /// rows of the tables [`Self::set_monitor_table`] and
3577 /// [`Self::set_age_table`] install, so install those first; a guard whose
3578 /// row is absent never faults. The observer runs after the monitor rows on
3579 /// every monitor tick and reports `hazard-detected`, `hazard-reaction` and
3580 /// `hazard-recovered` on the same drain.
3581 pub fn set_hazard_table(&mut self, table: &'static [super::monitor::HazardSpec]) {
3582 self.hazard_table = table;
3583 }
3584
3585 /// phase-462 W4 -- install the contract's mode variants of the two monitor
3586 /// tables; entry `i` is mode `i`, and entry 0 must be the base tables
3587 /// already installed. A switch is asked for with
3588 /// [`super::monitor::request_mode`] and applied at the next spin.
3589 ///
3590 /// # Errors
3591 ///
3592 /// [`super::monitor::ModeTablesMismatch`] when an entry's tables are not the base's
3593 /// length: a variant shares the base's cells and the per-row monitor
3594 /// state by index, so a variant of another length would judge one row
3595 /// by another's requirement. Nothing is installed.
3596 pub fn set_mode_tables(
3597 &mut self,
3598 modes: &'static [super::monitor::ModeTables],
3599 ) -> Result<(), super::monitor::ModeTablesMismatch> {
3600 for (id, m) in modes.iter().enumerate() {
3601 if m.monitors.len() != self.monitor_table.len() || m.ages.len() != self.age_table.len()
3602 {
3603 return Err(super::monitor::ModeTablesMismatch {
3604 mode: id as u16,
3605 monitors: (m.monitors.len(), self.monitor_table.len()),
3606 ages: (m.ages.len(), self.age_table.len()),
3607 missing_sc: None,
3608 });
3609 }
3610 if let Some(d) = m
3611 .deadlines
3612 .iter()
3613 .find(|d| !matches!(self.sched_contexts.get(usize::from(d.sc)), Some(Some(_))))
3614 {
3615 return Err(super::monitor::ModeTablesMismatch {
3616 mode: id as u16,
3617 monitors: (m.monitors.len(), self.monitor_table.len()),
3618 ages: (m.ages.len(), self.age_table.len()),
3619 missing_sc: Some(d.sc),
3620 });
3621 }
3622 }
3623 self.mode_tables = modes;
3624 Ok(())
3625 }
3626
3627 /// The mode whose tables this executor judges by (0 = the base).
3628 #[must_use]
3629 pub fn current_mode(&self) -> u16 {
3630 self.mode_applied
3631 }
3632
3633 /// phase-462 W4 -- once per spin, before any rule runs: enter the mode the
3634 /// application asked for, if this executor has tables for it.
3635 fn update_mode(&mut self, now_us: Option<u64>) {
3636 if self.mode_tables.is_empty() {
3637 return;
3638 }
3639 // phase-462 W4c -- a request seen now arrived after the previous
3640 // check, so this gap bounds how long it waited.
3641 let now_ms = now_us.map(|us| (us / 1_000) as u32);
3642 let waited_ms = match (now_ms, self.mode_checked_ms) {
3643 (Some(now), Some(prev)) => Some(now.wrapping_sub(prev)),
3644 _ => None,
3645 };
3646 self.mode_checked_ms = now_ms;
3647 let want = super::monitor::requested_mode();
3648 if want == self.mode_seen {
3649 return;
3650 }
3651 self.mode_seen = want;
3652 match self.mode_tables.get(usize::from(want)) {
3653 Some(t) => {
3654 self.monitor_table = t.monitors;
3655 self.age_table = t.ages;
3656 for d in t.deadlines {
3657 if let Some(Some(sc)) = self.sched_contexts.get_mut(usize::from(d.sc)) {
3658 sc.deadline_us = super::sched_context::OptUs::from_us(d.deadline_us);
3659 }
3660 }
3661 if let Some(v) = super::monitor::check_mode_switch(t, waited_ms) {
3662 self.violations.record(v);
3663 }
3664 self.mode_applied = want;
3665 nros_log::log_info!(
3666 nros_log::get_logger("nros"),
3667 "contract mode {} entered",
3668 want
3669 );
3670 }
3671 None => nros_log::log_warn!(
3672 nros_log::get_logger("nros"),
3673 "contract mode {} requested, but this executor bakes {} mode table(s); \
3674 staying in mode {}",
3675 want,
3676 self.mode_tables.len(),
3677 self.mode_applied
3678 ),
3679 }
3680 }
3681
3682 /// The installed hazard table (empty unless the entry set one).
3683 #[must_use]
3684 pub fn hazard_table(&self) -> &'static [super::monitor::HazardSpec] {
3685 self.hazard_table
3686 }
3687
3688 /// W3b.5 — override the wall-clock (epoch µs) source age monitors take
3689 /// message stamps against. Hosted builds default to `SystemTime`; a
3690 /// board with a synced RTC installs its own here (or via
3691 /// `ExecutorConfig::epoch_us`). Call BEFORE entity creation — the age
3692 /// hook captures this at `create_subscription` time.
3693 pub fn set_epoch_clock(&mut self, epoch_us: fn() -> u64) {
3694 self.epoch_us_fn = Some(epoch_us);
3695 }
3696
3697 /// W3b.5 — resolve a subscription's age hook at registration time:
3698 /// exact topic match against the baked age table, only for stamped
3699 /// types (`M::STAMP_OFFSET`). Issue 2215: with no epoch source the hook
3700 /// still records the stamped take (the silence rule's evidence), and the
3701 /// age is judged only once an epoch is installed.
3702 fn age_lookup<M: RosMessage>(&self, topic: &str) -> Option<super::arena::AgeMon> {
3703 M::STAMP_OFFSET?;
3704 self.age_table
3705 .iter()
3706 .find(|a| a.topic == topic)
3707 .map(|a| (a.cell, self.epoch_us_fn))
3708 }
3709
3710 /// Issue 2215 — bind a C/C++ callback subscription to its topic's
3711 /// contracted age row, so its stamped takes feed the silence rule (and,
3712 /// with an epoch source, the age rule) as the Rust typed paths' do.
3713 /// `stamp_offset` is the CDR byte offset of the type's `stamp.sec`
3714 /// (encapsulation header included; the C++ message type states it as
3715 /// `STAMP_OFFSET`). Returns `true` when a row was bound; `false` when the
3716 /// topic has no age row or `handle` is not a C callback subscription,
3717 /// which is not an error: such a subscription has nothing to feed.
3718 pub fn bind_raw_subscription_stamp(
3719 &mut self,
3720 handle: HandleId,
3721 topic: &str,
3722 stamp_offset: u16,
3723 ) -> bool {
3724 let Some(row) = self.age_table.iter().find(|a| a.topic == topic) else {
3725 return false;
3726 };
3727 let mon = Some(super::arena::RawAgeMon {
3728 cell: row.cell,
3729 epoch: self.epoch_us_fn,
3730 stamp_offset,
3731 });
3732 let i = self.live_slot(handle);
3733 let Some(meta) = self.entries.get(i).and_then(|m| m.as_ref()) else {
3734 return false;
3735 };
3736 let offset = meta.offset;
3737 type TryProcess = unsafe fn(*mut u8, u64, u8) -> Result<bool, TransportError>;
3738 let inplace = core::ptr::fn_addr_eq(
3739 meta.try_process,
3740 super::arena::sub_inplace_raw_c_try_process as TryProcess,
3741 );
3742 let buffered = core::ptr::fn_addr_eq(
3743 meta.try_process,
3744 super::arena::sub_buffered_raw_c_try_process as TryProcess,
3745 );
3746 // SAFETY: the entry at `offset` was written by
3747 // `add_arena_subscription_c_callback_with_capture` as the type its
3748 // `try_process` names, which is what the two tests above select on.
3749 unsafe {
3750 let ptr = (self.arena.as_mut_ptr() as *mut u8).add(offset);
3751 if inplace {
3752 (*(ptr as *mut super::arena::SubInplaceRawCEntry)).age_mon = mon;
3753 } else if buffered {
3754 (*(ptr as *mut SubBufferedRawCEntry)).age_mon = mon;
3755 } else {
3756 return false;
3757 }
3758 }
3759 true
3760 }
3761
3762 /// W3b.5 — install the `DeadlineAction::Fault` hook. Without one a
3763 /// fault-class deadline miss panics (watchdog-visible stop on
3764 /// embedded targets).
3765 /// Plan one `spin_once` against the caller's intended cadence, and return
3766 /// the park budget in microseconds.
3767 ///
3768 /// phase-474 I11 -- two cases:
3769 ///
3770 /// * **No declared cadence, non-zero timeout** (the generated entries'
3771 /// `spin_once(10)` loop, `spin`, `spin_period`): the timeout is the
3772 /// period and the loop runs on an absolute RELEASE GRID. The budget is
3773 /// the time to the next grid point, not a whole period from now, so the
3774 /// previous spin's work no longer pushes every later release back. A
3775 /// loop that comes back after its next point was due is released at
3776 /// once, late by the difference; one that comes back a whole period or
3777 /// more after it is released once for the LATEST due point and the
3778 /// older ones are skipped and counted -- the timer `Skip` policy, never
3779 /// a burst. Lateness is judged at the release (`record_grid_release`).
3780 /// A change of timeout re-anchors the grid at this entry.
3781 /// * **A declared cadence** (`set_spin_nominal_us`, the tier loops that
3782 /// sleep between spins themselves): unchanged. Late is
3783 /// `(now - last_entry) - cadence`, clamped at zero, and the budget is
3784 /// the timeout.
3785 ///
3786 /// A zero timeout claims no cadence and is skipped entirely, which keeps
3787 /// `Future::wait`-style busy spins out of the statistic and off the grid.
3788 fn record_release_jitter(&mut self, timeout: core::time::Duration) -> u64 {
3789 let timeout_us = timeout.as_micros().min(u64::MAX as u128) as u64;
3790 if !self.spin_nominal_self_paced() {
3791 if timeout_us == 0 {
3792 return 0;
3793 }
3794 let Some(now) = self.now_us() else {
3795 return timeout_us;
3796 };
3797 // phase-436 A2b -- a wait-paced declaration supplies the grid's
3798 // period; otherwise the timeout stands in for it.
3799 let period_us = if self.spin_nominal_declared_us != 0 {
3800 self.spin_nominal_declared_us
3801 } else {
3802 timeout_us
3803 };
3804 self.spin_nominal_us = period_us;
3805 if self.grid.period_us != period_us {
3806 // First periodic spin, or a new period: anchor here, so the
3807 // first park is a whole period exactly as before.
3808 self.grid.period_us = period_us;
3809 self.grid.next_us = now.saturating_add(period_us);
3810 } else if now >= self.grid.next_us.saturating_add(period_us) {
3811 // The loop came back a whole period or more after its next
3812 // release was due: serve the latest due point once and drop
3813 // the ones before it. The first dropped point is a release
3814 // that never happened, so its lateness at this moment (a
3815 // period or more) is what the jitter rule judges.
3816 let missed = (now - self.grid.next_us) / period_us;
3817 self.note_dropped_release(now - self.grid.next_us);
3818 self.grid.skipped = self
3819 .grid
3820 .skipped
3821 .saturating_add(missed.min(u32::MAX as u64) as u32);
3822 self.grid.next_us = self
3823 .grid
3824 .next_us
3825 .saturating_add(missed.saturating_mul(period_us));
3826 }
3827 // The timeout still bounds the wait. With a declared period it
3828 // may be shorter than the period (a caller that passes whole
3829 // milliseconds for a 1.5 ms cadence); a wake before the grid
3830 // point is then not a release, and the next spin parks again.
3831 return self.grid.next_us.saturating_sub(now).min(timeout_us);
3832 }
3833 // phase-474 I7 -- the violation reporter's own time since the last
3834 // sample is not a late release (see `flush_violation_reports`). Taken
3835 // first, so a spin that judges nothing does not carry it forward.
3836 let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3837 let nominal_us = self.spin_nominal_declared_us;
3838 let Some(now) = self.now_us() else {
3839 return timeout_us;
3840 };
3841 self.spin_nominal_us = nominal_us;
3842 if let Some(last) = self.last_spin_entry_us {
3843 let interval = now.saturating_sub(last).saturating_sub(report_us);
3844 self.total_wakes = self.total_wakes.saturating_add(1);
3845 if let Some(late) = interval.checked_sub(nominal_us)
3846 && late > 0
3847 {
3848 self.count_late_wake(late);
3849 }
3850 }
3851 self.last_spin_entry_us = Some(now);
3852 timeout_us
3853 }
3854
3855 /// phase-474 I11 -- judge a grid release, at the clock read after the
3856 /// park. A spin that wakes before its grid point (data arrived, a timer
3857 /// or another source was due first) is not a release: it dispatches and
3858 /// leaves the grid where it was. One that wakes at or after it is THE
3859 /// release of that point, late by `now - grid.next_us` (less the
3860 /// violation reporter's own time, phase-474 I7), and the grid advances by
3861 /// one period. Points the park itself overslept by a whole period are
3862 /// skipped at the next entry, not served in a burst.
3863 fn record_grid_release(&mut self, now: u64) {
3864 if self.spin_nominal_self_paced() || self.grid.period_us == 0 {
3865 return;
3866 }
3867 if now < self.grid.next_us {
3868 return;
3869 }
3870 let report_us = core::mem::take(&mut self.violations.reports.debt_us);
3871 let late = now
3872 .saturating_sub(self.grid.next_us)
3873 .saturating_sub(report_us);
3874 self.total_wakes = self.total_wakes.saturating_add(1);
3875 if late > 0 {
3876 self.count_late_wake(late);
3877 }
3878 self.grid.next_us = self.grid.next_us.saturating_add(self.grid.period_us);
3879 if now >= self.grid.next_us {
3880 // The park overslept the following point(s) too. This wake serves
3881 // one release; the points it ran past are skipped, not released
3882 // back to back on the next spins.
3883 let missed = (now - self.grid.next_us) / self.grid.period_us + 1;
3884 self.grid.skipped = self
3885 .grid
3886 .skipped
3887 .saturating_add(missed.min(u32::MAX as u64) as u32);
3888 self.grid.next_us = self
3889 .grid
3890 .next_us
3891 .saturating_add(missed.saturating_mul(self.grid.period_us));
3892 }
3893 }
3894
3895 /// One wake that came back `late_us` (> 0) after its release was due.
3896 fn count_late_wake(&mut self, late_us: u64) {
3897 self.late_wakes = self.late_wakes.saturating_add(1);
3898 if late_us > self.release_jitter_granularity_us() {
3899 self.late_wakes_beyond_granule = self.late_wakes_beyond_granule.saturating_add(1);
3900 }
3901 if late_us > self.max_release_jitter_us {
3902 self.max_release_jitter_us = late_us;
3903 }
3904 }
3905
3906 /// A cadence the caller meets with its own sleep between spins.
3907 fn spin_nominal_self_paced(&self) -> bool {
3908 self.spin_nominal_declared_us != 0 && !self.spin_nominal_wait_paced
3909 }
3910
3911 /// The lateness of a grid release that was dropped rather than served,
3912 /// into the high-water the jitter rule reads (not into the wake counts:
3913 /// no wake happened).
3914 fn note_dropped_release(&mut self, late_us: u64) {
3915 let late = late_us.saturating_sub(core::mem::take(&mut self.violations.reports.debt_us));
3916 if late > self.max_release_jitter_us {
3917 self.max_release_jitter_us = late;
3918 }
3919 }
3920
3921 /// phase-474 I11 -- grid releases skipped since the last
3922 /// `clear_release_jitter_stats`, because the loop came back after they
3923 /// were due. Zero for a declared-cadence or zero-timeout caller.
3924 pub fn skipped_releases(&self) -> u32 {
3925 self.grid.skipped
3926 }
3927
3928 /// Release-jitter statistics from the spin loop: worst lateness in
3929 /// microseconds, the number of wakes that were already late, and the
3930 /// number of wakes total.
3931 ///
3932 /// The maximum is the figure of merit, and the ratio is what tells the
3933 /// two failures apart: one late wake in ten thousand is a glitch, ten
3934 /// thousand in ten thousand means the period cannot be met at all.
3935 ///
3936 /// Zero on a build with no clock -- `spin_period` refuses to run at all
3937 /// there (issue 0709), so there is nothing to have measured.
3938 pub fn release_jitter(&self) -> (u64, u32, u32) {
3939 (
3940 self.max_release_jitter_us,
3941 self.late_wakes,
3942 self.total_wakes,
3943 )
3944 }
3945
3946 /// phase-436 A2c -- of the `late` wakes `release_jitter` counts, those
3947 /// late by more than [`Self::release_jitter_granularity_us`].
3948 ///
3949 /// A wake is late at one microsecond past its release. A loop paced by a
3950 /// tick-granular park comes back up to a tick past it on every wake, by
3951 /// design, so `late` approaches `total` on a loop that is keeping its
3952 /// period perfectly well. This count leaves that rounding out: it is the
3953 /// lateness the measurement can actually resolve. Cleared with the rest.
3954 pub fn late_wakes_beyond_granularity(&self) -> u32 {
3955 self.late_wakes_beyond_granule
3956 }
3957
3958 /// Reset the release-jitter statistics. For monitoring code that logs
3959 /// and clears per window, so a single early outlier does not pin the
3960 /// maximum for the life of the process.
3961 pub fn clear_release_jitter_stats(&mut self) {
3962 self.max_release_jitter_us = 0;
3963 self.late_wakes = 0;
3964 self.late_wakes_beyond_granule = 0;
3965 self.total_wakes = 0;
3966 self.last_spin_entry_us = None;
3967 self.jitter_reported_us = 0;
3968 self.grid.skipped = 0;
3969 }
3970
3971 pub fn set_fault_handler(&mut self, f: fn(&super::monitor::Violation)) {
3972 self.fault_fn = Some(f);
3973 }
3974
3975 /// phase-503 — install the REACTION hook: `f(event, ctx)` is called on the
3976 /// spin thread when a supervised route passes its deadline
3977 /// (`route-deadline-runtime`), with the row's reaction id, row index and
3978 /// mechanism. It also stands in for [`Self::set_fault_handler`] on a
3979 /// `DeadlineAction::Fault` when no fault handler is set (reaction id 0,
3980 /// the sched-context slot as the row), so a C or C++ image has a fault
3981 /// path that does not panic. Replaces any earlier hook; `None` removes it.
3982 ///
3983 /// The hook must not block and must not re-enter this executor: it starts
3984 /// the fallback, or hands it to another thread, and returns.
3985 pub fn set_reaction_hook(
3986 &mut self,
3987 hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
3988 ) {
3989 self.reaction_hook = hook;
3990 }
3991
3992 /// phase-509 P9b (RFC-0108 "As built in P9b") -- install the lock every
3993 /// FFI entry and every worker pass takes on this executor (`None` removes
3994 /// it). Call it while no second thread can reach the executor: a runner
3995 /// installs it before it spawns a level.
3996 pub fn set_exec_lock(&mut self, lock: Option<super::exec_lock::ExecLock>) {
3997 self.exec_lock = lock;
3998 }
3999
4000 /// phase-509 P9b -- the installed lock, if any.
4001 pub(crate) fn exec_lock(&self) -> Option<super::exec_lock::ExecLock> {
4002 self.exec_lock
4003 }
4004
4005 /// phase-509 P9b item 2 -- whether the callback of `slot` runs in a window
4006 /// on a thread other than the calling one. An operation on such an entry
4007 /// would race the callback, so the entry accessors answer as for a stale
4008 /// handle and cancel / reset are deferred to the end of the dispatch.
4009 /// `false` with no window open, which is every one-level image.
4010 pub(crate) fn running_elsewhere(&self, slot: usize) -> bool {
4011 if self.running.is_null() {
4012 return false;
4013 }
4014 let Some(me) = self.exec_lock.and_then(|l| l.current_thread()) else {
4015 return false;
4016 };
4017 let mut r = self.running;
4018 while !r.is_null() {
4019 // SAFETY: a record is linked only for its window and unlinked
4020 // under the lock before its frame ends (`pass`), and the caller
4021 // holds the lock.
4022 let rec = unsafe { &*r };
4023 if rec.slot == slot {
4024 return rec.thread != me;
4025 }
4026 r = rec.next;
4027 }
4028 false
4029 }
4030
4031 /// phase-509 P9b item 2 -- link `rec` into the open windows.
4032 ///
4033 /// # Safety
4034 /// `rec` stays alive and unmoved until [`Self::unlink_running`] removes
4035 /// it, under the lock.
4036 unsafe fn link_running(&mut self, rec: *mut super::exec_lock::Running) {
4037 // SAFETY: the caller's contract.
4038 unsafe { (*rec).next = self.running };
4039 self.running = rec;
4040 }
4041
4042 /// phase-509 P9b item 2 -- unlink `rec`, linked by
4043 /// [`Self::link_running`].
4044 fn unlink_running(&mut self, rec: *mut super::exec_lock::Running) {
4045 let mut at: *mut *mut super::exec_lock::Running = &mut self.running;
4046 // SAFETY: every linked record is alive (`link_running`'s contract),
4047 // and the list is touched only under the lock.
4048 unsafe {
4049 while !(*at).is_null() {
4050 if *at == rec {
4051 *at = (*rec).next;
4052 return;
4053 }
4054 at = &mut (**at).next;
4055 }
4056 }
4057 }
4058
4059 /// phase-509 P9b item 2 -- the dispatch level of `slot`'s occupant: its
4060 /// sched context's bucket, and inside `Normal` its rank.
4061 fn slot_level(&self, slot: usize) -> u8 {
4062 let sc_idx = self.sched_context_bindings[slot].0 as usize;
4063 let priority = self
4064 .sched_contexts
4065 .get(sc_idx)
4066 .and_then(|s| s.as_ref())
4067 .map_or(super::sched_context::Priority::Normal, |sc| sc.priority);
4068 let rank = self.slot_tags.get(slot).map_or(0, |t| t.rank);
4069 priority.dispatch_level(rank)
4070 }
4071
4072 /// phase-509 P9b item 2 -- the slots whose dispatch level is in `band`.
4073 /// Every slot for [`super::scheduler::Band::ALL`], the one-level worker's
4074 /// band, without looking at one.
4075 fn band_slots(&self, level: u8, band: super::scheduler::Band) -> u64 {
4076 // phase-509 P9b item 4 -- levels that own their slots by
4077 // registration (a level runner): the worker of `level` runs exactly
4078 // what its level's setup registered, whatever the ranks say.
4079 if self.worker.registering_level != super::scheduler::NO_OWNING_LEVEL {
4080 let mut mask = 0u64;
4081 for i in 0..self.entries.len().min(64) {
4082 if self.entries[i].is_some()
4083 && self.slot_tags.get(i).is_some_and(|t| t.level == level)
4084 {
4085 mask |= 1u64 << i;
4086 }
4087 }
4088 return mask;
4089 }
4090 if band == super::scheduler::Band::ALL {
4091 return u64::MAX;
4092 }
4093 let mut mask = 0u64;
4094 for i in 0..self.entries.len().min(64) {
4095 if self.entries[i].is_some() && band.contains(self.slot_level(i)) {
4096 mask |= 1u64 << i;
4097 }
4098 }
4099 mask
4100 }
4101
4102 /// phase-509 P9b item 4 -- the callbacks registered from now on belong
4103 /// to level `level`: its worker runs them, and no other does. The first
4104 /// call switches the executor to levels that own their slots by
4105 /// registration (a level runner sets each level up in turn); until then,
4106 /// and on every image that never calls it, a worker's band of dispatch
4107 /// levels decides.
4108 pub fn set_registering_level(&mut self, level: u8) {
4109 self.worker.registering_level = level.min(super::scheduler::NO_OWNING_LEVEL - 1);
4110 // Issue 2018 -- a level's own scheduling context is set after it
4111 // becomes the registering level, never inherited from the last one.
4112 self.worker.registering_sched = 0;
4113 }
4114
4115 /// Issue 2018 (RFC-0108 As-built) -- give the registering level its own
4116 /// scheduling context: the one its tier's policy lowers to
4117 /// ([`SchedContext::from_tier_policy`](super::sched_context::SchedContext::from_tier_policy)).
4118 /// Callbacks registered from now on, until the registering level
4119 /// changes, bind to it unless a node or callback-group binding names
4120 /// another. A level's budget is then charged to that level alone, never
4121 /// to the executor's default context, which every level shares.
4122 ///
4123 /// A `Sporadic` context is created with the executor's cooperative
4124 /// budget (it skips the level's callbacks once the budget is spent, until
4125 /// the period refills it). A runner whose kernel enforces the same
4126 /// budget on the level's thread hands it to the kernel with
4127 /// [`Self::defer_budget_to_kernel`], so the budget is enforced exactly
4128 /// once.
4129 pub fn set_level_sched_context(
4130 &mut self,
4131 sc: super::sched_context::SchedContext,
4132 ) -> Result<super::sched_context::SchedContextId, NodeError> {
4133 let id = self.create_sched_context(sc)?;
4134 self.worker.registering_sched = id.0;
4135 Ok(id)
4136 }
4137
4138 /// Issue 2018 -- the kernel enforces `sc`'s budget on the level's thread
4139 /// (NuttX `SCHED_SPORADIC`, Linux `SCHED_DEADLINE`), so the executor
4140 /// stops enforcing it: the context keeps its class, budget, period and
4141 /// deadline, and its callbacks are no longer skipped for want of budget.
4142 /// One enforcer per level, with one set of numbers. `false` when `sc`
4143 /// carries no cooperative budget (nothing to hand over).
4144 pub fn defer_budget_to_kernel(&mut self, sc: super::sched_context::SchedContextId) -> bool {
4145 match self.sporadic_states.get_mut(sc.0 as usize) {
4146 Some(st @ Some(_)) => {
4147 *st = None;
4148 true
4149 }
4150 _ => false,
4151 }
4152 }
4153
4154 /// Issue 2018 -- whether `sc`'s budget is enforced by this executor
4155 /// (a cooperative `Sporadic` context the kernel was not handed).
4156 pub fn enforces_budget(&self, sc: super::sched_context::SchedContextId) -> bool {
4157 self.sporadic_states
4158 .get(sc.0 as usize)
4159 .is_some_and(|s| s.is_some())
4160 }
4161
4162 /// Issue 2020 -- the level that owns what is registered now, `None` until
4163 /// a level runner first calls [`Self::set_registering_level`].
4164 pub fn registering_level(&self) -> Option<u8> {
4165 (self.worker.registering_level != super::scheduler::NO_OWNING_LEVEL)
4166 .then_some(self.worker.registering_level)
4167 }
4168
4169 /// Issue 2020 -- whether a pass ticks a component (or anything else
4170 /// registered) owned by `owner`, the level recorded at registration
4171 /// (`None`: no level owned it).
4172 ///
4173 /// `band_level` is the level of a band pass ([`Self::spin_band_once`]),
4174 /// `None` for a pass of the executor's own worker. When the levels own
4175 /// their slots by registration, a band pass ticks only its level's, and
4176 /// the executor's own worker (the boot level) ticks its own level's and
4177 /// any registered before a level was set. Otherwise the executor's own
4178 /// worker ticks everything and a band pass nothing, as before levels.
4179 pub fn pass_ticks_owner(&self, band_level: Option<u8>, owner: Option<u8>) -> bool {
4180 let owning = self.worker.registering_level != super::scheduler::NO_OWNING_LEVEL;
4181 match band_level {
4182 Some(l) => owning && owner == Some(l),
4183 None => !owning || owner.is_none() || owner == Some(self.worker.level),
4184 }
4185 }
4186
4187 /// phase-509 P9b item 2 -- the band the executor's own worker runs:
4188 /// every level by default. With band workers over the same executor
4189 /// ([`Self::spin_band_once`]), the bands must partition the levels, so
4190 /// each callback has one worker.
4191 pub fn set_worker_band(&mut self, band: super::scheduler::Band) {
4192 self.worker.band = band;
4193 }
4194
4195 /// phase-509 P9b item 2 -- one pass of the executor's own worker, through
4196 /// a raw pointer: `spin_once`, except that with a lock that has a thread
4197 /// identity installed ([`super::exec_lock::ExecLock::with_thread_id`]) the
4198 /// lock is released around each user callback, so other threads (band
4199 /// workers, FFI callers) reach the executor while it runs.
4200 ///
4201 /// # Safety
4202 /// `this` points at a live executor. If any other thread can reach it, a
4203 /// lock is installed and the calling thread holds it exactly once, and
4204 /// every other thread takes it before touching the executor. No reference
4205 /// to the executor is live in the caller across this call.
4206 pub unsafe fn spin_once_shared(
4207 this: *mut Self,
4208 timeout: core::time::Duration,
4209 ) -> SpinOnceResult {
4210 let mut discarded = None;
4211 // SAFETY: the caller's contract is `pass`'s.
4212 unsafe { Self::pass(this, PassMode::Shared, timeout, &mut discarded) }
4213 }
4214
4215 /// phase-509 P9b item 2 -- one pass of a band worker over an executor it
4216 /// shares: the readiness of `worker`'s band, then its callbacks, most
4217 /// urgent level first, the lock released around each one as in
4218 /// [`Self::spin_once_shared`]. A band pass does not park, drive the
4219 /// transport or run the executor-wide chores (parameter and lifecycle
4220 /// services, the monitors' rules): the executor's own worker does.
4221 /// Issue 2020 -- it does tick the components its level registered
4222 /// ([`Self::pass_ticks_owner`]), and the executor's own worker then does
4223 /// not.
4224 ///
4225 /// phase-509 P9b item 3 -- a worker with a waiter
4226 /// ([`super::scheduler::BandWorker::with_waiter`]) first parks, the lock
4227 /// released, for at most `timeout`: until its band's next timer is due or
4228 /// an arrival in its band signals it. Without one, `timeout` is unused
4229 /// and the pass polls.
4230 ///
4231 /// # Safety
4232 /// As [`Self::spin_once_shared`].
4233 pub unsafe fn spin_band_once(
4234 this: *mut Self,
4235 worker: &mut super::scheduler::BandWorker,
4236 timeout: core::time::Duration,
4237 ) -> SpinOnceResult {
4238 let mut discarded = None;
4239 // SAFETY: the caller's contract is `pass`'s.
4240 unsafe { Self::pass(this, PassMode::Band(worker), timeout, &mut discarded) }
4241 }
4242
4243 /// phase-509 P9b item 3 -- link `worker`'s waiter into this executor's
4244 /// arrival routing (RFC-0108 D7): an arrival attributed to a slot of its
4245 /// band then wakes it alone, an unattributed one wakes it with the rest.
4246 /// A worker with no waiter polls; this is then a no-op, as on a build
4247 /// with no arrival path. Attach each waiter to one executor, once.
4248 pub fn attach_band_worker(&mut self, worker: &super::scheduler::BandWorker) {
4249 let Some(waiter) = worker.waiter else {
4250 return;
4251 };
4252 waiter.slots.store(
4253 self.band_slots(worker.level, worker.band) as usize,
4254 portable_atomic::Ordering::SeqCst,
4255 );
4256 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4257 if let Some(ctx) = self.wake_ctx.as_ref() {
4258 let w = waiter as *const super::scheduler::BandWaiter as *mut _;
4259 let head = &ctx.band_waiters;
4260 let mut cur = head.load(portable_atomic::Ordering::Acquire);
4261 loop {
4262 waiter.next.store(cur, portable_atomic::Ordering::Release);
4263 match head.compare_exchange(
4264 cur,
4265 w,
4266 portable_atomic::Ordering::AcqRel,
4267 portable_atomic::Ordering::Acquire,
4268 ) {
4269 Ok(_) => break,
4270 Err(now) => cur = now,
4271 }
4272 }
4273 }
4274 }
4275
4276 /// phase-509 P9b item 2 -- a band pass's prelude: check in as its level,
4277 /// read the clock, and credit its timers with the time since ITS previous
4278 /// pass. Item 3: with a waiter and a positive `timeout`, park first, with
4279 /// the lock released, until the band's next timer is due, an arrival in
4280 /// the band signals the waiter, or `timeout` passes.
4281 ///
4282 /// # Safety
4283 /// As [`Self::owned_prelude`].
4284 unsafe fn band_prelude(
4285 this: *mut Self,
4286 window_lock: Option<super::exec_lock::ExecLock>,
4287 w: &mut super::scheduler::BandWorker,
4288 timeout: core::time::Duration,
4289 ) -> (Option<u64>, u64) {
4290 // SAFETY: the caller's contract; derived again after the park.
4291 let mut s: &mut Self = unsafe { &mut *this };
4292 if let Some(waiter) = w.waiter {
4293 let mask = s.band_slots(w.level, w.band);
4294 waiter
4295 .slots
4296 .store(mask as usize, portable_atomic::Ordering::SeqCst);
4297 let budget_us = timeout.as_micros().min(u64::MAX as u128) as u64;
4298 // A band timer's remainder is as of the worker's last pass; the
4299 // time since is already spent.
4300 let since_us = match (s.now_us(), w.last_spin_end_us) {
4301 (Some(now), Some(prev)) => now.saturating_sub(prev),
4302 _ => 0,
4303 };
4304 let bound_us = s
4305 .next_timer_deadline_in_us(mask)
4306 .map_or(budget_us, |t| t.saturating_sub(since_us).min(budget_us));
4307 if bound_us > 0 && !s.band_has_arrivals(mask) {
4308 let window = window_lock.map(super::exec_lock::Window::open);
4309 waiter.wait_us(bound_us);
4310 drop(window);
4311 // SAFETY: the lock is held again.
4312 s = unsafe { &mut *this };
4313 }
4314 }
4315 let now = s.now_us();
4316 super::level_liveness::LEVELS.check_in(w.level, now);
4317 match now {
4318 Some(now) => {
4319 let prev = w.last_spin_end_us.unwrap_or(now);
4320 w.last_spin_end_us = Some(now);
4321 (Some(now), now.saturating_sub(prev))
4322 }
4323 None => (None, 0),
4324 }
4325 }
4326
4327 /// phase-509 P9b item 3 -- whether an arrival for one of `mask`'s slots
4328 /// is already in the ready word (it then does not park).
4329 fn band_has_arrivals(&self, mask: u64) -> bool {
4330 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4331 if let Some(ctx) = self.wake_ctx.as_ref() {
4332 return ctx.ready.load(portable_atomic::Ordering::SeqCst) as u64 & mask != 0;
4333 }
4334 let _ = mask;
4335 false
4336 }
4337
4338 /// phase-503 — microseconds from now to the earliest deadline of a pending
4339 /// supervised route (0 when one is due), or `None` when none is waiting or
4340 /// the executor has no clock. A port that supervises with its own timer
4341 /// (`MECHANISM_TIMER`) re-arms it from this after each spin.
4342 pub fn route_next_due_us(&self) -> Option<u64> {
4343 // No table, no clock read: this runs on every park.
4344 if self.monitor_table.is_empty() {
4345 return None;
4346 }
4347 let clock = self.clock_us_fn?;
4348 super::monitor::route_next_due_us(self.monitor_table, clock())
4349 }
4350
4351 /// Wall microseconds since the last spin ended, the origin every timer's
4352 /// `elapsed_us` is credited from; 0 without a clock.
4353 fn since_last_spin_end_us(&self) -> u64 {
4354 match (self.clock_us_fn, self.last_spin_end_us) {
4355 (Some(clock), Some(prev)) => clock().saturating_sub(prev),
4356 _ => 0,
4357 }
4358 }
4359
4360 /// phase-503 W7 -- a [`RouteWatch`](super::monitor::RouteWatch) over this
4361 /// executor's supervised routes, which a port's timer or reaction thread
4362 /// reads from outside the spin thread: at its expiry it sees a route the
4363 /// running spin has already answered, without waiting for the spin to
4364 /// return. `None` without a monitor table or a clock. Take it after
4365 /// `set_monitor_table` and any clock change.
4366 pub fn route_watch(&self) -> Option<super::monitor::RouteWatch> {
4367 if self.monitor_table.is_empty() {
4368 return None;
4369 }
4370 let clock = self.clock_us_fn?;
4371 Some(super::monitor::RouteWatch::new(self.monitor_table, clock))
4372 }
4373
4374 /// Issue #515 — warn about periods the spin cadence cannot express.
4375 ///
4376 /// A timer fires on the first spin at or after its period elapses, so
4377 /// a period that is not an integer multiple of the executor's spin
4378 /// period lands on the spin grid instead of the declared cadence: a
4379 /// 33 ms timer on a 5 ms spin alternates 35 ms / 30 ms, mean 33.0.
4380 /// The rate is preserved and no activation is dropped, so every
4381 /// runtime rule is (correctly) silent — the jitter stays invisible
4382 /// until someone measures cadence on target.
4383 ///
4384 /// Runs ONCE, on the first spin carrying a non-zero timeout, because
4385 /// that timeout is where the tier's declared spin period becomes
4386 /// visible down here. A resolve-time diagnostic would be better
4387 /// still — the toolchain holds both numbers before the image is
4388 /// built — but this backstop needs no board or codegen change and
4389 /// catches hand-written spin loops too.
4390 /// phase-436 W1 (issue 1192) — how long the executor may park, in
4391 /// microseconds, given the caller's budget.
4392 ///
4393 /// The park used to be bounded by the caller's timeout narrowed by exactly
4394 /// one input, the BACKEND's `next_deadline_ms`. A registered timer is a
4395 /// deadline the EXECUTOR owns, and nothing let it shorten the sleep — so
4396 /// ~~`spin_default`~~'s 50 ms slept straight past a 10 ms timer, and the
4397 /// activations then arrived in the burst issue 0505 describes.
4398 ///
4399 /// This is the `min` over the deadline sources phase-436 names. The
4400 /// caller's budget is always a member, so the result is never unbounded
4401 /// and never zero-by-omission: with no timers registered the budget
4402 /// stands, which keeps a long idle wait a wait rather than a poll.
4403 ///
4404 /// Distinct from issue 0515's audit, which warns that a period does not
4405 /// divide the spin grid. That takes the grid as given; this moves it.
4406 ///
4407 /// `#[cfg(test)]`-gated (matching `mod tests`'s own gate in `mod.rs`):
4408 /// production code calls `next_wake_bound_attributed_us` directly to get
4409 /// the source alongside the bound, so this `.0`-only convenience has no
4410 /// caller outside the test module and is dead code in any build that
4411 /// does not compile it (every `cargo check`/`cargo doc`, and `cargo test
4412 /// --features rmw-cffi`, which excludes `mod tests` too).
4413 #[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
4414 pub(crate) fn next_wake_bound_us(&self, budget_us: u64) -> u64 {
4415 self.next_wake_bound_attributed_us(budget_us, None).0
4416 }
4417
4418 /// phase-436 W6 — contribute a deadline source.
4419 ///
4420 /// The core never names an RTOS: a port registers `k_poll`, `epoll`, a
4421 /// queue set or a device ISR here, and the executor only ever asks "when
4422 /// will you next need me?".
4423 ///
4424 /// Refuses past capacity rather than dropping silently. A source that was
4425 /// quietly discarded would leave the executor sleeping past a deadline it
4426 /// had been told about, which is the failure this whole phase exists to
4427 /// remove.
4428 pub fn register_wake_source(
4429 &mut self,
4430 next: NextDeadlineFn,
4431 ctx: *mut core::ffi::c_void,
4432 ) -> Result<WakeSourceId, NodeError> {
4433 if self.worker.wake_source_count as usize >= MAX_WAKE_SOURCES {
4434 return Err(NodeError::ExecutorFull);
4435 }
4436 let idx = self.worker.wake_source_count as usize;
4437 self.worker.wake_sources[idx] = Some(WakeSourceSlot { next, ctx });
4438 self.worker.wake_source_count += 1;
4439 Ok(WakeSourceId::Platform(idx as u8))
4440 }
4441
4442 /// Install THE thing that blocks. Singular by nature — only one primitive
4443 /// can actually wait — so this replaces whatever was there.
4444 /// `granularity_us` is the finest park this primitive can actually
4445 /// express. The port states it because only the port knows: the ABI
4446 /// signature is not the limit — an RTOS tick is a second, coarser one, and
4447 /// a timespec primitive is a finer one.
4448 pub(crate) fn set_park_primitive(
4449 &mut self,
4450 park: ParkUntilFn,
4451 ctx: *mut core::ffi::c_void,
4452 granularity_us: u64,
4453 ) {
4454 self.worker.park_primitive = Some((park, ctx));
4455 self.worker.park_granularity_declared_us = granularity_us;
4456 }
4457
4458 /// Issue 2225 (RFC-0113 D9) -- install (`Some`) or clear (`None`) the hook
4459 /// the executor's wake object calls after every signal: a backend's
4460 /// arrival callback, an attributed entity wake, the signal-fd worker. The
4461 /// wake half of a yielding park slot, for a tier that never waits on the
4462 /// object itself. Returns `false` when this build has no wake object.
4463 ///
4464 /// `ctx` must outlive the hook: clear it before `ctx` goes away.
4465 pub(crate) fn set_wake_hook(
4466 &mut self,
4467 hook: Option<WakeHookFn>,
4468 ctx: *mut core::ffi::c_void,
4469 ) -> bool {
4470 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4471 if let Some(w) = self.node_wake.as_ref() {
4472 w.set_hook(hook, ctx);
4473 return true;
4474 }
4475 let _ = (hook, ctx);
4476 false
4477 }
4478
4479 /// The wake primitive a port must hand its `ParkUntilFn` as `ctx`, or NULL
4480 /// when this build has none.
4481 ///
4482 /// phase-436 W7 — a park that waits on its own fresh object is NOT
4483 /// breakable by the backend's listener, and installing one makes latency
4484 /// worse: once a port has parked, the transport drain drops to
4485 /// non-blocking, so an unbreakable park sleeps out its full deadline with
4486 /// data already waiting. Waiting on THIS object keeps the async break.
4487 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4488 pub(crate) fn wake_raw_ptr(&self) -> *mut core::ffi::c_void {
4489 match self.node_wake.as_ref() {
4490 Some(w) => w.raw_ptr(),
4491 None => core::ptr::null_mut(),
4492 }
4493 }
4494
4495 /// phase-509 P7 (RFC-0108 D8.1) -- the level this executor runs, 0 the
4496 /// most urgent. An executor's runner sets it ([`Self::set_level`]); one it
4497 /// never set is level 0, which is every executor of a one-level image.
4498 /// It matches the generated `NROS_ENTRY_LEVEL_OF_<node>` of the nodes the
4499 /// executor hosts.
4500 pub fn level(&self) -> u8 {
4501 self.worker.level
4502 }
4503
4504 /// phase-509 P7 -- set the level this executor runs; its spins check in
4505 /// as that level. `false`, and nothing changed, at or past
4506 /// [`MAX_LEVELS`](super::MAX_LEVELS).
4507 pub fn set_level(&mut self, level: u8) -> bool {
4508 if level as usize >= super::level_liveness::MAX_LEVELS {
4509 return false;
4510 }
4511 self.worker.level = level;
4512 true
4513 }
4514
4515 /// Whether a platform park primitive is installed.
4516 pub fn has_park_primitive(&self) -> bool {
4517 self.worker.park_primitive.is_some()
4518 }
4519
4520 /// The bound and the source that produced it: the `min` over every
4521 /// deadline source, with the caller's budget as an ordinary member.
4522 ///
4523 /// The budget being a member is what makes the result never unbounded and
4524 /// never zero-by-omission, and it is why "no sources registered" needs no
4525 /// special case.
4526 pub(crate) fn next_wake_bound_attributed_us(
4527 &self,
4528 budget_us: u64,
4529 session_us: Option<u64>,
4530 ) -> (u64, WakeSourceId) {
4531 let mut best = (budget_us, WakeSourceId::CallerBudget);
4532
4533 // The backend's next internal event is a MEMBER, not a pre-cap. It was
4534 // folded into the budget upstream, which meant a park the session
4535 // actually won reported `CallerBudget` — the attribution named the
4536 // wrong source, and "why did we wake" is the question attribution
4537 // exists to answer.
4538 if let Some(s) = session_us
4539 && s < best.0
4540 {
4541 best = (s, WakeSourceId::Session);
4542 }
4543
4544 // Issue 2143 -- a timer's remainder is as of the last spin's END, the
4545 // point its `elapsed_us` was last credited; the time the host spent
4546 // since (a sleep between spins, the work of its own loop) is already
4547 // spent. Without this the bound was measured from a stale origin and
4548 // overslept the timer by exactly that gap -- the band prelude makes the
4549 // same correction for its own worker.
4550 if let Some(timer_us) = self
4551 .next_timer_deadline_us()
4552 .map(|t| t.saturating_sub(self.since_last_spin_end_us()))
4553 && timer_us < best.0
4554 {
4555 best = (timer_us, WakeSourceId::Timer);
4556 }
4557
4558 // phase-503 — a supervised route's deadline is the executor's own, like
4559 // a timer's: the park must end there, or a route whose output never
4560 // comes is judged only when something else wakes the loop.
4561 if let Some(due_us) = self.route_next_due_us()
4562 && due_us < best.0
4563 {
4564 best = (due_us, WakeSourceId::RouteDeadline);
4565 }
4566
4567 for (i, slot) in self.worker.wake_sources.iter().enumerate() {
4568 let Some(slot) = slot else { continue };
4569 // SAFETY: the callback and ctx were supplied together by the port
4570 // through `register_wake_source` and outlive the executor by its
4571 // contract; the call takes no ownership.
4572 let due = unsafe { (slot.next)(slot.ctx) };
4573 // `u64::MAX` is "nothing pending" — an async source spells itself
4574 // this way and breaks the park by signalling instead.
4575 if due != u64::MAX && due < best.0 {
4576 best = (due, WakeSourceId::Platform(i as u8));
4577 }
4578 }
4579
4580 best
4581 }
4582
4583 /// phase-436 — the bound the last park used, and which source won it.
4584 pub fn last_park(&self) -> (u64, WakeSourceId) {
4585 (self.worker.last_park_bound_us, self.worker.last_park_source)
4586 }
4587
4588 /// phase-436 W2 — the park the platform was actually asked for, after
4589 /// rounding the request up to an expressible value.
4590 ///
4591 /// Differs from `last_park().0` exactly when the request did not land on
4592 /// the platform's grid. Both are reported because a cadence that cannot be
4593 /// waited in must not be presented as if it were met.
4594 pub fn last_park_achieved_us(&self) -> u64 {
4595 self.worker.last_park_achieved_us
4596 }
4597
4598 /// phase-436 W3 (issue 1194) — the granularity the release-jitter
4599 /// measurement was actually made at, in microseconds.
4600 ///
4601 /// The jitter statistic is reported in microseconds, but the mechanism
4602 /// that PACES the loop decides how fine it can honestly be, and there are
4603 /// two mechanisms:
4604 ///
4605 /// * **No declared cadence** — the loop is paced by the blocking wait
4606 /// itself, so it cannot come round more precisely than one park granule.
4607 /// Reporting µs there is precision the wait cannot deliver.
4608 /// * **A declared cadence** (`set_spin_nominal_us`) — the caller paces
4609 /// itself, as nros-cpp's tiers do with `platform_sleep_us` between
4610 /// spins. That sleep is finer than the wait, so the measurement really
4611 /// is good to a microsecond and coarsening it would understate it.
4612 ///
4613 /// Reported rather than applied: this does not change what the monitor
4614 /// judges, because silently coarsening a bound could mask real lateness.
4615 /// It says what the number is worth.
4616 pub fn release_jitter_granularity_us(&self) -> u64 {
4617 if self.spin_nominal_self_paced() {
4618 1
4619 } else {
4620 self.park_granularity_us()
4621 }
4622 }
4623
4624 /// The finest park this build can actually express, in microseconds.
4625 ///
4626 /// Comes from the INSTALLED primitive, because only the port knows. The
4627 /// exported ABI signature is not the limit in either direction (issue
4628 /// 1242):
4629 ///
4630 /// * an RTOS tick is a COARSER limit — ThreadX pins
4631 /// `TX_TIMER_TICKS_PER_SECOND = 100`, a 10 ms tick, so a 1 ms park is not
4632 /// achievable there however the ABI is spelled;
4633 /// * a timespec primitive is a FINER one — POSIX's `sem_timedwait` and
4634 /// `pthread_cond_timedwait` are nanosecond-native, and only the
4635 /// `uint32_t timeout_ms` signature floors them at a millisecond.
4636 ///
4637 /// This was a hardcoded `1_000` justified by "every `nros_platform_wake_*`
4638 /// port takes `uint32_t timeout_ms`". That was right for FreeRTOS by
4639 /// coincidence and wrong for the other two, and it made the W3 jitter
4640 /// report over-claim on ThreadX — the failure W3 exists to prevent,
4641 /// reproduced one layer below W3 in its own dependency.
4642 ///
4643 /// With nothing installed the fallback is 1 ms, which IS the honest floor
4644 /// of the `wake_wait_ms` ABI every port exports today.
4645 ///
4646 /// The declared granularity counts only while the park is USABLE. An
4647 /// executor whose backend has no live wake (XRCE, or a session whose
4648 /// callback is gone) never parks: `spin_once` waits in the transport's
4649 /// own receive instead, whose timeout is whole milliseconds. Claiming a
4650 /// POSIX park's 1 us there over-claimed the jitter report, and worse,
4651 /// rounded the bound to microseconds that the millisecond wait then
4652 /// truncated, so a sub-millisecond deadline became a non-blocking receive
4653 /// and the loop spun hot until it passed.
4654 pub fn park_granularity_us(&self) -> u64 {
4655 if self.worker.park_granularity_declared_us != 0
4656 && self.worker.park_primitive.is_some()
4657 && self.park_can_be_woken()
4658 {
4659 self.worker.park_granularity_declared_us
4660 } else {
4661 1_000
4662 }
4663 }
4664
4665 /// phase-436 — whether a park could be ended early by data arriving.
4666 ///
4667 /// A park ends on its bound or on the wake object. With a poll-only
4668 /// backend (XRCE), or a session whose wake callback is gone, nothing
4669 /// signals that object, so a park would be a blind sleep and data
4670 /// arriving mid-park would wait out the whole bound — the Phase 127.C.4
4671 /// stall (a reliable stream whose ACK lands in a session nobody drives).
4672 /// Builds without `alloc` + `rmw-cffi` have no wake callback at all and
4673 /// park as they always did.
4674 pub(crate) fn park_can_be_woken(&self) -> bool {
4675 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4676 {
4677 self.async_wake_live()
4678 }
4679 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
4680 {
4681 true
4682 }
4683 }
4684
4685 /// phase-521 W6 (RFC-0113 D2) — the budget an entry's spin loop passes to
4686 /// [`spin_once`](Self::spin_once).
4687 ///
4688 /// An entry loop is `loop { spin_once(budget) }`, and each park already
4689 /// ends at the next deadline the executor knows (a timer, a backend's next
4690 /// internal event, a platform source) or on the wake object. So the
4691 /// budget is not a cadence: it bounds only a park nothing else bounds.
4692 /// Every entry used to pass 10 ms, which woke an idle tier 100 times a
4693 /// second whatever its park could do.
4694 ///
4695 /// [`ENTRY_IDLE_BUDGET`] when every session can end the wait itself: it
4696 /// states `Notify` with its wake callback live, or `Waitable` (whose
4697 /// handle the port multiplexes with the wake object). A cancel and a
4698 /// hosted termination post the same wake object. Otherwise
4699 /// [`ENTRY_POLL_BUDGET`]: what is left can wait on the wake object without
4700 /// its data posting it (a wake fan-out with no free member, more waitable
4701 /// sessions than one park multiplexes, a port with no wake object), so the
4702 /// short budget bounds how long its data waits. A session with no
4703 /// readiness form never gets here: it is refused at open (RFC-0113 D5).
4704 /// An enrolled component whose `tick` does work (it polls a service reply
4705 /// or completes action goals once per spin) also keeps the slice, so its
4706 /// tick rate does not change under it.
4707 pub fn entry_spin_budget(&self) -> core::time::Duration {
4708 if self.every_session_ends_the_wait() && !self.has_ticking_component() {
4709 ENTRY_IDLE_BUDGET
4710 } else {
4711 ENTRY_POLL_BUDGET
4712 }
4713 }
4714
4715 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4716 fn every_session_ends_the_wait(&self) -> bool {
4717 use nros_rmw::{ReadinessSource, Session as _};
4718 if self.node_wake.is_none() {
4719 return false;
4720 }
4721 let notify_live = self.async_wake_live();
4722 let primary = core::iter::once(self.session.readiness_source());
4723 let extra = self.extra_sessions.iter().map(|e| e.readiness_source());
4724 let mut waitable = 0usize;
4725 for source in primary.chain(extra) {
4726 match source {
4727 ReadinessSource::Waitable(_) => waitable += 1,
4728 ReadinessSource::Notify if notify_live => {}
4729 _ => return false,
4730 }
4731 }
4732 // More waitable sessions than one park multiplexes cannot be parked
4733 // on at all (`waitable_handles`).
4734 waitable <= MAX_WAITABLE_HANDLES
4735 }
4736
4737 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
4738 fn every_session_ends_the_wait(&self) -> bool {
4739 false
4740 }
4741
4742 /// phase-521 W3 (RFC-0113 D3) -- write the `ReadinessSource::Waitable`
4743 /// handles of this executor's sessions (primary first, then the extra
4744 /// ones) into `out`, and return how many. Asked every spin: a session's
4745 /// handle exists only once its transport is connected.
4746 ///
4747 /// More waitable sessions than `out` holds is answered with ZERO, not a
4748 /// truncated set: parking on some handles but not others would be a missed
4749 /// wake. Zero leaves the executor on the short entry budget, whose drain
4750 /// sees all of them.
4751 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
4752 pub(crate) fn waitable_handles(&self, out: &mut [i32; MAX_WAITABLE_HANDLES]) -> usize {
4753 use nros_rmw::{ReadinessSource, Session as _};
4754 let mut n = 0;
4755 let primary = core::iter::once(self.session.readiness_source());
4756 let extra = self.extra_sessions.iter().map(|e| e.readiness_source());
4757 for source in primary.chain(extra) {
4758 if let ReadinessSource::Waitable(fd) = source {
4759 if n == out.len() {
4760 return 0;
4761 }
4762 out[n] = fd;
4763 n += 1;
4764 }
4765 }
4766 n
4767 }
4768
4769 /// Drive the primary session for `timeout_ms`, the transport-side half of
4770 /// the wait.
4771 ///
4772 /// phase-521 W6 (RFC-0113 D2) — after a park this is always a zero, so it
4773 /// drains rather than waits. The guard slices phase-436 cut a poll-only
4774 /// backend's receive into are gone with that backend: a session that
4775 /// states no readiness form is refused at open (D5), so nothing waits
4776 /// inside a transport's `recv` for a trigger that cannot end it.
4777 fn drive_primary(&mut self, timeout_ms: i32) -> Result<(), ()> {
4778 self.session.drive_io(timeout_ms).map_err(|_| ())
4779 }
4780
4781 pub(crate) fn round_park_up_us(&self, requested_us: u64) -> u64 {
4782 if requested_us == 0 {
4783 return 0;
4784 }
4785 let g = self.park_granularity_us();
4786 if g <= 1 {
4787 return requested_us;
4788 }
4789 requested_us.div_ceil(g).saturating_mul(g)
4790 }
4791
4792 /// Microseconds until the soonest live timer is due, or `None` when no
4793 /// timer is pending.
4794 ///
4795 /// A timer that is cancelled, or a one-shot that already fired, owes
4796 /// nothing and must not hold the park short — otherwise the executor keeps
4797 /// waking on a deadline nobody is waiting for.
4798 ///
4799 /// So does a timer whose remainder is not WALL microseconds
4800 /// ([`TimerClockSource::remaining_is_wall_time`], issue 1321): the number
4801 /// this returns is handed to the port's park primitive, so a ROS-time
4802 /// timer's simulated remainder would be a units error — and on a paused
4803 /// `/clock` a frozen one, waking the image once per timer period forever.
4804 /// Its wake source is a `/clock` message, which reaches `drive_io` like
4805 /// any sample.
4806 ///
4807 /// phase-509 P9b item 3 -- the executor's own worker's timers: those of
4808 /// its band (every timer for the one-level worker), so it does not wake
4809 /// for a timer another band's worker runs.
4810 pub(crate) fn next_timer_deadline_us(&self) -> Option<u64> {
4811 self.next_timer_deadline_in_us(self.band_slots(self.worker.level, self.worker.band))
4812 }
4813
4814 /// phase-509 P9b item 3 -- [`Self::next_timer_deadline_us`] over the
4815 /// timers in `mask`'s slots.
4816 pub(crate) fn next_timer_deadline_in_us(&self, mask: u64) -> Option<u64> {
4817 let arena_ptr = self.arena.as_ptr() as *const u8;
4818 let mut soonest: Option<u64> = None;
4819 for (i, meta) in self.entries.iter().enumerate() {
4820 let Some(meta) = meta else { continue };
4821 if i < 64 && mask & (1u64 << i) == 0 {
4822 continue;
4823 }
4824 // phase-509 P9b item 2 -- not a timer running in another worker's
4825 // window: its header is that worker's while it runs, and a
4826 // running timer is not the one to wait for.
4827 if !matches!(meta.kind, EntryKind::Timer) || Self::slot_held(self.slot_tags, i) {
4828 continue;
4829 }
4830 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`, whose
4831 // leading layout is `TimerHeader` — the same projection
4832 // `audit_spin_quantization` relies on.
4833 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
4834 if header.cancelled || header.period_us == 0 {
4835 continue;
4836 }
4837 if header.oneshot && header.fired {
4838 continue;
4839 }
4840 // Issue 1321 — the park is WALL microseconds, so only a timer
4841 // whose own clock runs in them may bound it.
4842 if !header.clock_source.remaining_is_wall_time() {
4843 continue;
4844 }
4845 // Already due: the bound is zero, and no other timer can beat it.
4846 let remaining = header.period_us.saturating_sub(header.elapsed_us);
4847 soonest = Some(match soonest {
4848 Some(cur) => cur.min(remaining),
4849 None => remaining,
4850 });
4851 if remaining == 0 {
4852 break;
4853 }
4854 }
4855 soonest
4856 }
4857
4858 fn audit_spin_quantization(&mut self, spin_us: u64) {
4859 self.spin_quantized_timers(spin_us, |period_us, early_us, late_us| {
4860 nros_log::log_warn!(
4861 nros_log::get_logger("nros"),
4862 "timer period {} us is not a multiple of the {} us spin period: activations will alternate between {} us and {} us (mean cadence preserved)",
4863 period_us,
4864 spin_us,
4865 early_us,
4866 late_us
4867 );
4868 });
4869 }
4870
4871 /// Each timer the executor's own worker runs whose period `spin_us`
4872 /// quantizes, as `(period_us, early_us, late_us)`. Split out of
4873 /// [`Self::audit_spin_quantization`], which only logs, so the choice of
4874 /// timers can be asserted.
4875 ///
4876 /// Issue 2050 -- only the timers in the worker's own band
4877 /// ([`Self::band_slots`], the same mask its pass dispatches from). In a
4878 /// level runner the executor's own worker is the boot level, paced at the
4879 /// boot level's spin period, while a spawned level's timers run on that
4880 /// level's band worker, whose park is bounded by its next timer deadline.
4881 /// Auditing every timer judged a 1 ms timer on a spawned level against a
4882 /// 10 ms boot spin it never ran under.
4883 pub(crate) fn spin_quantized_timers(&self, spin_us: u64, mut f: impl FnMut(u64, u64, u64)) {
4884 if spin_us == 0 {
4885 return;
4886 }
4887 let mask = self.band_slots(self.worker.level, self.worker.band);
4888 let arena_ptr = self.arena.as_ptr() as *const u8;
4889 for (i, meta) in self.entries.iter().enumerate() {
4890 let Some(meta) = meta.as_ref() else {
4891 continue;
4892 };
4893 if i < 64 && mask & (1u64 << i) == 0 {
4894 continue;
4895 }
4896 if !matches!(meta.kind, EntryKind::Timer) || Self::slot_held(self.slot_tags, i) {
4897 continue;
4898 }
4899 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
4900 // whose leading layout is `TimerHeader`.
4901 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
4902 let period_us = header.period_us;
4903 let Some((early_us, late_us)) =
4904 spin_quantization_span(header.clock_source, period_us, spin_us)
4905 else {
4906 continue;
4907 };
4908 f(period_us, early_us, late_us);
4909 }
4910 }
4911
4912 /// Issue #514 — whether the executor logs each violation as it is
4913 /// detected (the default).
4914 ///
4915 /// Turn this off in an application that reports violations its own
4916 /// way via [`Self::drain_violations`]; the ring is unaffected
4917 /// either way.
4918 pub fn set_report_violations(&mut self, enabled: bool) {
4919 self.violations.report = enabled;
4920 }
4921
4922 /// phase-474 I1 -- the drain-and-report hook: at the end of every spin,
4923 /// drain the ring and log each entry with its sequence number and the
4924 /// `total` / `dropped` counters (`contract violation #N: <rule> <fqn>
4925 /// measured=.. declared=.. (total=.. dropped=..)`).
4926 ///
4927 /// While on it REPLACES the log line at detection, so one verdict is one
4928 /// line, and an application that wanted to drain the ring itself sees an
4929 /// empty one. The generated C/C++ entries turn it on when the image sets
4930 /// `CONFIG_NROS_VIOLATION_DRAIN_REPORT` (off by default). The trace marker
4931 /// (`callback_trace::MARKER_VIOLATION`) and the SWD record do not depend
4932 /// on it: both are written at detection.
4933 pub fn set_violation_drain_report(&mut self, enabled: bool) {
4934 self.violations.drain_report = enabled;
4935 }
4936
4937 /// Issue #514 / phase-474 I1 -- violations evicted from the full ring
4938 /// before any drain took them.
4939 ///
4940 /// The ring keeps the LATEST `MAX_VIOLATIONS`, so non-zero means the
4941 /// OLDEST verdicts were lost, never the newest. (Before phase-474 a full
4942 /// ring refused the push, which kept the first verdicts since boot and
4943 /// lost every later one.)
4944 pub fn violations_dropped(&self) -> u32 {
4945 self.violations.counts.dropped
4946 }
4947
4948 /// phase-474 I1 -- contract violations this executor stored since boot
4949 /// (wrapping). The newest entry in the ring is number `violations_total()`;
4950 /// `total - dropped - pending` is how many drains have taken.
4951 pub fn violations_total(&self) -> u32 {
4952 self.violations.counts.total
4953 }
4954
4955 /// RFC-0052 W3b.4 -- drain pending contract violations, OLDEST first.
4956 /// Draining clears the ring. Call between spins, never from a callback.
4957 pub fn drain_violations(&mut self, mut f: impl FnMut(&super::monitor::Violation)) {
4958 self.violations.drain(|_, v| f(v));
4959 }
4960
4961 /// [`Self::drain_violations`] with each entry's sequence number (1-based,
4962 /// per executor; the same number the drain-and-report line and the trace
4963 /// marker carry), so a reader can tell a gap from a quiet interval.
4964 pub fn drain_violations_numbered(&mut self, f: impl FnMut(u32, &super::monitor::Violation)) {
4965 self.violations.drain(f);
4966 }
4967
4968 /// phase-474 I2 -- choose when this executor's contract monitors arm,
4969 /// overriding the build default (`NROS_MONITOR_ARM_ON_CALL`,
4970 /// `NROS_MONITOR_ARM_GRACE_MS`). Call before the first spin (an
4971 /// `arm_monitors` made before it is undone by this); after the first spin
4972 /// an executor that already armed stays armed.
4973 ///
4974 /// `on_call`: wait for [`Self::arm_monitors`] or
4975 /// [`super::monitor::request_monitor_arming`] (`rclc_monitors_arm()`).
4976 /// `grace_ms`: arm anyway that long after the first spin (0 = never by
4977 /// deadline; with `on_call == false`, 0 means "at the first spin").
4978 pub fn set_monitor_arming(&mut self, on_call: bool, grace_ms: u32) {
4979 let v = &mut self.violations.counts;
4980 v.arm_on_call = on_call;
4981 v.arm_grace_ms = grace_ms;
4982 if !v.spun {
4983 // Before the first spin, "armed" is just the policy's answer (no
4984 // rule has run yet); the first spin counts the arming once.
4985 v.armed = super::monitor::MonitorArming { on_call, grace_ms }.armed_at_first_spin();
4986 }
4987 }
4988
4989 /// phase-474 I2 -- the application says its start-up is over: this
4990 /// executor's monitors start judging now. Idempotent.
4991 ///
4992 /// Judgement restarts at arming: every rate/age/silence window reopens,
4993 /// a latency or age measured before arming is discarded, the release-
4994 /// jitter statistics are cleared (so the figure after RUN is RUN's), and a
4995 /// stack low-water mark already past the declared minimum is reported
4996 /// again, because stack depth reached at start-up is a real fault, not
4997 /// start-up noise.
4998 pub fn arm_monitors(&mut self) {
4999 self.arm_now("the application armed them");
5000 }
5001
5002 /// phase-474 I2 -- whether this executor's monitors are armed.
5003 pub fn monitors_armed(&self) -> bool {
5004 self.violations.counts.armed
5005 }
5006
5007 /// phase-474 I2 -- verdicts reached before the monitors armed: counted,
5008 /// never stored, logged, sunk or traced.
5009 pub fn violations_suppressed_before_arm(&self) -> u32 {
5010 self.violations.counts.suppressed_before_arm
5011 }
5012
5013 fn arm_now(&mut self, why: &str) {
5014 if self.violations.counts.armed {
5015 return;
5016 }
5017 self.violations.counts.armed = true;
5018 for st in self.monitor_states.iter_mut() {
5019 *st = super::monitor::MonitorState::default();
5020 }
5021 for st in self.age_states.iter_mut() {
5022 *st = super::monitor::AgeState::default();
5023 }
5024 for spec in self.monitor_table.iter() {
5025 spec.cell
5026 .max_latency_us
5027 .store(0, core::sync::atomic::Ordering::Relaxed);
5028 }
5029 // phase10-B3 -- a take waiting since start-up is not a route of RUN's.
5030 super::monitor::route_reset(self.monitor_table);
5031 for spec in self.age_table.iter() {
5032 spec.cell
5033 .max_age_ms
5034 .store(0, core::sync::atomic::Ordering::Relaxed);
5035 spec.cell
5036 .takes
5037 .store(0, core::sync::atomic::Ordering::Relaxed);
5038 spec.cell
5039 .arrivals
5040 .store(0, core::sync::atomic::Ordering::Relaxed);
5041 }
5042 self.clear_release_jitter_stats();
5043 self.worker.stack_headroom_reported = usize::MAX;
5044 self.worker.stack_headroom_last_check_us = None;
5045 // Counted once per executor: here once it has spun, else by the first
5046 // spin (which counts an executor that is armed by then either way).
5047 #[cfg(nros_boot_report)]
5048 if self.violations.counts.spun {
5049 super::monitor::NROS_VIOLATION_RECORD
5050 .armed
5051 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
5052 }
5053 nros_log::log_info!(
5054 nros_log::get_logger("nros"),
5055 "contract monitors armed ({}); {} verdict(s) before arming suppressed",
5056 why,
5057 self.violations.counts.suppressed_before_arm
5058 );
5059 }
5060
5061 /// phase-474 I2 -- once per spin, before any rule runs: note the first
5062 /// spin, and arm when the policy says so.
5063 fn update_monitor_arming(&mut self, now_us: Option<u64>) {
5064 // Milliseconds in a wrapping `u32`, like the silence rule's lease
5065 // (`AgeState::last_take_ms`): this state is inline in the `Executor`
5066 // value, whose size is budgeted (issue 0961).
5067 let now_ms = now_us.map(|us| (us / 1_000) as u32);
5068 let v = &mut self.violations.counts;
5069 if !v.spun {
5070 v.spun = true;
5071 v.first_spin_ms = now_ms.unwrap_or(0);
5072 if v.armed {
5073 // Armed from the start (the default), or by a call made before
5074 // the first spin: count it in the record so a reader sees how
5075 // many executors are judging.
5076 #[cfg(nros_boot_report)]
5077 super::monitor::NROS_VIOLATION_RECORD
5078 .armed
5079 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
5080 return;
5081 }
5082 if (super::monitor::MonitorArming {
5083 on_call: v.arm_on_call,
5084 grace_ms: v.arm_grace_ms,
5085 })
5086 .armed_at_first_spin()
5087 {
5088 self.arm_now("at the first spin");
5089 return;
5090 }
5091 }
5092 if v.armed {
5093 return;
5094 }
5095 if super::monitor::arm_requests() != v.arm_requests_seen {
5096 self.arm_now("the application asked: rclc_monitors_arm");
5097 return;
5098 }
5099 let grace_ms = v.arm_grace_ms;
5100 if grace_ms == 0 {
5101 return;
5102 }
5103 match now_ms {
5104 Some(now) if now.wrapping_sub(v.first_spin_ms) >= grace_ms => {
5105 self.arm_now("the grace period after the first spin elapsed");
5106 }
5107 // No clock: a grace period cannot elapse, and waiting forever would
5108 // leave the image unmonitored. Arm rather than guess a duration.
5109 None if !v.arm_on_call => {
5110 self.arm_now("no clock to time the grace period");
5111 }
5112 _ => {}
5113 }
5114 }
5115
5116 /// Issue 1635 -- hand every stored violation to `sink`, beside the log line
5117 /// (issue 0514's floor) and the ring push.
5118 ///
5119 /// phase-474 I7 -- not at detection any more: the verdict is queued and
5120 /// leaves from `Executor::flush_violation_reports` at the end of a spin with
5121 /// slack, so the report never costs the dispatch it judges.
5122 ///
5123 /// Before this nothing in a generated image drained the ring, so the log
5124 /// line was the only output and `/diagnostics` stayed silent on every C
5125 /// and C++ road. A report point the RUNTIME owns reaches every road at
5126 /// once — every board's spin loop goes through the executor — where one
5127 /// per entry template would have to be rebuilt for each board runner. The
5128 /// ring is still filled, so an application (or a fixture) that drains it
5129 /// with [`Self::drain_violations`] sees exactly what it did before.
5130 /// `None` removes the sink.
5131 ///
5132 /// # Safety
5133 /// `ctx` must stay valid for every call of the sink until it is replaced
5134 /// or this executor is dropped (see [`super::monitor::ViolationSink`]).
5135 pub unsafe fn set_violation_sink(
5136 &mut self,
5137 sink: Option<(super::monitor::ViolationSink, *mut core::ffi::c_void)>,
5138 ) {
5139 self.violations.sink = sink.map(|(f, ctx)| (f, ctx as usize));
5140 if self.violations.sink.is_none() {
5141 // Nothing is left to send them to.
5142 self.violations.reports.pending.clear();
5143 self.violations.reports.since_us = None;
5144 }
5145 }
5146
5147 /// phase-474 I7 -- hand the queued verdicts to the sink, if this is a slot
5148 /// for it. Called at the end of every spin, after its dispatches and its
5149 /// rules, so the report never runs inside the tick being judged.
5150 ///
5151 /// A slot is a spin end whose next timer is due no sooner than the last
5152 /// report took (`ReportQueue::last_cost_us`; 0 before the first report, so
5153 /// the first goes at the first spin end with no timer already due), or
5154 /// any spin end once the oldest pending verdict has waited
5155 /// [`REPORT_DEFER_MAX_US`](super::monitor::REPORT_DEFER_MAX_US). Without a
5156 /// clock there is nothing to measure slack with, so every spin end is a
5157 /// slot. The sink takes as many as it coalesces into one message; the rest
5158 /// wait for the next slot.
5159 ///
5160 /// The time the sink takes is the reporter's own and is stated by
5161 /// [`Self::violation_report_cost_us`]; the release-jitter rule does not
5162 /// charge it to the spin's cadence (it is not a late release of any
5163 /// application work), which is what used to turn one verdict into a
5164 /// `release-jitter-runtime` verdict of its own on the safety island.
5165 pub(crate) fn flush_violation_reports(&mut self) {
5166 let Some((sink, ctx)) = self.violations.sink else {
5167 return;
5168 };
5169 if self.violations.reports.pending.is_empty() {
5170 return;
5171 }
5172 let now = self.now_us();
5173 if let Some(now) = now {
5174 let since = *self.violations.reports.since_us.get_or_insert(now);
5175 let overdue = now.saturating_sub(since) >= super::monitor::REPORT_DEFER_MAX_US;
5176 let cost = self.violations.reports.last_cost_us as u64;
5177 let slack = match self.next_timer_deadline_us() {
5178 None => true,
5179 Some(remaining) => remaining > 0 && remaining >= cost,
5180 };
5181 if !slack && !overdue {
5182 return;
5183 }
5184 }
5185 // SAFETY: `Executor::set_violation_sink`'s contract -- `ctx` is valid
5186 // for every call until the sink is replaced or the executor dropped.
5187 let taken = unsafe {
5188 sink(
5189 ctx as *mut core::ffi::c_void,
5190 &self.violations.reports.pending[..],
5191 )
5192 };
5193 let q = &mut self.violations.reports;
5194 if taken == 0 {
5195 // The sink counted its failure; drop the oldest so a report that
5196 // can never be sent does not stall the queue. It stays in the ring
5197 // and the record.
5198 q.pending.remove(0);
5199 q.failed = q.failed.saturating_add(1);
5200 } else {
5201 let n = taken.min(q.pending.len());
5202 for _ in 0..n {
5203 q.pending.remove(0);
5204 }
5205 }
5206 q.since_us = None;
5207 if let (Some(t0), Some(t1)) = (now, self.now_us()) {
5208 let q = &mut self.violations.reports;
5209 let cost = t1.saturating_sub(t0);
5210 q.last_cost_us = cost.min(u32::MAX as u64) as u32;
5211 q.max_cost_us = q.max_cost_us.max(q.last_cost_us);
5212 q.debt_us = q.debt_us.saturating_add(cost);
5213 }
5214 }
5215
5216 /// phase-474 I7 -- what handing verdicts to the violation sink has cost
5217 /// this executor: `(last_us, max_us, pending, overflowed)`. `overflowed`
5218 /// counts verdicts evicted from the report queue by newer ones before a
5219 /// slot came (they are still in the ring and the SWD record).
5220 pub fn violation_report_cost_us(&self) -> (u32, u32, usize, u32) {
5221 let q = &self.violations.reports;
5222 (q.last_cost_us, q.max_cost_us, q.pending.len(), q.overflowed)
5223 }
5224
5225 /// THE monotonic-µs read. phase-359 W4 — every consumer goes through here.
5226 ///
5227 /// Before this there were FIVE spellings of "what time is it": this one,
5228 /// ~~`last_spin_end: Instant`~~, and two ad-hoc `static EPOCH: OnceLock<Instant>`
5229 /// blocks — plus `PlatformClock`, which nothing called because `Executor` is
5230 /// deliberately non-generic and the trait's methods are associated fns.
5231 ///
5232 /// `None` means no clock is available (no_std with no injected hook).
5233 /// Callers must degrade, not guess: a missing clock is why the sporadic
5234 /// refill and the major-frame phase behaved differently on no_std.
5235 fn now_us(&mut self) -> Option<u64> {
5236 self.clock_us_fn.map(|clock| clock())
5237 }
5238
5239 /// Run the rate/latency/age checks over the baked tables (single
5240 /// branch each when empty).
5241 fn run_contract_monitors(&mut self) {
5242 if !self.monitor_table.is_empty()
5243 && let Some(now_us) = self.now_us()
5244 {
5245 {
5246 for (i, spec) in self
5247 .monitor_table
5248 .iter()
5249 .take(super::monitor::MAX_MONITORS)
5250 .enumerate()
5251 {
5252 if let Some(v) =
5253 super::monitor::check_rate(spec, &mut self.monitor_states[i], now_us)
5254 {
5255 self.violations.record(v);
5256 }
5257 if let Some(v) =
5258 super::monitor::check_latency(spec, &mut self.monitor_states[i])
5259 {
5260 self.violations.record(v);
5261 }
5262 }
5263 }
5264 }
5265 if !self.age_table.is_empty() {
5266 // phase-462 W2 -- the age loop needs the clock now: `silence-runtime`
5267 // judges how long an endpoint has taken NOTHING, and `None` (a
5268 // build with no clock) makes that rule abstain rather than guess.
5269 // Read before the loop because the borrow of `age_states` below
5270 // outlives the `&mut self` the reader wants.
5271 let now_us = self.now_us();
5272 for (i, spec) in self
5273 .age_table
5274 .iter()
5275 .take(super::monitor::MAX_AGE_MONITORS)
5276 .enumerate()
5277 {
5278 if let Some(v) = super::monitor::check_age(spec, &mut self.age_states[i], now_us) {
5279 self.violations.record(v);
5280 }
5281 }
5282 }
5283 // phase-462 W3b -- the hazard observer reads the verdict STATE the rows
5284 // above just updated, so it runs after them and on the same tick.
5285 if !self.hazard_table.is_empty()
5286 && let Some(now_us) = self.now_us()
5287 {
5288 let now_ms = (now_us / 1_000) as u32;
5289 // phase-462 W3c -- a detected hazard whose reacting path states a
5290 // `safe_state` asks the reaction hook to start it, once per
5291 // detection. Not before arming: a start-up verdict is only
5292 // counted (phase-474 I2), and it starts nothing either.
5293 let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
5294 // play_launch phase 96 W4.2 -- the mode this executor judges by
5295 // says which rung of each hazard's ladder is active.
5296 let mode = self.mode_applied;
5297 let violations = &mut self.violations;
5298 for (row, spec) in self.hazard_table.iter().enumerate() {
5299 super::monitor::check_hazard(
5300 spec,
5301 self.monitor_table,
5302 &self.monitor_states,
5303 &self.age_states,
5304 mode,
5305 now_ms,
5306 |v| {
5307 if v.rule == "hazard-detected" && spec.reaction_id != 0 {
5308 call_hazard_reaction(hook, spec, row, &v, now_us);
5309 }
5310 violations.record(v);
5311 },
5312 );
5313 }
5314 }
5315 self.check_route_deadlines();
5316 }
5317
5318 /// phase-503 — `route-deadline-runtime`: every pending supervised route
5319 /// past `take + route_deadline_ms` is reported (once until a route is
5320 /// within budget again) and its reaction requested through the hook.
5321 /// Runs right after the park, whose bound includes the earliest due time
5322 /// ([`WakeSourceId::RouteDeadline`]), so a route whose output never comes
5323 /// is judged within one park granule of its deadline. A callback that does
5324 /// not return holds this check too: that is `MECHANISM_TIMER`'s case.
5325 fn check_route_deadlines(&mut self) {
5326 let table = self.monitor_table;
5327 if table.is_empty() || !super::monitor::route_deadline_active(table) {
5328 return;
5329 }
5330 let Some(now_us) = self.now_us() else {
5331 return;
5332 };
5333 // Start-up is not RUN (phase-474 I2): before arming a verdict is only
5334 // counted, and no reaction is requested for it either.
5335 let hook = self.reaction_hook.filter(|_| self.violations.counts.armed);
5336 let violations = &mut self.violations;
5337 super::monitor::route_overdue(table, now_us, |row, v, first| {
5338 call_route_reaction(hook, table, row, &v, now_us);
5339 if first {
5340 violations.record(v);
5341 }
5342 });
5343 }
5344
5345 /// Issue #505 — report activations dropped by
5346 /// [`TimerOverrunPolicy::Skip`](super::arena::TimerOverrunPolicy)
5347 /// since the last check.
5348 ///
5349 /// Unlike the rate/age/latency rules this needs no baked spec table:
5350 /// every periodic timer counts its own overruns, and a dropped
5351 /// activation is a contract failure for any declared period. It runs
5352 /// on the same tick so violations land in the same ring the entry
5353 /// glue drains.
5354 /// Declare the minimum stack headroom this executor's thread must keep,
5355 /// in bytes. `0` (the default) disables the `stack-headroom-runtime`
5356 /// rule.
5357 ///
5358 /// Set by the entry that spawned the thread, because it is the only
5359 /// party that knows what stack it handed over: the executor never sees
5360 /// `stack_bytes`, and no portable query returns a task's total stack, so
5361 /// neither an absolute floor nor a percentage can be inferred here.
5362 /// Declare how often this executor's loop intends to come round, in
5363 /// microseconds. `0` (the default) falls back to the `spin_once` timeout.
5364 ///
5365 /// Needed because a driver's pacing and its blocking bound are separate
5366 /// numbers. nros-cpp's tier loops sleep `spin_period_us` between spins but
5367 /// pass a fixed 10 ms timeout, so the release-jitter rule judged every
5368 /// tier against 10 ms regardless of what the contract declared -- too lax
5369 /// for a 1 ms tier and far too strict for a 30 ms one.
5370 ///
5371 /// This also says the CALLER paces the loop (a sleep finer than the
5372 /// park), so the measurement is reported at 1 us. A loop paced by the
5373 /// `spin_once` wait itself declares with
5374 /// [`Self::set_spin_nominal_wait_paced_us`] instead.
5375 pub fn set_spin_nominal_us(&mut self, us: u64) {
5376 self.spin_nominal_declared_us = us;
5377 self.spin_nominal_wait_paced = false;
5378 }
5379
5380 /// phase-436 A2b -- declare the loop's cadence, in microseconds, for a
5381 /// loop paced by the `spin_once` wait itself. `0` withdraws it.
5382 ///
5383 /// [`Self::set_spin_nominal_us`] couples two facts: the period to judge
5384 /// against, and that the caller paces the loop with its own sleep. A loop
5385 /// like `for (;;) spin_once(period_ms)` has the first without the second.
5386 /// Declaring through the coupled setter would report its jitter at 1 us
5387 /// when the park it waits on is tick-granular (issue 1194's failure); not
5388 /// declaring at all makes the timeout the period, which is wrong whenever
5389 /// the timeout is not the period (a cadence that is not whole
5390 /// milliseconds, or one raised to a floor).
5391 ///
5392 /// Here the cadence is the period of the release grid that a
5393 /// `spin_once(timeout)` loop already runs on, and the timeout stays a
5394 /// bound on each wait. The jitter is judged at grid releases and reported
5395 /// at the park granularity.
5396 pub fn set_spin_nominal_wait_paced_us(&mut self, us: u64) {
5397 self.spin_nominal_declared_us = us;
5398 self.spin_nominal_wait_paced = us != 0;
5399 }
5400
5401 pub fn set_min_stack_headroom_bytes(&mut self, bytes: usize) {
5402 self.worker.min_stack_headroom_bytes = bytes;
5403 // phase-436 E4 — a new bound is checked on the next spin, not up to an
5404 // interval later.
5405 self.worker.stack_headroom_last_check_us = None;
5406 // phase-436 E3 — `check_stack_headroom_rule` treats a 0 from the port as
5407 // "not instrumented" and skips, which is right for the rule and wrong
5408 // for the reader: an armed rule that can never judge reports nothing,
5409 // exactly like one that judged and found the stack healthy. Ask the
5410 // port once, here. Entries set the bound at boot on the thread it
5411 // describes, so the answer is about the right stack and the warning
5412 // lands once per tier, next to the tier's other boot lines.
5413 // phase-436 E5 -- "does the port answer at all?" needs one byte, not
5414 // the whole unused region the full query would scan.
5415 self.worker.stack_headroom_blind =
5416 bytes != 0 && nros_platform_api::stack_unused_bytes_upto(1) == 0;
5417 if self.worker.stack_headroom_blind {
5418 nros_log::log_warn!(
5419 nros_log::get_logger("nros"),
5420 "stack-headroom-runtime: a {} B minimum is set but this port reports no stack \
5421 data, so the rule will never check (Zephyr: enable CONFIG_INIT_STACKS)",
5422 bytes
5423 );
5424 }
5425 }
5426
5427 /// phase-436 E3 — whether `stack-headroom-runtime` is armed on a port that
5428 /// cannot measure, and so will never report a violation.
5429 ///
5430 /// `true` means the rule's silence is not evidence of headroom. Zephyr
5431 /// answers only with `CONFIG_INIT_STACKS && CONFIG_THREAD_STACK_INFO`;
5432 /// POSIX never does.
5433 pub fn stack_headroom_blind(&self) -> bool {
5434 self.worker.stack_headroom_blind
5435 }
5436
5437 /// Report a spin thread that has come closer to the end of its stack
5438 /// than `set_min_stack_headroom_bytes` allows.
5439 ///
5440 /// Runs on the same tick as the other rules and feeds the same ring.
5441 /// Queries the port at most once per `STACK_HEADROOM_CHECK_INTERVAL_US`
5442 /// (phase-436 E4): on a painted Zephyr stack the full query scans every
5443 /// unused byte, so an every-spin query put that scan inside the control
5444 /// loop. Since E5 the query is capped at the declared minimum, so a scan
5445 /// reads at most that many bytes.
5446 /// Nothing at all when no minimum was declared.
5447 fn check_stack_headroom_rule(&mut self) {
5448 if self.worker.min_stack_headroom_bytes == 0 {
5449 return;
5450 }
5451 self.worker.stack_headroom_spins_since_check = self
5452 .worker
5453 .stack_headroom_spins_since_check
5454 .saturating_add(1);
5455 let now_us = self.now_us();
5456 if !super::monitor::stack_headroom_check_due(
5457 now_us,
5458 self.worker.stack_headroom_last_check_us,
5459 self.worker.stack_headroom_spins_since_check,
5460 ) {
5461 return;
5462 }
5463 self.worker.stack_headroom_last_check_us = Some(now_us.unwrap_or(0));
5464 self.worker.stack_headroom_spins_since_check = 0;
5465 self.worker.stack_headroom_checks = self.worker.stack_headroom_checks.saturating_add(1);
5466 // phase-436 E5 -- the CAPPED query. The rule only asks whether
5467 // `min_stack_headroom_bytes` are unused, and a port that scans for
5468 // paint answers that by reading those bytes alone. Below the cap the
5469 // answer is the exact headroom, which is what a breach reports, so
5470 // no second, full scan is needed. E4's throttle still applies.
5471 let unused =
5472 nros_platform_api::stack_unused_bytes_upto(self.worker.min_stack_headroom_bytes);
5473 // 0 means the port does not instrument stacks, not that the stack is
5474 // full. Reporting a violation there would be a fault invented from an
5475 // absence of data.
5476 if unused == 0 {
5477 return;
5478 }
5479 if let Some(v) = super::monitor::check_stack_headroom(
5480 unused,
5481 self.worker.min_stack_headroom_bytes,
5482 &mut self.worker.stack_headroom_reported,
5483 ) {
5484 self.violations.record(v);
5485 }
5486 }
5487
5488 /// AUTOSAR-style ALIVE supervision: report a SchedContext that produced
5489 /// no dispatch at all over a full window while declaring a period.
5490 ///
5491 /// Every other rule here fires when something happens and is wrong. This
5492 /// one fires when nothing happens, which is the failure a watchdog
5493 /// exists for and the one `rate-hierarchy-runtime` cannot see: that rule
5494 /// counts publishes on an endpoint, so a callback that stops firing while
5495 /// its topic is published from elsewhere leaves it satisfied.
5496 fn check_alive_supervision(&mut self) {
5497 let Some(now_us) = self.now_us() else {
5498 // No clock, no windows. Silence here is honest: the rule cannot
5499 // tell a stalled context from a fast one without time.
5500 return;
5501 };
5502 let n = self.alive_slots.len().min(self.sched_contexts.len());
5503 for i in 0..n {
5504 let period_us = match self.sched_contexts[i].as_ref() {
5505 Some(sc) => sc.period_us.get().map(|nz| nz.get() as u64).unwrap_or(0),
5506 None => continue,
5507 };
5508 let slot = &mut self.alive_slots[i];
5509 if let Some(v) =
5510 super::monitor::check_alive(period_us, slot.dispatches, &mut slot.state, now_us)
5511 {
5512 self.violations.record(v);
5513 }
5514 }
5515 }
5516
5517 /// Issue #515 — report a spin wake a whole period late.
5518 ///
5519 /// Runs on the same tick as `check_timer_overruns` and feeds the same
5520 /// ring, so the entry glue drains it with the other rules and no caller
5521 /// needs to know this one exists. Like that rule it needs no spec table:
5522 /// the bound is the spin period the caller already passes in.
5523 fn check_release_jitter_rule(&mut self) {
5524 let (max_us, _late, _total) = self.release_jitter();
5525 let period_us = self.spin_nominal_us;
5526 if let Some(v) =
5527 super::monitor::check_release_jitter(max_us, &mut self.jitter_reported_us, period_us)
5528 {
5529 self.violations.record(v);
5530 }
5531 }
5532
5533 fn check_timer_overruns(&mut self) {
5534 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
5535 for i in 0..self.entries.len() {
5536 let Some(meta) = self.entries[i].as_ref() else {
5537 continue;
5538 };
5539 // phase-509 P9b item 2 -- a timer whose callback runs in another
5540 // worker's window is that worker's to touch; its overrun is
5541 // reported by the next pass that finds it idle.
5542 if !matches!(meta.kind, EntryKind::Timer) || Self::slot_held(self.slot_tags, i) {
5543 continue;
5544 }
5545 // SAFETY: a Timer entry's arena slot holds a `TimerEntry<F>`,
5546 // which shares its leading layout with `TimerHeader`. The
5547 // baseline lives in the header too, so this needs no state
5548 // parallel to `entries` (whose capacity is a runtime slice
5549 // length, not a const).
5550 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
5551 if let Some(v) = super::monitor::check_timer_overrun(
5552 header.overruns,
5553 &mut header.overruns_reported,
5554 0,
5555 ) {
5556 self.violations.record(v);
5557 }
5558 }
5559 }
5560
5561 /// RFC-0052 / phase-296 W3a — replace the DEFAULT scheduling context
5562 /// (slot 0, the SC every unbound callback dispatches through).
5563 ///
5564 /// The run_tiers model runs one Executor per tier, so a tier-wide
5565 /// scheduling policy (`[tasks.<t>] class/budget_us/period_us` and the
5566 /// TT window) is exactly "this executor's default SC". Boards call
5567 /// this once, before entity creation; explicit per-handle/per-group
5568 /// bindings still take precedence (they never resolve to slot 0).
5569 ///
5570 /// Sporadic-class SCs get the same sibling `SporadicState` the
5571 /// `create_sched_context` path builds, so budget refill/exhaustion
5572 /// applies to the default queue too.
5573 pub fn set_default_sched_context(&mut self, sc: super::sched_context::SchedContext) {
5574 self.sched_contexts[0] = Some(sc);
5575 if matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
5576 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5577 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5578 self.sporadic_states[0] =
5579 Some(super::sched_context::SporadicState::new(budget, period));
5580 } else {
5581 self.sporadic_states[0] = None;
5582 }
5583 }
5584
5585 /// Bind a registered callback to a scheduling context. The next
5586 /// `spin_once` cycle dispatches the callback through that SC's
5587 /// queue (FIFO bitmap or EDF heap). Phase 110.B.
5588 pub fn bind_handle_to_sched_context(
5589 &mut self,
5590 handle: HandleId,
5591 sc_id: super::sched_context::SchedContextId,
5592 ) -> Result<(), NodeError> {
5593 let i = self.live_slot(handle);
5594 if i >= self.entries.len() {
5595 return Err(NodeError::InvalidSchedContextBinding);
5596 }
5597 if self.entries[i].is_none() {
5598 return Err(NodeError::InvalidSchedContextBinding);
5599 }
5600 let sc_idx = sc_id.0 as usize;
5601 if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
5602 return Err(NodeError::InvalidSchedContextBinding);
5603 }
5604 self.sched_context_bindings[i] = sc_id;
5605 Ok(())
5606 }
5607
5608 /// Phase 110.G — enable time-triggered dispatch by setting the
5609 /// executor's major-frame length. Once set, every `spin_once`
5610 /// cycle gates dispatch through each entry's bound SC's
5611 /// `tt_window_offset_us` / `tt_window_duration_us` fields:
5612 /// dispatch only fires when the current monotonic time falls
5613 /// inside the window `[off, off + duration) mod major_frame`.
5614 ///
5615 /// `major_frame_us = 0` disables the TT gate (default state).
5616 /// Setting a non-zero major frame after callbacks are already
5617 /// registered is allowed — TT gates take effect on the next
5618 /// `spin_once` cycle.
5619 pub fn register_time_triggered_dispatcher(&mut self, major_frame_us: u32) {
5620 self.major_frame_us = major_frame_us;
5621 }
5622
5623 /// Phase 110.G — apply a declarative cyclic schedule.
5624 ///
5625 /// One-shot helper that wraps the underlying primitives:
5626 /// validates the schedule (major_frame > 0, no overlapping
5627 /// windows, every window fits inside the major frame), sets the
5628 /// executor's major-frame length, then materialises one
5629 /// `SchedContext` per window with `class = TimeTriggered` +
5630 /// the window's offset / duration. Returns the per-window
5631 /// [`SchedContextId`](crate::executor::sched_context::SchedContextId) array so callers can immediately
5632 /// `bind_handle_to_sched_context(handle, sc_id)` for their
5633 /// subscription / timer handles.
5634 ///
5635 /// `N` is the schedule's *declared* maximum window count;
5636 /// `schedule.window_count` gates how many SCs are actually
5637 /// created. Unused trailing slots return
5638 /// `SchedContextId::default()` (sentinel — callers must respect
5639 /// `window_count`).
5640 pub fn apply_time_triggered_schedule<const N: usize>(
5641 &mut self,
5642 schedule: &super::sched_context::TimeTriggeredSchedule<N>,
5643 ) -> Result<
5644 [super::sched_context::SchedContextId; N],
5645 super::sched_context::TimeTriggeredScheduleError,
5646 > {
5647 schedule.validate()?;
5648 self.major_frame_us = schedule.major_frame_us;
5649 // SC slot 0 is the auto-created default; reusing it as a
5650 // sentinel for unused trailing slots is safe because the
5651 // caller respects `schedule.window_count`.
5652 let mut ids: [super::sched_context::SchedContextId; N] =
5653 [super::sched_context::SchedContextId(0); N];
5654 for (i, window) in schedule.windows[..schedule.window_count].iter().enumerate() {
5655 // TT is not a scheduling class (phase-482 W6 deleted the
5656 // `SchedClass::TimeTriggered` variant): it is a per-SC *window gate* on top of
5657 // the existing class-based dispatch (Fifo here keeps
5658 // the EDF / Sporadic budgets out of the picture for
5659 // pure cyclic schedules). The window-gate fields set
5660 // below are what `spin_once`'s 110.G runtime gate
5661 // actually reads.
5662 let sc = super::sched_context::SchedContext {
5663 tt_window_offset_us: super::sched_context::OptUs::from_us(window.offset_us),
5664 tt_window_duration_us: super::sched_context::OptUs::from_us(window.duration_us),
5665 ..super::sched_context::SchedContext::new_fifo()
5666 };
5667 ids[i] = self.create_sched_context(sc).map_err(|_| {
5668 super::sched_context::TimeTriggeredScheduleError::WindowCountOverflow
5669 })?;
5670 }
5671 Ok(ids)
5672 }
5673
5674 /// Phase 110.E.b — register an ISR-driven refill timer for an
5675 /// already-created Sporadic SC. The caller invokes their
5676 /// platform's `PlatformTimer::create_periodic` with the returned
5677 /// `Arc<AtomicSporadicState>` as `user_data` and the
5678 /// `atomic_sporadic_refill_thunk` as the callback, then hands
5679 /// the resulting platform handle to this method via
5680 /// `OpaqueTimerHandle::new(handle, destroy_fn)`.
5681 ///
5682 /// The Executor stores both the Arc and the handle so Drop can
5683 /// clean them up. Calling this on a non-Sporadic SC returns
5684 /// `Err(InvalidSchedContextBinding)`.
5685 #[cfg(feature = "alloc")]
5686 pub fn register_sporadic_timer(
5687 &mut self,
5688 sc_id: super::sched_context::SchedContextId,
5689 timer: OpaqueTimerHandle,
5690 ) -> Result<portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>, NodeError>
5691 {
5692 let i = sc_id.0 as usize;
5693 if i >= self.sched_contexts.len() {
5694 return Err(NodeError::InvalidSchedContextBinding);
5695 }
5696 let sc = self.sched_contexts[i]
5697 .as_ref()
5698 .ok_or(NodeError::InvalidSchedContextBinding)?;
5699 if !matches!(sc.class, super::sched_context::SchedClass::Sporadic) {
5700 return Err(NodeError::InvalidSchedContextBinding);
5701 }
5702 let budget = sc.budget_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5703 let period = sc.period_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX);
5704 let state = portable_atomic_util::Arc::new(super::sched_context::AtomicSporadicState::new(
5705 budget, period,
5706 ));
5707 self.sporadic_atomic_states[i] = Some((portable_atomic_util::Arc::clone(&state), timer));
5708 Ok(state)
5709 }
5710
5711 /// Inspect a registered scheduling context. Phase 110.B.
5712 pub fn sched_context(
5713 &self,
5714 sc_id: super::sched_context::SchedContextId,
5715 ) -> Option<&super::sched_context::SchedContext> {
5716 self.sched_contexts.get(sc_id.0 as usize)?.as_ref()
5717 }
5718
5719 /// Phase 104.C.2 — start a rclcpp-style Node builder for this
5720 /// Executor. The returned [`NodeBuilder`](super::node_record::NodeBuilder)
5721 /// is chainable:
5722 ///
5723 /// ```ignore
5724 /// let id = exec.node_builder("ingress")
5725 /// .rmw("zenoh")
5726 /// .locator("tcp/127.0.0.1:7447")
5727 /// .sched(my_sc_id)
5728 /// .build()?;
5729 /// ```
5730 ///
5731 /// In Phase 104.C.2 the Node table is storage-only — all
5732 /// registered Nodes share the Executor's primary session. Per-
5733 /// Node session binding (the bridge feature) lands in Phase
5734 /// 104.C.3 when the session cache is wired.
5735 pub fn node_builder<'a, 'cfg>(
5736 &'a mut self,
5737 name: &'cfg str,
5738 ) -> super::node_record::NodeBuilder<'a, 'cfg, 's> {
5739 super::node_record::NodeBuilder {
5740 executor: self,
5741 name,
5742 namespace: None,
5743 rmw_name: None,
5744 locator: None,
5745 domain_id: None,
5746 sched: None,
5747 session_idx: None,
5748 }
5749 }
5750
5751 /// Return the Node table — Phase 104.C.2 read accessor.
5752 pub fn nodes(&self) -> &[super::node_record::NodeRecord] {
5753 &self.nodes
5754 }
5755
5756 /// Borrow a Node's metadata by id, returning `None` if the id
5757 /// is out of range.
5758 pub fn node(&self, id: super::node_record::NodeId) -> Option<&super::node_record::NodeRecord> {
5759 self.nodes.get(id.index())
5760 }
5761
5762 /// Phase 189.M1 — an executor-borrowing node handle for the entity builders
5763 /// (`exec.node_mut(id).subscription(t)...` / `.create_subscription(...)`).
5764 /// A short-lived `&mut Executor` borrow — use one at a time; entity handles
5765 /// are owned and outlive it (see `Node`).
5766 /// phase-483 W3 — the component registration frame, for the `nros`
5767 /// facade's declarative constructors. Not a user API.
5768 #[doc(hidden)]
5769 pub fn __component_frame(&self) -> Option<core::ptr::NonNull<()>> {
5770 self.component_frame
5771 }
5772
5773 /// phase-483 W3 — install or clear the component registration frame.
5774 /// The caller guarantees the pointee outlives the installation.
5775 #[doc(hidden)]
5776 pub fn __set_component_frame(&mut self, frame: Option<core::ptr::NonNull<()>>) {
5777 self.component_frame = frame;
5778 }
5779
5780 pub fn node_mut(&mut self, id: super::node_record::NodeId) -> super::node::Node<'_, 's> {
5781 super::node::Node::new(self, id)
5782 }
5783
5784 /// Issue #52 — install the baked QoS-override table on one node. Every
5785 /// entity created on it AFTERWARDS folds the matching `(topic, role)`
5786 /// entries into its QoS, before the backend-compat check — so an override
5787 /// the active RMW cannot honour still errors loudly rather than silently
5788 /// downgrading.
5789 ///
5790 /// Called by the generated entry (`nros::main!` → the register seam) right
5791 /// after the node is created and before the component declares entities.
5792 /// Unknown `node_id` is a no-op.
5793 pub fn set_node_qos_overrides(
5794 &mut self,
5795 node_id: super::node_record::NodeId,
5796 overrides: &'static [super::node_record::QoSOverrideCode],
5797 ) {
5798 if let Some(r) = self.nodes.get_mut(node_id.index()) {
5799 r.qos_overrides = overrides;
5800 }
5801 }
5802
5803 /// Phase 104.C.3 — resolve a session-slot index to a mutable
5804 /// session reference. Slot 0 = the Executor's primary session;
5805 /// slots 1..=N = the `extra_sessions` vec opened by
5806 /// `node_builder.rmw(name)` calls that named a backend
5807 /// different from the primary.
5808 pub(crate) fn session_at_mut(&mut self, idx: u8) -> Option<&mut session::ConcreteSession> {
5809 if idx == 0 {
5810 Some(&mut *self.session)
5811 } else {
5812 self.extra_sessions.get_mut((idx - 1) as usize)
5813 }
5814 }
5815
5816 /// [`Self::session_at_mut`] plus a shared borrow of the remap table, for
5817 /// building a [`NodeHandle`] — which needs both at once, and gets them
5818 /// here as two disjoint field borrows rather than through two `&mut self`
5819 /// calls the borrow checker could not split.
5820 pub(crate) fn session_and_remaps_at_mut(
5821 &mut self,
5822 idx: u8,
5823 ) -> Option<(&mut session::ConcreteSession, RemapView<'_>)> {
5824 let remaps = RemapView {
5825 launch: self
5826 .launch_remaps
5827 .unwrap_or_else(super::launch_remap::launch_remaps),
5828 runtime: &self.remap_table[..self.remap_len],
5829 };
5830 let session = if idx == 0 {
5831 Some(&mut *self.session)
5832 } else {
5833 self.extra_sessions.get_mut((idx - 1) as usize)
5834 };
5835 session.map(|s| (s, remaps))
5836 }
5837
5838 /// Phase 104.C.9.b — resolve the per-Node session for direct
5839 /// entity creation paths (C++ FFI publisher / subscription /
5840 /// service that bypass the `register_*_on` arena dispatch).
5841 /// Returns `None` when `node_id` is out of range or the Node's
5842 /// `session_idx` lands outside the executor's session table.
5843 pub(crate) fn node_session_mut(
5844 &mut self,
5845 node_id: super::node_record::NodeId,
5846 ) -> Option<&mut session::ConcreteSession> {
5847 let session_idx = self.nodes.get(node_id.index())?.session_idx;
5848 self.session_at_mut(session_idx)
5849 }
5850
5851 /// Phase 189.M1 — create a typed publisher bound to a node's session.
5852 /// Backs `node.publisher(t).typed::<M>().build()` on the
5853 /// executor-borrowing [`Node`](super::node::Node); the returned
5854 /// handle is owned and outlives the `Node`.
5855 pub fn create_publisher_on<M: crate::rmw_type_registry::MessageForRmw>(
5856 &mut self,
5857 node_id: super::node_record::NodeId,
5858 topic_name: &str,
5859 qos: QoSProfile,
5860 ) -> Result<crate::executor::handles::EmbeddedPublisher<M>, NodeError> {
5861 // RFC-0088 / phase-421 W1 — the message's declared format must be the
5862 // one the linked backend speaks. Universal: the const lives on
5863 // `RosMessage`, which `MessageForRmw` requires under every backend.
5864 crate::format_check::assert_message_format::<M>();
5865 // Phase 212.K.7.6.b — register `M`'s cyclonedds descriptor before
5866 // creating the underlying publisher handle. No-op for other RMWs.
5867 crate::rmw_type_registry::register_type::<M>()?;
5868 let handle = self.create_raw_publisher_handle_on(
5869 node_id,
5870 topic_name,
5871 <M as RosMessage>::TYPE_NAME,
5872 <M as RosMessage>::TYPE_HASH,
5873 qos,
5874 )?;
5875 // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell.
5876 let monitor = self
5877 .monitor_table
5878 .iter()
5879 .find(|m| m.topic == topic_name)
5880 .map(|m| m.cell);
5881 Ok(crate::executor::handles::EmbeddedPublisher {
5882 handle,
5883 event_regs: crate::executor::handles::empty_event_regs(),
5884 monitor,
5885 epoch: self.epoch_us_fn,
5886 _phantom: PhantomData,
5887 })
5888 }
5889
5890 /// Phase 189.M1 — create a generic (type-erased) publisher bound to a
5891 /// node's session. Backs `node.publisher(t).generic(ty, hash).build()`;
5892 /// the bridge re-publishes through this handle on the dest session.
5893 pub fn create_publisher_raw_on(
5894 &mut self,
5895 node_id: super::node_record::NodeId,
5896 topic_name: &str,
5897 type_name: &str,
5898 type_hash: &str,
5899 qos: QoSProfile,
5900 ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
5901 let handle =
5902 self.create_raw_publisher_handle_on(node_id, topic_name, type_name, type_hash, qos)?;
5903 Ok(crate::executor::handles::EmbeddedRawPublisher {
5904 handle,
5905 arena: crate::executor::handles::TxArena::new(),
5906 event_regs: crate::executor::handles::empty_event_regs(),
5907 // Issue 1676 — attach the contracted endpoint's counter cell by
5908 // exact topic match against the installed table, the rule
5909 // `NodeHandle::create_publisher` applies. This is the publisher
5910 // the Rust component road creates, so without it no contracted
5911 // Rust publisher was ever counted.
5912 monitor: self.publisher_monitor_cell(topic_name),
5913 })
5914 }
5915
5916 /// Shared prelude for the publisher-on-node paths: resolve the node's
5917 /// identity + session slot, build the [`TopicInfo`], validate QoS, and
5918 /// create the backend publisher handle. Mirrors
5919 /// `register_subscription_buffered_raw_on`'s session resolution so a
5920 /// bridge's source sub + dest pub agree on topic construction.
5921 fn create_raw_publisher_handle_on(
5922 &mut self,
5923 node_id: super::node_record::NodeId,
5924 topic_name: &str,
5925 type_name: &str,
5926 type_hash: &str,
5927 qos: QoSProfile,
5928 ) -> Result<session::RmwPublisher, NodeError> {
5929 let (node_name, ns, session_idx, overrides) = {
5930 let r = self
5931 .nodes
5932 .get(node_id.index())
5933 .ok_or(NodeError::InvalidSchedContextBinding)?;
5934 (
5935 r.name.clone(),
5936 r.namespace.clone(),
5937 r.session_idx,
5938 r.qos_overrides,
5939 )
5940 };
5941 // Issue #52 — fold the node's baked overrides for this topic BEFORE
5942 // `validate_against`, so an override the backend cannot honour errors
5943 // loudly instead of being silently dropped.
5944 let qos = super::node_record::apply_qos_override_codes(
5945 qos,
5946 topic_name,
5947 nros_rmw::QoSOverrideRole::Publisher,
5948 overrides,
5949 );
5950 let mut topic = TopicInfo::new(topic_name, type_name, type_hash)
5951 .with_domain(self.domain_id)
5952 .with_namespace(&ns);
5953 if !node_name.is_empty() {
5954 topic = topic.with_node_name(&node_name);
5955 }
5956 // issue 1608 -- the publisher half of the declared-QoS check, before
5957 // validation so a policy the contract raises this publisher to is one
5958 // the backend is asked about. See `declared_qos::honour_publisher`.
5959 let qos = crate::declared_qos::honour_publisher(topic.type_name, topic.name, qos)?;
5960 let session = self
5961 .session_at_mut(session_idx)
5962 .ok_or(NodeError::BackendMismatch)?;
5963 super::node::validate_qos_or_report(
5964 &qos,
5965 Session::supported_qos_policies(session),
5966 "publisher",
5967 topic_name,
5968 )?;
5969 session
5970 .create_publisher(&topic, qos)
5971 .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))
5972 }
5973
5974 /// Phase 124.B.1 — install the executor's wake callback onto the
5975 /// primary session. Best-effort: backends that don't override
5976 /// `Session::set_wake_callback` (poll-only XRCE, bare-metal)
5977 /// ignore the call and continue to be drained on the executor's
5978 /// deadline-bound cv-wait boundary.
5979 ///
5980 /// `pub` for issue 1385, and for the same reason
5981 /// [`set_primary_identity`](Self::set_primary_identity) is: the three
5982 /// `Executor::open*` paths call this automatically, and a binding that
5983 /// builds its executor over a session it BORROWS
5984 /// ([`from_session_ptr_in`](Self::from_session_ptr_in)) reaches none of
5985 /// them, so it has to call it itself. `rclc_executor_init` is that
5986 /// binding, and until 1385 it did not — leaving `has_async_wake` false
5987 /// for the life of every C executor on every backend.
5988 ///
5989 /// Pairs with `clear_wake_signal`, which the
5990 /// executor runs from `close()` and from `drop`. A caller that installs
5991 /// owes nothing further: the teardown is the executor's.
5992 ///
5993 /// phase-436 — the backend's slot holds a `WakeFanout`
5994 /// and this executor JOINS it, so every executor over one session is woken
5995 /// by an arrival: the boot executor and each spawned tier
5996 /// (`open_with_session*` now installs too), or several C executors over
5997 /// one support context. Idempotent. If the fan-out pool is exhausted the
5998 /// executor installs its own context directly, as before; if the entry's
5999 /// member table is full it joins nothing and reads as having no async
6000 /// wake, so it never parks on a wake nobody will signal.
6001 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6002 pub(crate) fn install_wake_signal_on_primary(&mut self) {
6003 use nros_rmw::Session as _;
6004 if self.wake_membership.is_some() {
6005 return;
6006 }
6007 let ctx = self.wake_ctx_ptr();
6008 let key = self.session_ptr() as *mut core::ffi::c_void;
6009 let supports = self.session.supports_wake_callback();
6010 let fanout = match super::wake_fanout::find(key) {
6011 Some(f) => Some(f),
6012 None => super::wake_fanout::claim(key).inspect(|f| {
6013 f.set_live(supports);
6014 // SAFETY: the context is a `static` pool entry, valid for the
6015 // program's life; `clear_wake_signal` takes the callback off
6016 // before returning the entry to the pool.
6017 unsafe {
6018 self.session
6019 .set_wake_callback(Some(nros_rmw_fanout_wake_cb), f.as_ctx());
6020 }
6021 }),
6022 };
6023 match fanout {
6024 Some(f) => {
6025 if let Some(m) = f.join(ctx) {
6026 self.wake_membership = Some(m);
6027 // Issue 1385 — this executor now owes a teardown: leaving
6028 // the fan-out (and, for the session's owner or its last
6029 // member, taking the callback off the backend).
6030 self.wake_cb_installed = true;
6031 }
6032 }
6033 None => {
6034 // SAFETY: `ctx` points at executor-owned wake state that
6035 // outlives the installation and is cleared by
6036 // `clear_wake_signal` on `close()`/drop.
6037 unsafe {
6038 self.session
6039 .set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
6040 }
6041 self.wake_cb_installed = true;
6042 if supports {
6043 self.has_async_wake = true;
6044 }
6045 }
6046 }
6047 }
6048
6049 /// phase-436 — does an arrival on any of this executor's sessions signal
6050 /// its wake object? Through the primary's fan-out (live until the
6051 /// session's owner takes the callback down), or a direct install on the
6052 /// primary or an extra session.
6053 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6054 pub(crate) fn async_wake_live(&self) -> bool {
6055 self.has_async_wake || self.wake_membership.is_some_and(|m| m.is_live())
6056 }
6057
6058 /// Phase 124.B.1 — install the wake callback onto an extra
6059 /// session opened by `node_builder.rmw(...)`. Called from
6060 /// `NodeBuilder::build()` right after `extra_sessions.push(...)`.
6061 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6062 pub(crate) fn install_wake_signal_on_extra(&mut self, idx: usize) {
6063 use nros_rmw::Session as _;
6064 let ctx = self.wake_ctx_ptr();
6065 if let Some(s) = self.extra_sessions.get_mut(idx) {
6066 // SAFETY: same executor-owned wake state as the primary session;
6067 // the extra session is owned by this executor.
6068 unsafe {
6069 s.set_wake_callback(Some(nros_rmw_runtime_wake_cb), ctx);
6070 }
6071 // Issue 1385 — as on the primary.
6072 self.wake_cb_installed = true;
6073 if s.supports_wake_callback() {
6074 self.has_async_wake = true;
6075 }
6076 }
6077 }
6078
6079 /// Issue 1385 — the teardown half of `install_wake_signal_on_*`, and the
6080 /// obligation `nros_rmw_runtime_wake_cb`'s own SAFETY comment has asserted
6081 /// since phase 124: *"Executor::drop must clear the callback via
6082 /// `set_wake_callback(None, _)` on all sessions before dropping
6083 /// wake_ctx"*. Nothing did. The string `set_wake_callback(None` occurred
6084 /// exactly once in the tree — in that comment.
6085 ///
6086 /// The context the backend holds is `Arc::as_ptr(&self.wake_ctx)`, freed
6087 /// with this executor. Leaving it installed is a dangling callback the
6088 /// backend may invoke from a worker thread or an ISR. On the Rust `open*`
6089 /// paths the session is `SessionStore::Owned` and dies in the same drop,
6090 /// so the window is narrow; on the C path it is arbitrary —
6091 /// `rclc_executor_fini` drops the executor and zero-fills `_opaque` while
6092 /// the session lives on in `rclc_support_t` until `rclc_support_fini`.
6093 ///
6094 /// Clears the primary and every extra session, then latches off, so
6095 /// `close()` followed by drop clears once.
6096 ///
6097 /// Only fires when THIS executor installed (see
6098 /// `install_wake_signal_on_primary`). Executors sharing one session were
6099 /// last-writer-wins on the backend's single slot until phase-436: the first
6100 /// to tear down cleared the other's wake. The slot now holds a wake
6101 /// fan-out, so a member's teardown is leaving it, and the callback comes
6102 /// off the backend only with the session's owner or its last member.
6103 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6104 pub(crate) fn clear_wake_signal(&mut self) {
6105 use nros_rmw::Session as _;
6106 if !self.wake_cb_installed {
6107 return;
6108 }
6109 self.wake_cb_installed = false;
6110 // phase-436 — a fan-out member leaves first; the callback comes off
6111 // the backend only when the session's OWNER leaves (the session dies
6112 // with it) or the last member does. A membership from an entry the
6113 // owner already released is a no-op, and must not touch the entry's
6114 // next tenant.
6115 let take_off_primary = match self.wake_membership.take() {
6116 Some(m) => {
6117 let fanout = m.fanout();
6118 let current = m.is_current();
6119 m.leave(nros_platform_api::yield_now);
6120 let owned = matches!(self.session, SessionStore::Owned(_));
6121 if current && (owned || fanout.member_count() == 0) {
6122 // SAFETY: as below; the entry is released only after the
6123 // backend can no longer start a callback on it.
6124 unsafe { self.session.set_wake_callback(None, core::ptr::null_mut()) };
6125 fanout.release(nros_platform_api::yield_now);
6126 }
6127 false
6128 }
6129 None => true,
6130 };
6131 // SAFETY: clearing takes no context, and both sessions are still live
6132 // here — this runs BEFORE `Session::close` in `close()` and before the
6133 // session field is dropped in `Drop`.
6134 unsafe {
6135 if take_off_primary {
6136 self.session.set_wake_callback(None, core::ptr::null_mut());
6137 }
6138 for s in self.extra_sessions.iter_mut() {
6139 s.set_wake_callback(None, core::ptr::null_mut());
6140 }
6141 }
6142 self.has_async_wake = false;
6143 }
6144
6145 /// phase-467 Row 8 — hand the PRIMARY session a graph-change target it may
6146 /// trigger from a worker thread, and take ownership of that target.
6147 ///
6148 /// The install half of the pair; [`clear_graph_change_signal`] is the
6149 /// other and the executor owes it. `guard` is CLONED, so the caller keeps
6150 /// its own handle to the same arena flag — that is the whole point: the
6151 /// pointer the backend receives is the executor's box, never the caller's
6152 /// storage. Issue 1385 is the same hazard on the wake slot, found after
6153 /// the fact; this is the shape that cannot reach it.
6154 ///
6155 /// PRIMARY only, deliberately. A graph is a property of a session, and the
6156 /// extra sessions of `node_builder.rmw(...)` each have their own — feeding
6157 /// several into one guard would make "the graph changed" unattributable
6158 /// while costing the caller the ability to ask which. A caller wanting a
6159 /// second backend's graph asks that session for its own.
6160 ///
6161 /// Returns `Err(Unsupported)` from a backend with no graph. A previously
6162 /// installed target is retired first, so an executor never has two.
6163 ///
6164 /// [`clear_graph_change_signal`]: Self::clear_graph_change_signal
6165 ///
6166 /// `pub(crate)`, not `pub`: the public verb is
6167 /// [`register_graph_change_guard_on`], which does the probe, the
6168 /// registration and this in the one order that cannot hand back a guard
6169 /// nothing fires. Exposing the install alone would let a caller reach the
6170 /// second step without the first.
6171 ///
6172 /// [`register_graph_change_guard_on`]: Self::register_graph_change_guard_on
6173 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6174 pub(crate) fn install_graph_change_guard(
6175 &mut self,
6176 guard: &GuardCondition,
6177 ) -> Result<(), NodeError> {
6178 use nros_rmw::Session as _;
6179 self.clear_graph_change_signal();
6180 let boxed = alloc::boxed::Box::new(guard.clone());
6181 let ptr = (&*boxed) as *const GuardCondition as *const core::ffi::c_void;
6182 // SAFETY: `ptr` names heap storage this executor owns and which
6183 // `clear_graph_change_signal` retires from the backend before it is
6184 // freed. `GuardCondition` is `Send + Sync` and its `trigger()` is a
6185 // relaxed store plus the runtime wake hook, which is what a foreign
6186 // thread is allowed to do here.
6187 let rc = unsafe {
6188 self.session
6189 .set_graph_change_callback(Some(nros_rmw_runtime_graph_change_cb), ptr)
6190 };
6191 match rc {
6192 Ok(()) => {
6193 self.graph_change_target = Some(boxed);
6194 Ok(())
6195 }
6196 // `boxed` drops here, and nothing holds its pointer: the backend
6197 // either refused or failed, so there is no installed callback to
6198 // retire first.
6199 Err(e) => Err(NodeError::Transport(e)),
6200 }
6201 }
6202
6203 /// phase-467 Row 8 — can the primary session deliver a graph-change edge?
6204 ///
6205 /// Asked by CLEARING, which is the one probe that costs nothing and cannot
6206 /// lie: a backend with no graph answers `Unsupported` to a `None` install
6207 /// just as it does to a real one, and an idempotent clear on a backend
6208 /// that HAS a graph is a no-op. Nullity of the vtable slot cannot answer
6209 /// it — `rust_adapter.rs` fills the slot for every Rust backend whether or
6210 /// not that backend overrides the trait method, which
6211 /// `check-rmw-slot-producers`'s own report says in as many words.
6212 ///
6213 /// The caller is `register_graph_change_guard_on`, which asks BEFORE it
6214 /// consumes an executor handle slot, so an unsupported backend costs
6215 /// nothing and the caller is never handed a guard nothing fires.
6216 /// `pub(crate)` for the same reason the install is: asking costs a CLEAR,
6217 /// so a caller that asks and then does nothing has silently retired a live
6218 /// target. The public verb asks once, on the caller's behalf.
6219 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6220 pub(crate) fn supports_graph_change_callback(&mut self) -> bool {
6221 use nros_rmw::Session as _;
6222 // Retire whatever is installed first — the probe IS a clear, so doing
6223 // it without this would silently drop a live target.
6224 self.clear_graph_change_signal();
6225 // SAFETY: a `None` install takes no context and installs nothing.
6226 unsafe {
6227 self.session
6228 .set_graph_change_callback(None, core::ptr::null())
6229 .is_ok()
6230 }
6231 }
6232
6233 /// phase-467 Row 8 — the teardown half of
6234 /// [`install_graph_change_guard`](Self::install_graph_change_guard), and
6235 /// the reason that install cannot recreate issue 1385.
6236 ///
6237 /// Order is the whole content: tell the backend to stop FIRST, then drop
6238 /// the box it was pointing at. Reversed, the window between the free and
6239 /// the clear is exactly 1385's dangling callback — and on the C path that
6240 /// window is arbitrary, because the session outlives the executor.
6241 ///
6242 /// Latched on `graph_change_target.is_some()`, not on a separate flag: the
6243 /// box IS the record of having installed, so `close()` followed by drop
6244 /// clears once and an executor that never installed clears nothing. That
6245 /// is the same predicate `clear_wake_signal` needs a bool for only because
6246 /// its context is shared with `signal_fd()` and every guard condition.
6247 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6248 pub(crate) fn clear_graph_change_signal(&mut self) {
6249 use nros_rmw::Session as _;
6250 if self.graph_change_target.is_none() {
6251 return;
6252 }
6253 // SAFETY: clearing takes no context, and the session is still live
6254 // here — this runs BEFORE `Session::close` in `close()` and before the
6255 // session field is dropped in `Drop`.
6256 unsafe {
6257 let _ = self
6258 .session
6259 .set_graph_change_callback(None, core::ptr::null());
6260 }
6261 // Only now. The backend has been told, so nothing can be holding this
6262 // pointer when it is freed.
6263 self.graph_change_target = None;
6264 }
6265
6266 /// Phase 124.B.2 — opaque context pointer the runtime wake
6267 /// callback receives. Encodes `(flag, mu, cv)` as a borrowed
6268 /// `&WakeCtx` reference; the callback decodes via
6269 /// `*const WakeCtx`.
6270 ///
6271 /// Lifetime: tied to the Executor instance. WakeCtx storage
6272 /// lives inside Executor (lazy-allocated on first install), so
6273 /// the pointer stays valid as long as the Executor is.
6274 /// Phase 124.B.7.c — POSIX signal-handler-safe wake fd.
6275 ///
6276 /// Returns a Linux `eventfd` that callers (typically POSIX
6277 /// signal handlers) can `write(fd, &1u64, 8)` to from any
6278 /// context, including signal handlers. A runtime-owned worker
6279 /// thread reads the fd and signals the executor's `NodeWake`, unblocking
6280 /// `spin_once`.
6281 ///
6282 /// The worker thread is spawned lazily on first call and
6283 /// joined on Executor drop. Linux-only and gated behind
6284 /// `feature = "signal-fd-wake"`; binaries that don't install
6285 /// signal handlers shouldn't enable it.
6286 ///
6287 /// Returns the raw fd. The Executor retains ownership; do not
6288 /// `close()` it from the caller.
6289 #[cfg(all(feature = "signal-fd-wake", feature = "rmw-cffi", target_os = "linux"))]
6290 /// phase-359 W10 — was `std::io::Result`. Same values, an error type that
6291 /// does not require `std`.
6292 pub fn signal_fd(&mut self) -> Result<core::ffi::c_int, NodeError> {
6293 let ctx_ptr = self.wake_ctx_ptr() as *const WakeCtx;
6294 if self.signal_fd.is_none() {
6295 self.signal_fd = Some(WakeSignalFd::new(ctx_ptr)?);
6296 }
6297 Ok(self.signal_fd.as_ref().expect("just set").fd())
6298 }
6299
6300 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6301 fn wake_ctx_ptr(&mut self) -> *mut core::ffi::c_void {
6302 if self.wake_ctx.is_none() {
6303 self.wake_ctx = Some(portable_atomic_util::Arc::new(WakeCtx::new(
6304 self.wake_flag.clone(),
6305 self.node_wake.clone(),
6306 )));
6307 }
6308 let arc = self.wake_ctx.as_ref().expect("just set");
6309 portable_atomic_util::Arc::as_ptr(arc) as *mut core::ffi::c_void
6310 }
6311
6312 /// Phase 104.C.4 — apply a Node's default SchedContext to a
6313 /// freshly-registered handle. Called from every `_inner`
6314 /// register variant after the entry slot is committed. No-op
6315 /// when `node_id` is None (legacy path), when the Node is
6316 /// out of range, or when the Node's `default_sched` is the
6317 /// auto-created Fifo slot (0) which matches the executor's
6318 /// default binding already.
6319 ///
6320 /// Phase 273 (RFC-0047) — extends with an optional `group` name.
6321 /// Precedence: **group table > node default > no binding** (SC 0).
6322 /// When `group` is `Some(g)`, consults `group_sched_table` first;
6323 /// if no entry exists for `(name, namespace, g)` falls back to the
6324 /// node's `default_sched`. When `group` is `None` the group table
6325 /// is not consulted (unchanged phase-272 path).
6326 ///
6327 /// Handles can still override per-call via
6328 /// `bind_handle_to_sched_context(handle, sc_id)` post-register.
6329 pub(crate) fn apply_node_default_sched(
6330 &mut self,
6331 slot: usize,
6332 node_id: Option<super::node_record::NodeId>,
6333 group: Option<&str>,
6334 ) {
6335 let Some(id) = node_id else { return };
6336 // Copy name, namespace, and default_sched out so the borrow on
6337 // `self.nodes` is released before the immutable `lookup_group_sched`
6338 // borrow and the mutable `sched_context_bindings` write below.
6339 let (name, namespace, node_sc, rank) = {
6340 let Some(rec) = self.nodes.get(id.index()) else {
6341 return;
6342 };
6343 (
6344 rec.name.clone(),
6345 rec.namespace.clone(),
6346 rec.default_sched,
6347 rec.rank,
6348 )
6349 };
6350 // phase-509 P2 -- the entry dispatches at its node's rank, whatever
6351 // sched context it ends up bound to.
6352 if let Some(tag) = self.slot_tags.get_mut(slot) {
6353 tag.rank = rank;
6354 }
6355 // Phase 273: group table > node default.
6356 let sc = match group {
6357 Some(g) => self
6358 .lookup_group_sched(name.as_str(), namespace.as_str(), g)
6359 .unwrap_or(node_sc),
6360 None => node_sc,
6361 };
6362 if sc.0 == 0 {
6363 return;
6364 }
6365 if slot >= self.entries.len() {
6366 return;
6367 }
6368 let sc_idx = sc.0 as usize;
6369 if sc_idx >= self.sched_contexts.len() || self.sched_contexts[sc_idx].is_none() {
6370 return;
6371 }
6372 self.sched_context_bindings[slot] = sc;
6373 }
6374
6375 /// Phase 104.C.3.2 — scoped Node-handle access. The closure
6376 /// receives a [`NodeHandle`](crate::executor::NodeHandle) bound to the
6377 /// requested [`NodeId`](crate::executor::NodeId)'s
6378 /// session + identity. Use the standard `Node::create_publisher`,
6379 /// `create_subscription`, etc. APIs inside.
6380 ///
6381 /// rclcpp-aligned bridge pattern:
6382 ///
6383 /// ```ignore
6384 /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
6385 /// let node_out = exec.node_builder("egress").rmw("xrce").build()?;
6386 ///
6387 /// let pub_out = exec.with_node(node_out, |n| {
6388 /// n.create_publisher::<Int32>("/fwd")
6389 /// })??;
6390 ///
6391 /// exec.with_node(node_in, |n| {
6392 /// n.create_subscription_buffered::<Int32, _, 1024>(
6393 /// "/src", qos(), move |m| { let _ = pub_out.publish(m); }
6394 /// )
6395 /// })??;
6396 /// ```
6397 ///
6398 /// The closure can return any type; double-`?` unwraps the
6399 /// outer `Result<R, NodeError>` from `with_node` and the inner
6400 /// result returned by the closure.
6401 /// Phase 104.C.3.3.d — flat-Result variant of
6402 /// [`with_node`](Self::with_node). When the closure already
6403 /// returns `Result<R, NodeError>`, this avoids the double-`?`:
6404 ///
6405 /// ```ignore
6406 /// // Without `with_node_try`:
6407 /// let pub_ = exec.with_node(id, |n| n.create_publisher(...))??;
6408 ///
6409 /// // With `with_node_try`:
6410 /// let pub_ = exec.with_node_try(id, |n| n.create_publisher(...))?;
6411 /// ```
6412 pub fn with_node_try<R>(
6413 &mut self,
6414 id: super::node_record::NodeId,
6415 f: impl FnOnce(&mut NodeHandle<'_>) -> Result<R, NodeError>,
6416 ) -> Result<R, NodeError> {
6417 self.with_node(id, f)?
6418 }
6419
6420 pub fn with_node<R>(
6421 &mut self,
6422 id: super::node_record::NodeId,
6423 f: impl FnOnce(&mut NodeHandle<'_>) -> R,
6424 ) -> Result<R, NodeError> {
6425 let (name, ns, session_idx) = {
6426 let r = self
6427 .nodes
6428 .get(id.index())
6429 .ok_or(NodeError::InvalidSchedContextBinding)?;
6430 (r.name.clone(), r.namespace.clone(), r.session_idx)
6431 };
6432 let monitors = self.monitor_table;
6433 let age_monitors = self.age_table;
6434 let epoch = self.epoch_us_fn;
6435 let domain_id = self.domain_id;
6436 let (session, remaps) = self
6437 .session_and_remaps_at_mut(session_idx)
6438 .ok_or(NodeError::BackendMismatch)?;
6439 // SAFETY: short-lived scoped reference. `Node::new` takes
6440 // `&mut ConcreteSession`; lifetime is bound to this fn's
6441 // body via the closure's borrow of `node`.
6442 // issue 0801 (second half) — the executor's domain, NOT a literal 0.
6443 // 429d5a581 fixed the ELEVEN arena `TopicInfo`s and left the THREE
6444 // `NodeHandle::new` sites, so an entity created through a node HANDLE
6445 // (`with_node`, `node`, ~~`node_on`~~) still declared on domain 0 while the
6446 // arena path declared on the configured one. Same split the issue is
6447 // about, one constructor over: `loan_e2e` publishes through a handle and
6448 // subscribes through the arena, so it delivered on domain 0 and on
6449 // nothing else.
6450 let mut node = NodeHandle::new(name, ns, session, remaps, domain_id);
6451 // RFC-0052 W3b.4/.5 — seed the baked monitor tables so contracted
6452 // publishers/subscribers attach their cells without entry glue.
6453 node.set_monitors(monitors);
6454 node.set_age_monitors(age_monitors, epoch);
6455 Ok(f(&mut node))
6456 }
6457
6458 /// Find a registered executor node by final name and namespace.
6459 pub fn node_id_by_name(
6460 &self,
6461 name: &str,
6462 namespace: &str,
6463 ) -> Option<super::node_record::NodeId> {
6464 self.nodes
6465 .iter()
6466 .enumerate()
6467 .find(|(_, node)| node.name.as_str() == name && node.namespace.as_str() == namespace)
6468 .map(|(index, _)| super::node_record::NodeId::from_raw(index as u8))
6469 }
6470
6471 /// Create a node on this executor.
6472 ///
6473 /// Registers the node in the executor's table, deduplicating on
6474 /// `(name, namespace)` — phase-376 W5/B1.
6475 ///
6476 /// # This is `rclrs::Executor::create_node`
6477 ///
6478 /// phase-427 W10 (RFC-0089 "Context and `init`, settled"). Same name, same
6479 /// place in the sequence — `Context` → executor → node — so a ported rclrs
6480 /// `main` lands here unchanged:
6481 ///
6482 /// ```ignore
6483 /// let context = nros::Context::default_from_env()?;
6484 /// let mut executor = context.create_executor()?;
6485 /// let mut node = executor.create_node("talker")?;
6486 /// ```
6487 ///
6488 /// Two differences from upstream, both structural and both named in the
6489 /// ledger row (`rust:Executor::create_node`): ours takes `&mut self`,
6490 /// because the node is registered in the executor's compile-time-sized
6491 /// table rather than allocated, and it hands back a borrowing
6492 /// [`NodeHandle`] rather than an `Arc<Node>`, because there is no
6493 /// allocator. Neither is silent — the compiler names both.
6494 ///
6495 /// The name comes from HERE, not from
6496 /// [`ExecutorConfig::node_name`](crate::ExecutorConfig::node_name), whose
6497 /// builder spelling is deprecated for exactly this reason: one executor
6498 /// holds several named nodes (RFC-0047), so a name on the session config
6499 /// can only ever describe the first of them.
6500 ///
6501 /// Until 2026-08-24 this path registered NOTHING. It built a `NodeHandle`
6502 /// and returned it, so `node_id_by_name` could not find a node the caller
6503 /// had just created, and two calls with one name handed out two handles the
6504 /// executor had never heard of. `create_node_on_with_domain` had the dedup
6505 /// (phase-267 added it there when N bridge endpoints overflowed the table);
6506 /// the plain path never got it.
6507 ///
6508 /// That is a prerequisite for the `create_node` vtable slot, whose contract
6509 /// is that the runtime calls it ONCE per distinct `(name, namespace)`:
6510 /// without a registry to check, the runtime would call it once per
6511 /// `create_node` and every backend would need its own dedup — which is the
6512 /// registry the slot exists to delete (zenoh's `ensure_node_liveliness`
6513 /// linear-scans `per_node_liveliness` for exactly this reason).
6514 ///
6515 /// The table is bounded by `MAX_NODES` (`NROS_EXECUTOR_MAX_NODES`, default
6516 /// 4), so a caller creating a fifth DISTINCT node now gets
6517 /// `NodeError::NodeTableFull` where it previously got a handle. That is the
6518 /// bound doing its job: a node the executor does not know about cannot
6519 /// carry a sched context, a QoS override, or a graph identity. Repeated
6520 /// calls with the SAME name are free.
6521 ///
6522 /// phase-515 R2 -- takes rclrs's `impl IntoNodeOptions`: a bare name
6523 /// (`create_node("talker")`) or a [`NodeOptions`](crate::NodeOptions)
6524 /// (`create_node("talker".namespace("/robot"))`). Here the options'
6525 /// DEFAULTS mean "the executor's": namespace `/` places the node in the
6526 /// executor's own namespace and domain `0` on its own session, as a bare
6527 /// name always did. A non-default value is applied, so an explicit `/`
6528 /// under an executor configured with another namespace, or an explicit
6529 /// domain 0 under another domain, is not expressible here; open a node on
6530 /// another domain with [`create_node_on_with_domain`](Self::create_node_on_with_domain).
6531 pub fn create_node<'o>(
6532 &mut self,
6533 options: impl crate::IntoNodeOptions<'o>,
6534 ) -> Result<super::node::Node<'_, 's>, NodeError> {
6535 let options = options.into_node_options();
6536 let name = options.name;
6537 if name.len() > 64 {
6538 return Err(NodeError::NameTooLong);
6539 }
6540 let namespace = (options.namespace != "/").then_some(options.namespace);
6541 // Dedup against the namespace the node lands in -- the executor's
6542 // own when none is named, which is what `node_builder` gives it.
6543 // phase-483 W2: this returns THE node type (`Node`, re-exported as
6544 // `nros::Node`), the same handle `node_mut` gives a component, where
6545 // it used to return a session-borrowing `NodeHandle` with a different
6546 // method set.
6547 let lookup_ns = match namespace {
6548 Some(ns) => heapless::String::<64>::try_from(ns).map_err(|_| NodeError::NameTooLong)?,
6549 None => self.namespace.clone(),
6550 };
6551 let id = match self.node_id_by_name(name, lookup_ns.as_str()) {
6552 Some(id) => id,
6553 None => {
6554 let mut builder = self.node_builder(name);
6555 if let Some(ns) = namespace {
6556 builder = builder.namespace(ns);
6557 }
6558 if options.domain_id != 0 {
6559 builder = builder.domain_id(options.domain_id);
6560 }
6561 builder.build()?
6562 }
6563 };
6564 Ok(super::node::Node::new(self, id))
6565 }
6566
6567 /// Phase 128.F.2 — bridge-mode node factory. Registers a Node
6568 /// bound to the named RMW backend by opening (or reusing) an
6569 /// extra session via `node_builder().rmw(rmw).build()`, then
6570 /// returns a [`NodeHandle`](crate::executor::NodeHandle) borrowing that
6571 /// session. Use when the
6572 /// binary intentionally links more than one backend and a Node
6573 /// must speak a specific one.
6574 ///
6575 /// The single-backend common case should keep using
6576 /// [`create_node`](Self::create_node) — this entry costs an
6577 /// extra session lookup and serves no purpose when only one
6578 /// backend is registered.
6579 #[cfg(feature = "rmw-cffi")]
6580 pub fn create_node_on(
6581 &mut self,
6582 name: &str,
6583 rmw: &str,
6584 ) -> Result<super::node::Node<'_, 's>, NodeError> {
6585 self.create_node_on_with_domain(name, rmw, None, None)
6586 }
6587
6588 /// Like [`create_node_on`](Self::create_node_on) but pins the extra
6589 /// session's domain id. Required for a multi-domain config-driven bridge:
6590 /// an extra RMW session's participant domain follows the **node builder's**
6591 /// `domain_id` (`resolve_session_slot` → `domain_id.unwrap_or(0)`), NOT the
6592 /// `SessionSpec`'s — so without this an egress on a non-zero domain silently
6593 /// opens on domain 0 and never matches its receiver (phase-267 issue 0109).
6594 /// `None` domain preserves the legacy domain-0 default. `locator` pins the
6595 /// extra session's address — REQUIRED for an agent-based backend (xrce: the
6596 /// Micro-XRCE-DDS Agent addr) whose session can't be opened locator-less;
6597 /// `None` keeps the rmw-default (cyclonedds is domain-discovered, no locator).
6598 pub fn create_node_on_with_domain(
6599 &mut self,
6600 name: &str,
6601 rmw: &str,
6602 domain_id: Option<u32>,
6603 locator: Option<&str>,
6604 ) -> Result<super::node::Node<'_, 's>, NodeError> {
6605 if name.len() > 64 {
6606 return Err(NodeError::NameTooLong);
6607 }
6608 // Reuse an existing Node of this name rather than growing the node table
6609 // (phase-267 non-flat): a config-driven bridge calls this once per bridge
6610 // ENDPOINT, and the same session node (`s0`/`s1`) recurs across every
6611 // `[[bridge]]`. Without dedup, N bridges push 2N records and overflow
6612 // `MAX_NODES`. Names are unique per session in a generated bridge config,
6613 // so matching by name is unambiguous.
6614 let id = if let Some(i) = self.nodes.iter().position(|n| n.name.as_str() == name) {
6615 super::node_record::NodeId::from_raw(i as u8)
6616 } else {
6617 let mut builder = self.node_builder(name).rmw(rmw);
6618 if let Some(d) = domain_id {
6619 builder = builder.domain_id(d);
6620 }
6621 if let Some(loc) = locator {
6622 builder = builder.locator(loc);
6623 }
6624 builder.build()?
6625 };
6626 Ok(super::node::Node::new(self, id))
6627 }
6628
6629 /// Drive transport I/O (poll network, dispatch callbacks).
6630 #[allow(dead_code)]
6631 pub(crate) fn drive_io(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
6632 self.session
6633 .drive_io(timeout_ms)
6634 .map_err(|_| NodeError::Transport(TransportError::PollFailed))
6635 }
6636
6637 /// Close the underlying session, running the shutdown hooks around it.
6638 ///
6639 /// Issue 0790. The order is the feature:
6640 ///
6641 /// 1. every registered PRE-shutdown hook, while the session is still open
6642 /// and every entity still works — this is where a node publishes a final
6643 /// state, answers a last request, parks an actuator or releases a bus;
6644 /// 2. the session close;
6645 /// 3. every registered ON-shutdown hook.
6646 ///
6647 /// A hook runs EXACTLY ONCE: each phase table is emptied before its first
6648 /// hook is invoked, so a second `close()` — or the [`Drop`] sweep after one
6649 /// — finds nothing left to run. Step 2 runs even
6650 /// if a pre-shutdown hook was registered and step 3 even if the close
6651 /// failed — a hook cannot strand the session, and a dead session must not
6652 /// strand the hooks.
6653 ///
6654 /// # This is a CLEAN-STOP facility and nothing more
6655 ///
6656 /// A watchdog reset, a hard fault or a panic does not come through here, so
6657 /// these hooks do not run then. Nothing in a fixed static table can promise
6658 /// otherwise, and an API that implied it would be worse than none: hardware
6659 /// that must be safe across an abnormal stop needs a hardware answer (a
6660 /// pull-down, a watchdog-driven output disable), not a callback.
6661 pub fn close(&mut self) -> Result<(), NodeError> {
6662 self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
6663 // Issue 1385 — hand the callback back BEFORE closing the session that
6664 // holds it. `close()` leaves the executor alive, so without this a
6665 // later `set_wake_callback` from the drop sweep would reach a session
6666 // the backend has already torn down.
6667 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6668 self.clear_wake_signal();
6669 // phase-467 Row 8 — the same obligation on the graph-change slot, and
6670 // it has to be discharged in the same place for the same reason.
6671 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
6672 self.clear_graph_change_signal();
6673 let result = self
6674 .session
6675 .close()
6676 .map_err(|_| NodeError::Transport(TransportError::ConnectionFailed));
6677 self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
6678 result
6679 }
6680
6681 /// Register a hook to run BEFORE the session is closed (issue 0790).
6682 ///
6683 /// rclcpp's `Context::add_pre_shutdown_callback`. Returns the handle
6684 /// [`Self::remove_pre_shutdown_callback`] takes, or
6685 /// [`NodeError::ShutdownCallbacksFull`] when the phase table is full —
6686 /// raise `NROS_EXECUTOR_MAX_SHUTDOWN_CBS` (default 2) at build time.
6687 ///
6688 /// # Safety
6689 /// `callback` must be safe to invoke exactly once with `context`, and
6690 /// `context` must stay valid until the hook runs or is removed. The hook
6691 /// runs on whichever task calls [`Self::close`] (or drops the executor),
6692 /// which is not necessarily the task that registered it.
6693 pub(crate) unsafe fn add_pre_shutdown_callback(
6694 &mut self,
6695 callback: super::types::ShutdownCallbackFn,
6696 context: *mut core::ffi::c_void,
6697 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
6698 Self::claim_shutdown_slot(
6699 &mut self.pre_shutdown_hooks,
6700 super::types::ShutdownPhase::Pre,
6701 callback,
6702 context,
6703 )
6704 }
6705
6706 /// Register a hook to run AFTER the session is closed (issue 0790).
6707 ///
6708 /// rclcpp's `Context::add_on_shutdown_callback` / `rclcpp::on_shutdown`.
6709 /// Entities are gone by the time it runs, so anything that needs the wire
6710 /// belongs in [`Self::add_pre_shutdown_callback`] instead.
6711 ///
6712 /// # Safety
6713 /// Same contract as [`Self::add_pre_shutdown_callback`].
6714 pub(crate) unsafe fn add_on_shutdown_callback(
6715 &mut self,
6716 callback: super::types::ShutdownCallbackFn,
6717 context: *mut core::ffi::c_void,
6718 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
6719 Self::claim_shutdown_slot(
6720 &mut self.on_shutdown_hooks,
6721 super::types::ShutdownPhase::Post,
6722 callback,
6723 context,
6724 )
6725 }
6726
6727 /// Remove a pre-shutdown hook. `true` if `handle` named a live one.
6728 ///
6729 /// rclcpp's `Context::remove_pre_shutdown_callback`, and `bool` for the
6730 /// same reason: "it was not there" is an ordinary answer (the hook may
6731 /// already have run), not an error. A handle issued for the OTHER phase
6732 /// returns `false` and removes nothing — that is what the phase tag in
6733 /// [`ShutdownCallbackHandle`] buys.
6734 ///
6735 /// [`ShutdownCallbackHandle`]: super::types::ShutdownCallbackHandle
6736 pub(crate) fn remove_pre_shutdown_callback(
6737 &mut self,
6738 handle: super::types::ShutdownCallbackHandle,
6739 ) -> bool {
6740 Self::release_shutdown_slot(
6741 &mut self.pre_shutdown_hooks,
6742 super::types::ShutdownPhase::Pre,
6743 handle,
6744 )
6745 }
6746
6747 /// Remove an on-shutdown hook. See [`Self::remove_pre_shutdown_callback`].
6748 pub(crate) fn remove_on_shutdown_callback(
6749 &mut self,
6750 handle: super::types::ShutdownCallbackHandle,
6751 ) -> bool {
6752 Self::release_shutdown_slot(
6753 &mut self.on_shutdown_hooks,
6754 super::types::ShutdownPhase::Post,
6755 handle,
6756 )
6757 }
6758
6759 /// How many hooks are currently registered for `phase`. Diagnostic /
6760 /// test surface, and what a "did my registration land?" assertion reads.
6761 pub fn shutdown_callback_count(&self, phase: super::types::ShutdownPhase) -> usize {
6762 let table = match phase {
6763 super::types::ShutdownPhase::Pre => &self.pre_shutdown_hooks,
6764 super::types::ShutdownPhase::Post => &self.on_shutdown_hooks,
6765 };
6766 table.iter().flatten().count()
6767 }
6768
6769 /// Claim the first free slot of a phase table.
6770 fn claim_shutdown_slot(
6771 table: &mut [Option<super::types::ShutdownHook>],
6772 phase: super::types::ShutdownPhase,
6773 callback: super::types::ShutdownCallbackFn,
6774 context: *mut core::ffi::c_void,
6775 ) -> Result<super::types::ShutdownCallbackHandle, NodeError> {
6776 for (index, slot) in table.iter_mut().enumerate() {
6777 if slot.is_some() {
6778 continue;
6779 }
6780 let handle = super::types::ShutdownCallbackHandle::new(phase, index)
6781 .ok_or(NodeError::ShutdownCallbacksFull)?;
6782 *slot = Some(super::types::ShutdownHook { callback, context });
6783 return Ok(handle);
6784 }
6785 Err(NodeError::ShutdownCallbacksFull)
6786 }
6787
6788 /// Clear a slot of a phase table, rejecting a handle from the other phase.
6789 fn release_shutdown_slot(
6790 table: &mut [Option<super::types::ShutdownHook>],
6791 phase: super::types::ShutdownPhase,
6792 handle: super::types::ShutdownCallbackHandle,
6793 ) -> bool {
6794 if handle.phase() != Some(phase) {
6795 return false;
6796 }
6797 match table.get_mut(handle.index()) {
6798 Some(slot) => slot.take().is_some(),
6799 None => false,
6800 }
6801 }
6802
6803 /// Run — and consume — every hook registered for `phase`, in registration
6804 /// order.
6805 ///
6806 /// The table is EMPTIED BEFORE the first callback runs, not slot by slot as
6807 /// the loop walks it, and both halves of that matter:
6808 ///
6809 /// * "exactly once" becomes a property of the table rather than of the
6810 /// caller — a second `close()`, or the `Drop` sweep after one, finds
6811 /// nothing left to run;
6812 /// * the `&mut self` borrow ENDS before any foreign code is invoked. A hook
6813 /// is an `extern "C" fn` that may hold a raw pointer back to this
6814 /// executor (the C and C++ shims hand out exactly that), so a hook that
6815 /// registers or removes another one must not be running inside a live
6816 /// `&mut` into the table it is touching.
6817 pub(crate) fn run_shutdown_hooks(&mut self, phase: super::types::ShutdownPhase) {
6818 let table = match phase {
6819 super::types::ShutdownPhase::Pre => &mut self.pre_shutdown_hooks,
6820 super::types::ShutdownPhase::Post => &mut self.on_shutdown_hooks,
6821 };
6822 // `ShutdownHook` is `Copy` (a fn pointer and a raw pointer), so this is
6823 // a register-width move of a table whose default size is two slots —
6824 // not a reason to keep the borrow open across the calls.
6825 let hooks = *table;
6826 *table = [None; crate::config::MAX_SHUTDOWN_CBS];
6827 for hook in hooks.iter().flatten() {
6828 // SAFETY: the `add_*_shutdown_callback` caller promised `callback`
6829 // is safe to invoke once with `context`, and that `context` stays
6830 // valid until the hook runs or is removed. This is that one call,
6831 // and the table is already cleared so it cannot happen again.
6832 unsafe {
6833 (hook.callback)(hook.context);
6834 }
6835 }
6836 }
6837
6838 /// Phase 258 (Track 2, 2a) — enroll a component into the executor-owned
6839 /// tick registry. Called by `nros`'s `install`/`register_node_borrowed`
6840 /// after it builds the `Arc<ComponentCell>`: `state` is the leaked
6841 /// `Arc<ComponentCell>` (the slot takes ownership), `tick`/`drop` are the
6842 /// `nros`-side trampolines (see `ComponentSlot`). The slot's `tick`
6843 /// runs at the tail of every [`spin_once`](Self::spin_once); its `drop`
6844 /// runs once on `Executor::drop`. Issue 2020 -- on an executor whose
6845 /// levels own their slots, the component belongs to the registering
6846 /// level, and only that level's pass ticks it.
6847 ///
6848 /// Returns `Err(())` when the registry is full (`MAX_NODES` — raise via
6849 /// `NROS_EXECUTOR_MAX_NODES` at build time). On error the caller still
6850 /// owns `state` (the slot was not stored) and must drop it.
6851 ///
6852 /// # Safety
6853 /// `state` must be a `*mut` produced by leaking the component cell the
6854 /// `tick`/`drop` trampolines expect (an `Arc<ComponentCell>` via
6855 /// `Arc::into_raw` in the canonical `nros` caller), and must remain valid
6856 /// until the matching `drop` runs. `tick` must be safe to invoke with
6857 /// `(state, exec_ctx = *mut Executor)` each spin; `drop` must be safe to
6858 /// invoke exactly once with `state`.
6859 #[doc(hidden)]
6860 #[allow(clippy::result_unit_err)]
6861 pub unsafe fn enroll_component(
6862 &mut self,
6863 state: *mut core::ffi::c_void,
6864 tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
6865 drop: unsafe extern "C" fn(*mut core::ffi::c_void),
6866 ) -> Result<(), ()> {
6867 unsafe { self.enroll_component_ticking(state, tick, drop, true) }
6868 }
6869
6870 /// phase-521 W6 — [`enroll_component`](Self::enroll_component), stating
6871 /// whether the component's `tick` does any work. `ticks == false` lets
6872 /// the entry park past the spin cadence (see
6873 /// [`entry_spin_budget`](Self::entry_spin_budget)).
6874 ///
6875 /// # Safety
6876 /// As [`enroll_component`](Self::enroll_component).
6877 #[doc(hidden)]
6878 #[allow(clippy::result_unit_err)]
6879 pub unsafe fn enroll_component_ticking(
6880 &mut self,
6881 state: *mut core::ffi::c_void,
6882 tick: unsafe extern "C" fn(*mut core::ffi::c_void, *mut core::ffi::c_void),
6883 drop: unsafe extern "C" fn(*mut core::ffi::c_void),
6884 ticks: bool,
6885 ) -> Result<(), ()> {
6886 let level = self.worker.registering_level;
6887 self.component_slots
6888 .push(ComponentSlot {
6889 state,
6890 tick,
6891 drop,
6892 level,
6893 ticks,
6894 })
6895 .map_err(|_| ())
6896 }
6897
6898 /// phase-521 W6 — whether an enrolled component polls from its `tick`.
6899 pub fn has_ticking_component(&self) -> bool {
6900 self.component_slots.iter().any(|s| s.ticks)
6901 }
6902
6903 /// Phase 258 (Track 2, 2a) — current enrolled component-slot count.
6904 /// Diagnostic / test surface.
6905 #[doc(hidden)]
6906 pub fn component_slot_count(&self) -> usize {
6907 self.component_slots.len()
6908 }
6909
6910 /// issue #140 — the enrolled components' opaque `state` pointers, in enroll
6911 /// order. Each is the *leaked* `Arc<ComponentCell>` `enroll_component` was
6912 /// handed (see `ComponentSlot::state`); the `nros` layer re-borrows them
6913 /// to fold per-component dispatch counters into
6914 /// `observed_callback_counts` — install-seam components
6915 /// (`register_node_borrowed`) live ONLY here, not in
6916 /// `ExecutorNodeRuntime::components`, so the hosted-spin counts read zero
6917 /// without this surface.
6918 #[doc(hidden)]
6919 pub fn enrolled_component_states(&self) -> impl Iterator<Item = *mut core::ffi::c_void> + '_ {
6920 self.component_slots.iter().map(|slot| slot.state)
6921 }
6922
6923 /// Get a reference to the underlying session.
6924 pub fn session(&self) -> &session::ConcreteSession {
6925 &self.session
6926 }
6927
6928 /// Get a mutable reference to the underlying session.
6929 pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
6930 &mut self.session
6931 }
6932
6933 /// Phase 124.F.3 — session-level connectivity probe. Wire-level
6934 /// round-trip "is the peer / agent / router still reachable?"
6935 /// — cheaper than the service-availability probe (no discovery
6936 /// state required).
6937 ///
6938 /// Returns `Ok(())` on reply within `timeout_ms`,
6939 /// `Err(NodeError::Transport(Timeout))` on no reply,
6940 /// `Err(NodeError::Transport(Unsupported))` when the active
6941 /// backend can't probe.
6942 ///
6943 /// Mirrors micro-ROS's `rmw_uros_ping_agent`. Useful for
6944 /// reconnect-on-link-loss patterns: bare-metal code can call
6945 /// `ping(100)` periodically and tear down / re-open the session
6946 /// on timeout.
6947 pub fn ping(&mut self, timeout_ms: i32) -> Result<(), NodeError> {
6948 use nros_rmw::Session;
6949 self.session
6950 .ping_session(timeout_ms)
6951 .map_err(NodeError::Transport)
6952 }
6953
6954 /// phase-381 W4 — every node on the graph, with its namespace.
6955 ///
6956 /// `visit(name, namespace, enclave)` is called once per node and returns
6957 /// `false` to stop early. A VISITOR rather than a returned collection
6958 /// because there is no allocator at this layer and the graph has no bound
6959 /// the caller can know; peak extra memory is one entry. Every string is
6960 /// BORROWED for the duration of the call.
6961 ///
6962 /// `enclave` is `None` where the backend does not track one, which is what
6963 /// lets one call answer both `rmw_get_node_names` and
6964 /// `rmw_get_node_names_with_enclaves`.
6965 ///
6966 /// **This reports what has already been DISCOVERED, and never blocks.** The
6967 /// first call after startup legitimately sees a partial graph — the backend
6968 /// keeps a standing query fed by `spin`, so the view fills in over
6969 /// successive calls. Code that waits for a peer should poll, not call once
6970 /// and conclude. An empty result is "nobody seen yet", never "nobody
6971 /// exists".
6972 ///
6973 /// `Err(Transport(Unsupported))` from a backend with no graph — distinct
6974 /// from an empty graph, deliberately.
6975 pub fn get_node_names(
6976 &mut self,
6977 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
6978 ) -> Result<(), NodeError> {
6979 use nros_rmw::Session;
6980 self.session
6981 .get_node_names(visit)
6982 .map_err(NodeError::Transport)
6983 }
6984
6985 /// phase-381 W4 — every topic on the graph, with the types on it.
6986 ///
6987 /// `visit(topic_name, types)` is called once per distinct TOPIC — a topic
6988 /// carrying two types is one call with two entries, not two calls. `types`
6989 /// may legitimately be empty on a partially discovered graph: reporting the
6990 /// name without a type beats dropping it.
6991 ///
6992 /// Same discovery caveat as [`Self::get_node_names`].
6993 pub fn get_topic_names_and_types(
6994 &mut self,
6995 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
6996 ) -> Result<(), NodeError> {
6997 use nros_rmw::Session;
6998 self.session
6999 .get_topic_names_and_types(visit)
7000 .map_err(NodeError::Transport)
7001 }
7002
7003 /// phase-381 W4 — every service on the graph, with its types.
7004 /// As [`Self::get_topic_names_and_types`], over servers and clients.
7005 pub fn get_service_names_and_types(
7006 &mut self,
7007 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7008 ) -> Result<(), NodeError> {
7009 use nros_rmw::Session;
7010 self.session
7011 .get_service_names_and_types(visit)
7012 .map_err(NodeError::Transport)
7013 }
7014
7015 /// phase-381 W4 — how many publishers are visible on `topic_name`.
7016 ///
7017 /// `topic_name` is a ROS name (`"/chatter"`). A count reflects what has
7018 /// been DISCOVERED, so it can be low right after startup and is never a
7019 /// proof of absence — see [`Self::get_node_names`].
7020 pub fn count_publishers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
7021 use nros_rmw::Session;
7022 self.session
7023 .count_publishers(topic_name)
7024 .map_err(NodeError::Transport)
7025 }
7026
7027 /// phase-381 W4 — how many subscribers are visible on `topic_name`.
7028 /// See [`Self::count_publishers`] for the caveats.
7029 pub fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, NodeError> {
7030 use nros_rmw::Session;
7031 self.session
7032 .count_subscribers(topic_name)
7033 .map_err(NodeError::Transport)
7034 }
7035
7036 /// Issue 1978 — how many service CLIENTS are visible on `service_name`,
7037 /// upstream (Jazzy) `rcl_count_clients`. `service_name` is a ROS name
7038 /// (`"/add_two_ints"`); same discovery caveats as
7039 /// [`Self::count_publishers`], and a backend with no graph answers
7040 /// `Unsupported`, never `0`.
7041 pub fn count_clients(&mut self, service_name: &str) -> Result<usize, NodeError> {
7042 use nros_rmw::Session;
7043 self.session
7044 .count_clients(service_name)
7045 .map_err(NodeError::Transport)
7046 }
7047
7048 /// Issue 1978 — how many service SERVERS are visible on `service_name`,
7049 /// upstream (Jazzy) `rcl_count_services`. See [`Self::count_clients`].
7050 pub fn count_services(&mut self, service_name: &str) -> Result<usize, NodeError> {
7051 use nros_rmw::Session;
7052 self.session
7053 .count_services(service_name)
7054 .map_err(NodeError::Transport)
7055 }
7056
7057 /// phase-381 W4 — what one named node PUBLISHES, with the types.
7058 ///
7059 /// `visit(topic_name, types)` per distinct topic. A node the graph has not
7060 /// discovered yields no visits, which is not an error — see
7061 /// [`Self::get_node_names`] for why an empty answer means "not seen yet".
7062 pub fn get_publisher_names_and_types_by_node(
7063 &mut self,
7064 node_name: &str,
7065 node_namespace: &str,
7066 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7067 ) -> Result<(), NodeError> {
7068 use nros_rmw::{GraphEntityKind, Session};
7069 self.session
7070 .get_names_and_types_by_node(
7071 GraphEntityKind::Publisher,
7072 node_name,
7073 node_namespace,
7074 visit,
7075 )
7076 .map_err(NodeError::Transport)
7077 }
7078
7079 /// phase-381 W4 — what one named node SUBSCRIBES to, with the types.
7080 ///
7081 /// **`subscription`, not `subscriber`** — this is rclrs's spelling
7082 /// (`get_subscription_names_and_types_by_node`), and the Rust surface takes
7083 /// its vocabulary from rclrs so a user porting Rust ROS 2 code types what
7084 /// they already know. The C surface says `subscriber` because rcl does, and
7085 /// the vtable slot says `subscriber` because upstream rmw does. Three
7086 /// layers, three upstreams, one word each — not drift. Issue 0788 owns the
7087 /// wider verb sweep.
7088 pub fn get_subscription_names_and_types_by_node(
7089 &mut self,
7090 node_name: &str,
7091 node_namespace: &str,
7092 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7093 ) -> Result<(), NodeError> {
7094 use nros_rmw::{GraphEntityKind, Session};
7095 self.session
7096 .get_names_and_types_by_node(
7097 GraphEntityKind::Subscriber,
7098 node_name,
7099 node_namespace,
7100 visit,
7101 )
7102 .map_err(NodeError::Transport)
7103 }
7104
7105 /// phase-381 W4 — what services one named node SERVES, with the types.
7106 pub fn get_service_names_and_types_by_node(
7107 &mut self,
7108 node_name: &str,
7109 node_namespace: &str,
7110 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7111 ) -> Result<(), NodeError> {
7112 use nros_rmw::{GraphEntityKind, Session};
7113 self.session
7114 .get_names_and_types_by_node(GraphEntityKind::Service, node_name, node_namespace, visit)
7115 .map_err(NodeError::Transport)
7116 }
7117
7118 /// phase-381 W4 — what services one named node CALLS, with the types.
7119 pub fn get_client_names_and_types_by_node(
7120 &mut self,
7121 node_name: &str,
7122 node_namespace: &str,
7123 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
7124 ) -> Result<(), NodeError> {
7125 use nros_rmw::{GraphEntityKind, Session};
7126 self.session
7127 .get_names_and_types_by_node(GraphEntityKind::Client, node_name, node_namespace, visit)
7128 .map_err(NodeError::Transport)
7129 }
7130
7131 /// phase-381 W4 — the publishers on `topic_name`, one visit each.
7132 ///
7133 /// Each `GraphEndpointInfo` BORROWS its strings for the duration of the
7134 /// visit; copy anything kept.
7135 ///
7136 /// It carries no QoS. The granted profile is what would answer "why is
7137 /// nothing arriving", and no backend can read one back yet — reporting the
7138 /// remote's DECLARED profile instead would be a confident wrong answer, so
7139 /// the field is absent rather than misleading.
7140 pub fn get_publishers_info_by_topic(
7141 &mut self,
7142 topic_name: &str,
7143 visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
7144 ) -> Result<(), NodeError> {
7145 use nros_rmw::Session;
7146 self.session
7147 .get_endpoint_info_by_topic(true, topic_name, visit)
7148 .map_err(NodeError::Transport)
7149 }
7150
7151 /// phase-381 W4 — the subscriptions on `topic_name`, one visit each.
7152 /// See [`Self::get_publishers_info_by_topic`].
7153 pub fn get_subscriptions_info_by_topic(
7154 &mut self,
7155 topic_name: &str,
7156 visit: &mut dyn FnMut(&nros_rmw::GraphEndpointInfo<'_>) -> bool,
7157 ) -> Result<(), NodeError> {
7158 use nros_rmw::Session;
7159 self.session
7160 .get_endpoint_info_by_topic(false, topic_name, visit)
7161 .map_err(NodeError::Transport)
7162 }
7163
7164 /// phase-444 — block until at least `count` publishers are visible on
7165 /// `topic_name`, or `timeout` elapses. Upstream's `rcl_wait_for_publishers`.
7166 ///
7167 /// Returns `Ok(true)` when the graph showed at least `count` inside the
7168 /// budget, `Ok(false)` on timeout. The startup-ordering primitive one
7169 /// entity kind over from [`EmbeddedServiceClient::wait_for_service`](super::handles::EmbeddedServiceClient::wait_for_service),
7170 /// and built the same way: a spin on a discovery read, so other
7171 /// subscriptions and timers keep making progress while it waits.
7172 ///
7173 /// **This is the one blocking member of the graph family, and it does not
7174 /// weaken the family's contract — it composes it.** The query underneath
7175 /// ([`count_publishers`](Self::count_publishers)) still reports only what
7176 /// has been DISCOVERED and still never blocks; what waits is this loop,
7177 /// and it waits by POLLING that query. RFC-0021 is the rule that governs
7178 /// it, and that rule does not forbid blocking: it requires a blocking
7179 /// helper to take an executor and drive it, which is what makes the
7180 /// timeout reliable on a single-threaded transport and what makes the
7181 /// reentrancy visible at the call site.
7182 ///
7183 /// **`Unsupported` is propagated IMMEDIATELY, not waited out** — and this
7184 /// is the one place it deliberately differs from `wait_for_service`, which
7185 /// treats a backend that cannot answer as "keep waiting" and reports
7186 /// `Ok(false)` at the deadline (issue 1087). That is right for a service
7187 /// probe, where not-yet-answerable is a transient. It is wrong here: a
7188 /// backend with no graph (XRCE) can never answer, so spending the budget
7189 /// to return `Ok(false)` would report "no publishers appeared" for a
7190 /// backend that cannot see publishers at all — collapsing "cannot tell
7191 /// you" into "not there", which is exactly the distinction RFC-0036 exists
7192 /// to keep and which every other method in this family promises to keep.
7193 ///
7194 /// `count == 0` is satisfied immediately and without asking the backend —
7195 /// including on a backend with no graph, because "at least zero" is true
7196 /// of every graph and needs no discovery to establish.
7197 pub fn wait_for_publishers(
7198 &mut self,
7199 topic_name: &str,
7200 count: usize,
7201 timeout: core::time::Duration,
7202 ) -> Result<bool, NodeError> {
7203 self.wait_for_endpoints(true, topic_name, count, timeout)
7204 }
7205
7206 /// phase-444 — block until at least `count` subscribers are visible on
7207 /// `topic_name`. Upstream's `rcl_wait_for_subscribers`. See
7208 /// [`wait_for_publishers`](Self::wait_for_publishers) for the contract,
7209 /// and for why `Unsupported` returns rather than waits.
7210 pub fn wait_for_subscribers(
7211 &mut self,
7212 topic_name: &str,
7213 count: usize,
7214 timeout: core::time::Duration,
7215 ) -> Result<bool, NodeError> {
7216 self.wait_for_endpoints(false, topic_name, count, timeout)
7217 }
7218
7219 /// The one loop both `wait_for_*` verbs run — a shared helper rather than
7220 /// a second spelling, because the pair differs only in which counter it
7221 /// polls and a copied loop is how the two drift.
7222 fn wait_for_endpoints(
7223 &mut self,
7224 publishers: bool,
7225 topic_name: &str,
7226 count: usize,
7227 timeout: core::time::Duration,
7228 ) -> Result<bool, NodeError> {
7229 if count == 0 {
7230 return Ok(true);
7231 }
7232 // `?`, not a swallow: a backend that cannot see the graph says so now.
7233 if self.count_endpoints(publishers, topic_name)? >= count {
7234 return Ok(true);
7235 }
7236 let spin_interval =
7237 core::time::Duration::from_millis(super::handles::DEFAULT_SPIN_INTERVAL_MS);
7238 let max_spins =
7239 (timeout.as_millis() as u64 / super::handles::DEFAULT_SPIN_INTERVAL_MS).max(1);
7240 let mut budget = super::handles::WaitBudget::new(max_spins, timeout);
7241 loop {
7242 self.spin_once(budget.next_spin_interval(spin_interval));
7243 if self.count_endpoints(publishers, topic_name)? >= count {
7244 return Ok(true);
7245 }
7246 if !budget.tick() {
7247 return Ok(false);
7248 }
7249 }
7250 }
7251
7252 /// Which counter a `wait_for_*` polls. Keeps the boolean's meaning in ONE
7253 /// place instead of at both call sites.
7254 fn count_endpoints(&mut self, publishers: bool, topic_name: &str) -> Result<usize, NodeError> {
7255 if publishers {
7256 self.count_publishers(topic_name)
7257 } else {
7258 self.count_subscribers(topic_name)
7259 }
7260 }
7261
7262 /// Get a mutable reference to an action client core in the arena by entry index.
7263 ///
7264 /// # Safety
7265 /// The caller must ensure that `entry_index` refers to an `ActionClientRawArenaEntry`.
7266 #[doc(hidden)]
7267 pub(crate) unsafe fn action_client_core_mut(
7268 &mut self,
7269 entry_index: usize,
7270 ) -> Option<&mut super::action_core::ActionClientCore> {
7271 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7272 if !matches!(meta.kind, EntryKind::ActionClient) {
7273 return None;
7274 }
7275 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7276 unsafe {
7277 let entry_ptr = arena_ptr.add(meta.offset)
7278 as *mut super::arena::ActionClientRawArenaEntry<
7279 { crate::config::DEFAULT_RX_BUF_SIZE },
7280 { crate::config::DEFAULT_RX_BUF_SIZE },
7281 { crate::config::DEFAULT_RX_BUF_SIZE },
7282 >;
7283 Some(&mut (*entry_ptr).core)
7284 }
7285 }
7286
7287 /// Get a mutable reference to a service-client arena entry (Phase 82).
7288 ///
7289 /// Returns `None` if `entry_index` doesn't refer to a service client
7290 /// entry. The default reply buffer size is assumed because the C API
7291 /// always uses the default — the entry was registered via
7292 /// `register_service_client_raw_sized::<DEFAULT_RX_BUF_SIZE>`.
7293 ///
7294 /// # Safety
7295 /// `entry_index` must refer to a `ServiceClientRawArenaEntry`.
7296 pub(crate) unsafe fn service_client_entry_mut(
7297 &mut self,
7298 entry_index: usize,
7299 ) -> Option<&mut super::arena::ServiceClientRawArenaEntry<{ crate::config::DEFAULT_RX_BUF_SIZE }>>
7300 {
7301 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7302 if !matches!(meta.kind, EntryKind::ServiceClient) {
7303 return None;
7304 }
7305 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7306 unsafe {
7307 let entry_ptr = arena_ptr.add(meta.offset)
7308 as *mut super::arena::ServiceClientRawArenaEntry<
7309 { crate::config::DEFAULT_RX_BUF_SIZE },
7310 >;
7311 Some(&mut *entry_ptr)
7312 }
7313 }
7314
7315 // ------------------------------------------------------------------
7316 // issue 1437 / phase-444 — the ARENA half of the granted-QoS read-back.
7317 //
7318 // An entity registered with a callback lives in the executor arena, and
7319 // the C / C++ handle the user holds is then bookkeeping: its inline
7320 // storage is untouched and the only identity it carries is the entry
7321 // index. So an accessor that reads the user's own storage answers for
7322 // the POLL road and returns an absence on the CALLBACK road — which is
7323 // the road a ported `rclcpp` node takes, i.e. the common one. These
7324 // three close that reach.
7325 //
7326 // Each reads the entry's HANDLE and nothing else, which is why one
7327 // function per kind serves every entry SHAPE of that kind: every arena
7328 // entry is `#[repr(C)]` with its handle as the first field (12
7329 // subscription shapes, 2 service-server, 2 service-client), so
7330 // `arena_base + offset` IS the handle pointer whatever buffer sizes and
7331 // callback types the shape was monomorphised with.
7332 // `service_client_entry_mut` above has to name ONE shape because it
7333 // reaches past the handle; these do not, and that is the whole
7334 // difference. `arena::tests::every_arena_entry_begins_with_its_handle`
7335 // pins the invariant.
7336
7337 /// The `RmwSubscriber` of an arena-registered subscription, by entry index.
7338 ///
7339 /// `None` when `entry_index` names no entry, or names one that is not a
7340 /// subscription.
7341 ///
7342 /// # Safety
7343 /// `entry_index` must be an index this executor handed out. The borrow is
7344 /// valid only while the arena is not re-entered.
7345 #[doc(hidden)]
7346 pub(crate) unsafe fn subscription_handle(
7347 &self,
7348 entry_index: usize,
7349 ) -> Option<&session::RmwSubscriber> {
7350 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7351 if !matches!(meta.kind, EntryKind::Subscription) {
7352 return None;
7353 }
7354 let arena_ptr = self.arena.as_ptr() as *const u8;
7355 // SAFETY: `meta.offset` is inside the arena by construction, and every
7356 // subscription entry shape begins with its `RmwSubscriber`.
7357 Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwSubscriber) })
7358 }
7359
7360 /// [`Executor::subscription_handle`], mutably — issue 1972: registering a
7361 /// status-event callback (`Subscription::register_event_callback`) takes
7362 /// `&mut`, and the C API's subscription is arena-owned.
7363 ///
7364 /// Issue 2060 — taking it also makes the readiness scan probe this
7365 /// subscription's `has_data` on every pass, where its backend polls the
7366 /// status events (see `entity_status_polled`).
7367 ///
7368 /// # Safety
7369 /// See [`Executor::subscription_handle`]; in addition no other borrow of
7370 /// the same entry may be live.
7371 #[doc(hidden)]
7372 pub(crate) unsafe fn subscription_handle_mut(
7373 &mut self,
7374 entry_index: usize,
7375 ) -> Option<&mut session::RmwSubscriber> {
7376 let slot = self.slot_of_raw(entry_index)?;
7377 let meta = self.entries.get(slot)?.as_ref()?;
7378 if !matches!(meta.kind, EntryKind::Subscription) {
7379 return None;
7380 }
7381 let offset = meta.offset;
7382 // Issue 2060 — `&mut` to an arena subscriber's handle exists to
7383 // register a status-event callback, and those events are polled from
7384 // `has_data`: from now on the scan asks it every pass, not only after
7385 // an arrival. Marked before the registration is known to succeed; a
7386 // refused one costs one cheap probe per pass, a missed mark costs the
7387 // event.
7388 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7389 if slot < super::entity_wake::SLOTS {
7390 self.entity_status_polled |= 1u64 << slot;
7391 }
7392 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
7393 // SAFETY: as `subscription_handle`; `&mut self` makes the borrow unique.
7394 Some(unsafe { &mut *(arena_ptr.add(offset) as *mut session::RmwSubscriber) })
7395 }
7396
7397 /// The `RmwServiceServer` of an arena-registered service, by entry index.
7398 ///
7399 /// # Safety
7400 /// See [`Executor::subscription_handle`].
7401 #[doc(hidden)]
7402 pub(crate) unsafe fn service_server_handle(
7403 &self,
7404 entry_index: usize,
7405 ) -> Option<&session::RmwServiceServer> {
7406 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7407 if !matches!(meta.kind, EntryKind::Service) {
7408 return None;
7409 }
7410 let arena_ptr = self.arena.as_ptr() as *const u8;
7411 // SAFETY: as above; every service-server entry shape begins with its
7412 // `RmwServiceServer`.
7413 Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceServer) })
7414 }
7415
7416 /// The `RmwServiceClient` of an arena-registered client, by entry index.
7417 ///
7418 /// # Safety
7419 /// See [`Executor::subscription_handle`].
7420 #[doc(hidden)]
7421 pub(crate) unsafe fn service_client_handle(
7422 &self,
7423 entry_index: usize,
7424 ) -> Option<&session::RmwServiceClient> {
7425 let meta = self.entries.get(self.slot_of_raw(entry_index)?)?.as_ref()?;
7426 if !matches!(meta.kind, EntryKind::ServiceClient) {
7427 return None;
7428 }
7429 let arena_ptr = self.arena.as_ptr() as *const u8;
7430 // SAFETY: as above; both service-client entry shapes begin with their
7431 // `RmwServiceClient` — the typed one through `ServiceClientSendHeader`,
7432 // itself `#[repr(C)]` and handle-first.
7433 Some(unsafe { &*(arena_ptr.add(meta.offset) as *const session::RmwServiceClient) })
7434 }
7435
7436 /// Set the executor-level trigger condition.
7437 ///
7438 /// Controls which handles must be ready before `spin_once` dispatches
7439 /// callbacks. Defaults to [`Trigger::Any`](crate::Trigger).
7440 pub fn set_trigger(&mut self, trigger: Trigger) {
7441 self.trigger = trigger;
7442 }
7443
7444 /// Set the executor data communication semantics.
7445 ///
7446 /// Choose between `Direct` (process in place) and `LET`
7447 /// (snapshot-then-process) semantics. See [`ExecutorSemantics`].
7448 pub fn set_semantics(&mut self, semantics: ExecutorSemantics) {
7449 self.semantics = semantics;
7450 }
7451
7452 /// Set the invocation mode for a specific handle.
7453 ///
7454 /// Controls whether the callback fires on every spin
7455 /// ([`Always`](InvocationMode::Always)) or only when new data
7456 /// arrives ([`OnNewData`](InvocationMode::OnNewData), the default).
7457 pub fn set_invocation(&mut self, id: HandleId, mode: InvocationMode) {
7458 let slot = self.live_slot(id);
7459 if let Some(Some(meta)) = self.entries.get_mut(slot) {
7460 meta.invocation = mode;
7461 }
7462 }
7463
7464 // ========================================================================
7465 // Arena-based callback registration
7466 // ========================================================================
7467
7468 /// Arena bytes claimed by registered entities so far.
7469 ///
7470 /// EXACT, not a worst case: the arena is a bump allocator and every
7471 /// `arena_alloc` charges `size_of::<T>()`, so nothing is reserved per slot.
7472 /// `ARENA_SIZE` itself is derived the other way — every slot budgeted at
7473 /// the ActionClient worst case — which is issue 0900, and this accessor is
7474 /// how an image can find out what it actually needs.
7475 pub fn arena_used(&self) -> usize {
7476 self.arena_used
7477 }
7478
7479 /// Total arena bytes this executor was given.
7480 ///
7481 /// The arena is a borrowed slice; where its storage lives is the caller's
7482 /// choice, not a property of this type. Three answers are real — a `.bss`
7483 /// static (the C/C++ entries, and the `alloc` constructors since phase-392
7484 /// W6), the heap (those constructors' fallback), and a stack-declared
7485 /// `rclc_executor_t`. See `super::backing` and `report_arena_headroom`.
7486 pub fn arena_capacity(&self) -> usize {
7487 self.arena.len()
7488 }
7489
7490 /// Bump-allocate space for `T` in the arena. Returns the byte offset.
7491 pub(crate) fn arena_alloc<T>(&mut self) -> Result<usize, NodeError> {
7492 // Issue 1667 — every region is grain-aligned and at least one free-list
7493 // node long, so it can be listed when it is released.
7494 let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
7495 let size = Self::arena_region_len(core::mem::size_of::<T>());
7496 if let Some((offset, granted)) = self.arena_take_freed(size, align) {
7497 crate::boot_report::note_alloc(size, self.arena_used);
7498 self.note_entry_region(offset, granted);
7499 return Ok(offset);
7500 }
7501 let aligned_offset = (self.arena_used + align - 1) & !(align - 1);
7502 let new_used = aligned_offset + size;
7503 if new_used > self.arena.len() {
7504 // issue 0900 — `BufferTooSmall` is returned by a dozen other paths,
7505 // so on a target where a return code is all you get, arena
7506 // exhaustion is indistinguishable from a message that did not fit.
7507 return Err(self.arena_exhausted(
7508 core::any::type_name::<T>(),
7509 size,
7510 new_used - self.arena.len(),
7511 ));
7512 }
7513 self.arena_used = new_used;
7514 crate::boot_report::note_alloc(size, new_used);
7515 self.note_entry_region(aligned_offset, size);
7516 Ok(aligned_offset)
7517 }
7518
7519 /// Issue 1631 — remember the region an ENTRY allocation handed out, for
7520 /// `emplace_entry` to record. Only the two entry allocators call this; a
7521 /// callback capture rides inside the entry's trailing region (phase-476
7522 /// W0), so it is part of what they record.
7523 fn note_entry_region(&mut self, offset: usize, len: usize) {
7524 self.pending_region = Some(FreedRegion { offset, len });
7525 }
7526
7527 /// Issue 1667 — the length a region of `len` requested bytes occupies:
7528 /// rounded up to the grain, and never shorter than a free-list node.
7529 const fn arena_region_len(len: usize) -> usize {
7530 let len = len.next_multiple_of(ARENA_GRAIN);
7531 if len < ARENA_MIN_REGION {
7532 ARENA_MIN_REGION
7533 } else {
7534 len
7535 }
7536 }
7537
7538 /// Bytes a callback CAPTURE takes in an entry's trailing region: its length
7539 /// rounded up to the arena's `u64` grain. 0 for no capture.
7540 ///
7541 /// phase-476 W0 — a capture used to be a SEPARATE arena allocation
7542 /// (~~`stow_capture`~~ over ~~`arena_alloc_bytes`~~), which `release_entry` never
7543 /// gave back: it frees exactly the region `emplace_entry` recorded, and that
7544 /// was the entry's. Placing the capture at the END of the entry's own
7545 /// trailing region puts it inside the recorded region, so releasing the
7546 /// entry releases its capture.
7547 pub(crate) fn capture_trailing_len(capture: Option<&[u8]>) -> usize {
7548 match capture {
7549 Some(b) if !b.is_empty() => b.len().next_multiple_of(core::mem::align_of::<u64>()),
7550 _ => 0,
7551 }
7552 }
7553
7554 /// Copy a caller's callback CAPTURE to arena offset `at` and return the
7555 /// context pointer the entry should dispatch with — phase-456 W1, made a
7556 /// runtime length by W8, made part of the entry's region by phase-476 W0.
7557 ///
7558 /// `context` is one pointer. That carries a C++ `[this]` capture and not a
7559 /// `[this, state]` (two) or a `[obj, method]` (three, because a
7560 /// pointer-to-member-function is two words on the Itanium ABI) — the
7561 /// distribution phase-442 W0 measured over this tree and the porting corpus.
7562 /// So the capture lives in the arena beside the registration that uses it:
7563 /// arena bytes never move once allocated, and the returned pointer is into
7564 /// them rather than into anything of the caller's, so the C++ side keeps
7565 /// nothing.
7566 ///
7567 /// `at` must start [`capture_trailing_len`](Self::capture_trailing_len)
7568 /// bytes the caller's entry allocation reserved for it. `None` and an empty
7569 /// slice both mean "no capture", and both give the caller's own `context`
7570 /// straight back, which is every pre-W1 caller.
7571 pub(crate) fn place_capture(
7572 &mut self,
7573 at: usize,
7574 capture: Option<&[u8]>,
7575 context: *mut core::ffi::c_void,
7576 ) -> *mut core::ffi::c_void {
7577 let bytes = match capture {
7578 Some(b) if !b.is_empty() => b,
7579 _ => return context,
7580 };
7581 debug_assert!(at + bytes.len() <= self.arena.len());
7582 // SAFETY: the caller's entry allocation reserved
7583 // `capture_trailing_len(capture)` bytes at `at` for exactly this, and
7584 // `bytes` is a caller slice that cannot alias the arena (the arena is
7585 // borrowed mutably by `self` for the whole call).
7586 unsafe {
7587 let dst = (self.arena.as_mut_ptr() as *mut u8).add(at);
7588 core::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len());
7589 dst as *mut core::ffi::c_void
7590 }
7591 }
7592
7593 /// Bump-allocate space for `T` plus `trailing_bytes` extra bytes.
7594 ///
7595 /// Returns `(entry_offset, trailing_offset)`. The trailing region starts
7596 /// immediately after `T` (aligned to 8 bytes).
7597 pub(crate) fn arena_alloc_with_trailing<T>(
7598 &mut self,
7599 trailing_bytes: usize,
7600 ) -> Result<(usize, usize), NodeError> {
7601 let align = core::mem::align_of::<T>().max(ARENA_GRAIN);
7602 let entry_size = core::mem::size_of::<T>();
7603 // Issue 1496 — a released region that holds the entry AND its trailing
7604 // region is reused whole. The trailing region keeps its rule: it starts
7605 // on a `u64` boundary after the entry, so the request is sized by that.
7606 let u64_align = core::mem::align_of::<u64>();
7607 let whole = Self::arena_region_len(entry_size.next_multiple_of(u64_align) + trailing_bytes);
7608 if let Some((entry_offset, granted)) = self.arena_take_freed(whole, align) {
7609 let trailing_offset = (entry_offset + entry_size).next_multiple_of(u64_align);
7610 crate::boot_report::note_alloc(whole, self.arena_used);
7611 self.note_entry_region(entry_offset, granted);
7612 return Ok((entry_offset, trailing_offset));
7613 }
7614 let entry_offset = self.arena_used.next_multiple_of(align);
7615 // Trailing region starts on an 8-byte (u64) boundary after the entry.
7616 let trailing_offset =
7617 (entry_offset + entry_size).next_multiple_of(core::mem::align_of::<u64>());
7618 // Issue 1667 — the region ends on the grain and is at least a node.
7619 let new_used = entry_offset + whole;
7620 debug_assert!(new_used >= trailing_offset + trailing_bytes);
7621 if new_used > self.arena.len() {
7622 // This path reported NOTHING until phase-412's self-report went in,
7623 // while its sibling `arena_alloc` has named the knob since issue
7624 // 0900. Half of arena exhaustion was therefore silent -- and it is
7625 // the half carrying buffered subscriptions and action entries,
7626 // which is what an island image actually allocates.
7627 return Err(self.arena_exhausted(
7628 core::any::type_name::<T>(),
7629 new_used - entry_offset,
7630 new_used - self.arena.len(),
7631 ));
7632 }
7633 self.arena_used = new_used;
7634 crate::boot_report::note_alloc(new_used - entry_offset, new_used);
7635 self.note_entry_region(entry_offset, new_used - entry_offset);
7636 Ok((entry_offset, trailing_offset))
7637 }
7638
7639 /// The ONE exhaustion path every arena allocator goes through — issues
7640 /// 0900, 1036, 1496.
7641 ///
7642 /// It names the knob (`report_arena_exhausted`), writes the first failure
7643 /// and its shortfall to the boot record (the only channel a console-less
7644 /// board has), says whether the failure is a SHAPE problem rather than a
7645 /// size one (released regions that together hold the request, none of
7646 /// which alone does), and — where the image asked for it — halts through
7647 /// the platform's fatal hook, so the record survives instead of the board
7648 /// running on with an entity missing. Three spellings of this existed, and
7649 /// the `_with_trailing` one reported nothing until phase-412.
7650 fn arena_exhausted(&self, entity: &'static str, request: usize, shortfall: usize) -> NodeError {
7651 super::arena::report_arena_exhausted(
7652 entity,
7653 shortfall,
7654 self.arena_used,
7655 self.arena.len(),
7656 self.arena_released(),
7657 request,
7658 );
7659 crate::boot_report::note_alloc_failed(request, shortfall);
7660 super::arena::arena_exhaustion_is_fatal();
7661 NodeError::BufferTooSmall
7662 }
7663
7664 /// Issue 1667 — read the free-list node at `offset`: `(next, len)`.
7665 fn arena_hole(&self, offset: usize) -> (usize, usize) {
7666 // SAFETY: `offset` is a listed free region: in bounds, grain-aligned and
7667 // at least `ARENA_MIN_REGION` long, written by `arena_set_hole`.
7668 unsafe {
7669 let p = (self.arena.as_ptr() as *const u8).add(offset) as *const usize;
7670 (p.read_unaligned(), p.add(1).read_unaligned())
7671 }
7672 }
7673
7674 /// Issue 1667 — write the free-list node at `offset`.
7675 fn arena_set_hole(&mut self, offset: usize, next: usize, len: usize) {
7676 debug_assert!(len >= ARENA_MIN_REGION && offset + len <= self.arena.len());
7677 // SAFETY: as `arena_hole`; the region is free, so nothing else reads it.
7678 unsafe {
7679 let p = (self.arena.as_mut_ptr() as *mut u8).add(offset) as *mut usize;
7680 p.write_unaligned(next);
7681 p.add(1).write_unaligned(len);
7682 }
7683 }
7684
7685 /// Point `prev`'s node (or the head, for `ARENA_FREE_NONE`) at `next`.
7686 fn arena_link(&mut self, prev: usize, next: usize) {
7687 if prev == ARENA_FREE_NONE {
7688 self.arena_free_head = next;
7689 } else {
7690 let (_, len) = self.arena_hole(prev);
7691 self.arena_set_hole(prev, next, len);
7692 }
7693 }
7694
7695 /// Issue 1496 / 1667 — first-fit over the free list. Returns the offset and
7696 /// the length GRANTED, which is the request plus any tail too short to list
7697 /// on its own: that tail belongs to the allocation, so its release gives it
7698 /// back. A hole whose alignment slack would be too short to list is skipped
7699 /// rather than split, for the same reason — no byte is ever orphaned.
7700 fn arena_take_freed(&mut self, size: usize, align: usize) -> Option<(usize, usize)> {
7701 let mut prev = ARENA_FREE_NONE;
7702 let mut cur = self.arena_free_head;
7703 while cur != ARENA_FREE_NONE {
7704 let (next, len) = self.arena_hole(cur);
7705 let start = cur.next_multiple_of(align);
7706 let head = start - cur;
7707 let fits = start + size <= cur + len;
7708 if fits && (head == 0 || head >= ARENA_MIN_REGION) {
7709 let tail = cur + len - (start + size);
7710 let granted = if tail < ARENA_MIN_REGION {
7711 size + tail
7712 } else {
7713 size
7714 };
7715 // What stays listed: the head slack, the tail, both, or neither.
7716 let mut after = next;
7717 if tail >= ARENA_MIN_REGION {
7718 let t = start + size;
7719 self.arena_set_hole(t, after, tail);
7720 after = t;
7721 }
7722 if head > 0 {
7723 self.arena_set_hole(cur, after, head);
7724 } else {
7725 self.arena_link(prev, after);
7726 }
7727 return Some((start, granted));
7728 }
7729 prev = cur;
7730 cur = next;
7731 }
7732 None
7733 }
7734
7735 /// Issue 1496 / 1667 — give a region back. It is inserted in address order
7736 /// and coalesced with both neighbours, so a loop that frees what it
7737 /// allocated returns the arena to the shape it had; a hole that then ends
7738 /// at `arena_used` is handed back to the bump pointer.
7739 pub(crate) fn arena_release(&mut self, offset: usize, len: usize) {
7740 debug_assert!(len >= ARENA_MIN_REGION && len.is_multiple_of(ARENA_GRAIN));
7741 // `pprev` -> `prev` -> [offset] -> `cur`, in address order.
7742 let mut pprev = ARENA_FREE_NONE;
7743 let mut prev = ARENA_FREE_NONE;
7744 let mut cur = self.arena_free_head;
7745 while cur != ARENA_FREE_NONE && cur < offset {
7746 pprev = prev;
7747 prev = cur;
7748 cur = self.arena_hole(cur).0;
7749 }
7750 let (mut start, mut size) = (offset, len);
7751 let mut next = cur;
7752 if next != ARENA_FREE_NONE && start + size == next {
7753 let (after, next_len) = self.arena_hole(next);
7754 size += next_len;
7755 next = after;
7756 }
7757 if prev != ARENA_FREE_NONE {
7758 let (_, prev_len) = self.arena_hole(prev);
7759 if prev + prev_len == start {
7760 start = prev;
7761 size += prev_len;
7762 // `prev` is absorbed: link from ITS predecessor.
7763 prev = pprev;
7764 }
7765 }
7766 if start + size == self.arena_used {
7767 // The top of the arena: give it back to the bump pointer.
7768 self.arena_used = start;
7769 self.arena_link(prev, next);
7770 return;
7771 }
7772 self.arena_set_hole(start, next, size);
7773 self.arena_link(prev, start);
7774 }
7775
7776 /// Issue 1496 — bytes released back to the arena and not yet reused.
7777 /// Bytes handed back to the bump pointer are not counted: `arena_used`
7778 /// already went down by them.
7779 pub fn arena_released(&self) -> usize {
7780 let mut total = 0;
7781 let mut cur = self.arena_free_head;
7782 while cur != ARENA_FREE_NONE {
7783 let (next, len) = self.arena_hole(cur);
7784 total += len;
7785 cur = next;
7786 }
7787 total
7788 }
7789
7790 /// Issue 1667 — how many separate holes the free list holds. For tests and
7791 /// diagnostics.
7792 pub fn arena_holes(&self) -> usize {
7793 let mut count = 0;
7794 let mut cur = self.arena_free_head;
7795 while cur != ARENA_FREE_NONE {
7796 count += 1;
7797 cur = self.arena_hole(cur).0;
7798 }
7799 count
7800 }
7801
7802 /// Issue 1496 — RELEASE one callback entry: drop the entry in place (its
7803 /// RMW handles' `Drop`s destroy the backend entities, so nothing stays
7804 /// advertised), free its callback slot, and give its arena bytes back for
7805 /// reuse. `false`, touching nothing, when `index` is not a live entry of
7806 /// `kind`.
7807 ///
7808 /// Issue 1631 — the bytes released are the ones `emplace_entry` RECORDED
7809 /// from the allocator (`CallbackMeta::arena_len`), entry and trailing
7810 /// region together. 1496's form took the size from the caller, which had
7811 /// to restate the concrete entry type — impossible for a buffered
7812 /// subscription (its trailing region is sized at runtime) or a timer (its
7813 /// closure type is the registering crate's).
7814 ///
7815 /// # Safety
7816 /// No handle naming `index` may be used afterwards: the slot and its bytes
7817 /// go to the next registration.
7818 pub(crate) unsafe fn release_entry(&mut self, index: usize, kind: EntryKind) -> bool {
7819 let live = self
7820 .entries
7821 .get(index)
7822 .and_then(|e| e.as_ref())
7823 .is_some_and(|m| m.kind == kind);
7824 if !live {
7825 return false;
7826 }
7827 // Issue 1667 — the entry's own callback is running (it, or something it
7828 // called, asked for this release). Mark it and let `finish_dispatch` do
7829 // the release when the callback returns. The handle stops resolving
7830 // now, so a second request answers `false` and nothing dispatches it.
7831
7832 if let Some(tag) = self.slot_tags.get(index)
7833 && tag.flags() & super::types::SlotTag::IN_FLIGHT != 0
7834 {
7835 let was = tag.set(super::types::SlotTag::RELEASE_PENDING);
7836 return was & super::types::SlotTag::RELEASE_PENDING == 0;
7837 }
7838 // phase-397 W2 — the slot's next occupant arms (or not) on its own.
7839 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7840 if index < super::entity_wake::SLOTS {
7841 self.entity_attributed &= !(1u64 << index);
7842 self.entity_recheck &= !(1u64 << index);
7843 self.entity_status_polled &= !(1u64 << index);
7844 }
7845 // Issue 2146 -- the slot's next occupant starts inline and unqueued.
7846 self.forget_deferred(index);
7847 let Some(meta) = self.entries[index].take() else {
7848 return false;
7849 };
7850 // SAFETY: the entry was written by its registration and not yet
7851 // dropped; `drop_fn` matches its concrete type.
7852 unsafe {
7853 let data_ptr = (self.arena.as_mut_ptr() as *mut u8).add(meta.offset);
7854 (meta.drop_fn)(data_ptr);
7855 }
7856 // The entry's binding goes; the CONTEXT's budget state stays. It is
7857 // indexed by sched context, not by entry slot, and other entries may
7858 // still be bound to it: clearing `sporadic_states[index]` here dropped
7859 // the budget of whichever context shared the released slot's number.
7860 self.sched_context_bindings[index] = super::sched_context::SchedContextId(0);
7861 // phase10-B3 -- a released trigger subscription stops starting routes.
7862 if matches!(kind, EntryKind::Subscription) {
7863 super::monitor::route_unbind(self.monitor_table, index);
7864 super::monitor::deadline_unbind(self.age_table, index);
7865 }
7866 if let Some(len) = meta.arena_len.get() {
7867 self.arena_release(meta.offset, len);
7868 }
7869 if let Some(tag) = self.slot_tags.get_mut(index) {
7870 tag.owner = 0;
7871 tag.rank = 0;
7872 tag.reset_flags();
7873 }
7874 true
7875 }
7876
7877 /// Issue 1667 — mark `slot`'s callback as running, so a release it requests
7878 /// is deferred rather than dropping the entry under it.
7879 #[inline]
7880 pub(super) fn begin_dispatch(slot_tags: &[super::types::SlotTag], slot: usize) {
7881 if let Some(tag) = slot_tags.get(slot) {
7882 tag.set(super::types::SlotTag::IN_DISPATCH);
7883 }
7884 }
7885
7886 /// Issue 1705 — `slot`'s entry is not to be touched by the spin thread
7887 /// this pass: its release is pending (it is never dispatched again), or
7888 /// (phase-509 P9b item 2) its callback is running, in another worker's
7889 /// window or below this pass on the same stack. So one callback never
7890 /// runs twice at once, and a pass never probes or sweeps an entry whose
7891 /// callback another thread is in.
7892 #[inline]
7893 pub(super) fn slot_held(slot_tags: &[super::types::SlotTag], slot: usize) -> bool {
7894 slot_tags.get(slot).is_some_and(|t| {
7895 t.flags() & (super::types::SlotTag::RELEASE_PENDING | super::types::SlotTag::IN_FLIGHT)
7896 != 0
7897 })
7898 }
7899
7900 /// phase-509 P3 (RFC-0108 D2) -- after a callback, merge what became
7901 /// ready while it ran into this pass's ready set, so the next pick is
7902 /// from the set as it is now.
7903 ///
7904 /// - An attributed slot's arrival (its bit in the ready word) joins.
7905 /// - With [`Self::set_reprobe_each_callback`], a slot that cannot be
7906 /// attributed is probed (`has_data`) and joins when it has data.
7907 /// - A slot the pass already `settled` (queued, dispatched or held back by
7908 /// a gate) does not join: it runs at most once per pass. An arrival for
7909 /// one is kept for the next pass's scan.
7910 /// - A slot whose context gates its dispatch (a sporadic budget, a
7911 /// time-triggered window) waits for the next pass, where the scan
7912 /// applies the gate.
7913 fn merge_mid_pass(
7914 &mut self,
7915 fifo: &mut super::ready_set::RankedFifoSet<{ MAX_CALLBACK_SLOTS }>,
7916 edf: &mut super::ready_set::BucketedEdfSet<
7917 { super::sched_context::Priority::COUNT },
7918 { MAX_CALLBACK_SLOTS },
7919 >,
7920 settled: &mut u64,
7921 arena_ptr: *mut u8,
7922 band_mask: u64,
7923 ) {
7924 let mut fresh: u64 = 0;
7925 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7926 let attributed = self.entity_attributed;
7927 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
7928 let attributed: u64 = 0;
7929 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7930 {
7931 // phase-509 P9b item 2 -- this band's arrivals only.
7932 let arrived = self
7933 .wake_ctx
7934 .as_ref()
7935 .map_or(0, |ctx| take_band_bits(&ctx.ready, band_mask))
7936 & attributed;
7937 // Kept for the next pass: its scan asks `has_data` (the bit has
7938 // left the word, and its wake already ended no park early).
7939 self.entity_recheck |= arrived & *settled;
7940 fresh |= arrived & !*settled;
7941 }
7942 if self.reprobe_each_callback {
7943 let mut probes: u32 = 0;
7944 for (i, meta) in self.entries.iter().enumerate() {
7945 let bit = 1u64 << i;
7946 let Some(meta) = meta else { continue };
7947 if (*settled | attributed | fresh) & bit != 0
7948 || band_mask & bit == 0
7949 || matches!(meta.kind, EntryKind::Timer | EntryKind::GuardCondition)
7950 || matches!(meta.invocation, InvocationMode::Always)
7951 || Self::slot_held(self.slot_tags, i)
7952 {
7953 continue;
7954 }
7955 probes = probes.wrapping_add(1);
7956 // SAFETY: as the readiness scan's probe: `meta.offset` is this
7957 // live entry's arena slot, and `has_data` only reads it.
7958 if unsafe { (meta.has_data)(arena_ptr.add(meta.offset) as *const u8) } {
7959 fresh |= bit;
7960 }
7961 }
7962 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7963 {
7964 self.readiness_probes = self.readiness_probes.wrapping_add(probes);
7965 }
7966 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
7967 let _ = probes;
7968 }
7969 // Issue 2146 -- a deferred slot that became ready mid-pass is queued,
7970 // not joined to the pass.
7971 if self.deferred_mask & fresh != 0 {
7972 fresh = self.defer_ready(fresh);
7973 }
7974 if fresh == 0 {
7975 return;
7976 }
7977 if !self.age_table.is_empty() {
7978 super::monitor::credit_arrivals(self.age_table, fresh);
7979 }
7980 let mut rest = fresh;
7981 while rest != 0 {
7982 let i = rest.trailing_zeros() as usize;
7983 let bit = 1u64 << i;
7984 rest &= !bit;
7985 if self.entries.get(i).is_none_or(|e| e.is_none()) || Self::slot_held(self.slot_tags, i)
7986 {
7987 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
7988 {
7989 self.entity_recheck |= bit & attributed;
7990 }
7991 continue;
7992 }
7993 let sc_idx = self.sched_context_bindings[i].0 as usize;
7994 let sc = self.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
7995 let class = sc.map_or(super::sched_context::SchedClass::Fifo, |sc| sc.class);
7996 let priority = sc.map_or(super::sched_context::Priority::Normal, |sc| sc.priority);
7997 let windowed = self.major_frame_us > 0
7998 && sc.is_some_and(|sc| sc.tt_window_duration_us.get().is_some());
7999 if matches!(class, super::sched_context::SchedClass::Sporadic) || windowed {
8000 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8001 {
8002 self.entity_recheck |= bit & attributed;
8003 }
8004 continue;
8005 }
8006 let is_edf = matches!(class, super::sched_context::SchedClass::Edf);
8007 let job = super::types::ActiveJob {
8008 sort_key: if is_edf {
8009 sc.and_then(|sc| sc.deadline_us.get())
8010 .map_or(u32::MAX, |nz| nz.get())
8011 } else {
8012 i as u32
8013 },
8014 desc_idx: i as super::types::DescIdx,
8015 };
8016 if is_edf {
8017 let _ = edf.insert_into(priority.index(), job);
8018 } else {
8019 let rank = self.slot_tags.get(i).map_or(0, |t| t.rank);
8020 let _ = fifo.insert_at(priority.dispatch_level(rank), job);
8021 }
8022 *settled |= bit;
8023 // As the scan does: a ready attributed slot is asked again next
8024 // spin, since one dispatch may leave data behind.
8025 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8026 {
8027 self.entity_recheck |= bit & attributed;
8028 }
8029 }
8030 }
8031
8032 /// Issue 1667 — `slot`'s callback has returned. Complete a release it
8033 /// requested while running.
8034 pub(super) fn finish_dispatch(&mut self, slot: usize) {
8035 let Some(tag) = self.slot_tags.get(slot) else {
8036 return;
8037 };
8038 // phase-509 P9b item 2 -- a cancel or reset deferred behind this
8039 // callback first, while the entry is still marked running.
8040 if tag.flags()
8041 & (super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING)
8042 != 0
8043 {
8044 self.apply_deferred_timer_op(slot);
8045 }
8046 let Some(tag) = self.slot_tags.get(slot) else {
8047 return;
8048 };
8049 let was = tag.clear(super::types::SlotTag::IN_DISPATCH);
8050 if was & super::types::SlotTag::RELEASE_PENDING != 0 {
8051 self.complete_deferred_release(slot);
8052 }
8053 }
8054
8055 /// Issue 1667 / 1705 — finish a release that was deferred while a dispatch
8056 /// of the entry was in flight, if none is any more. A no-op while one still
8057 /// is: `reap_deferred_releases` retries.
8058 fn complete_deferred_release(&mut self, slot: usize) {
8059 let Some(tag) = self.slot_tags.get(slot) else {
8060 return;
8061 };
8062 let f = tag.flags();
8063 if f & super::types::SlotTag::RELEASE_PENDING == 0
8064 || f & super::types::SlotTag::IN_FLIGHT != 0
8065 {
8066 return;
8067 }
8068 if let Some(kind) = self
8069 .entries
8070 .get(slot)
8071 .and_then(|e| e.as_ref())
8072 .map(|m| m.kind)
8073 {
8074 // SAFETY: the release was requested through a generation-checked
8075 // handle, and `resolve_handle` has refused the slot since; no
8076 // dispatch holds the entry (`IN_FLIGHT` is clear).
8077 let _ = unsafe { self.release_entry(slot, kind) };
8078 }
8079 }
8080
8081 /// Issue 1705 — complete every deferred release whose last dispatch has
8082 /// finished. `finish_dispatch` completes one deferred behind a callback
8083 /// on the spin thread when it returns; this is the backstop at the top
8084 /// of every `spin_once` (it served the retired OS-priority worker pool,
8085 /// whose worker could not touch the executor, phase-509 P11).
8086 pub(crate) fn reap_deferred_releases(&mut self) {
8087 for slot in 0..self.slot_tags.len() {
8088 if self.slot_tags[slot].flags() & super::types::SlotTag::RELEASE_PENDING != 0 {
8089 self.complete_deferred_release(slot);
8090 }
8091 }
8092 }
8093
8094 /// Issue 1631 — release a SUBSCRIPTION entry, whatever registration shape
8095 /// made it (typed, raw, in-place, buffered, with-info). See
8096 /// `release_entry` (crate-private): the entry is dropped, so its
8097 /// subscriber leaves the graph; its slot and arena bytes are reused by the
8098 /// next registration. `false`, touching nothing, when `handle` is not a
8099 /// live subscription.
8100 ///
8101 /// # Safety
8102 /// No copy of `handle` may be used afterwards — the slot is handed to the
8103 /// next registration, which may be of any kind.
8104 pub(crate) unsafe fn release_subscription(&mut self, handle: HandleId) -> bool {
8105 match self.resolve_handle(handle) {
8106 Some(slot) => unsafe { self.release_entry(slot, EntryKind::Subscription) },
8107 None => false,
8108 }
8109 }
8110
8111 /// Issue 1631 — release a TIMER entry; the timer's callback (and anything
8112 /// its closure captured) is dropped and never fires again. As
8113 /// [`release_subscription`](Self::release_subscription).
8114 ///
8115 /// # Safety
8116 /// As [`release_subscription`](Self::release_subscription).
8117 pub(crate) unsafe fn release_timer(&mut self, handle: HandleId) -> bool {
8118 match self.resolve_handle(handle) {
8119 Some(slot) => unsafe { self.release_entry(slot, EntryKind::Timer) },
8120 None => false,
8121 }
8122 }
8123
8124 /// Issue 1631 — release a SERVICE SERVER entry; the server leaves the
8125 /// graph. As [`release_subscription`](Self::release_subscription).
8126 ///
8127 /// # Safety
8128 /// As [`release_subscription`](Self::release_subscription).
8129 pub(crate) unsafe fn release_service(&mut self, handle: HandleId) -> bool {
8130 match self.resolve_handle(handle) {
8131 Some(slot) => unsafe { self.release_entry(slot, EntryKind::Service) },
8132 None => false,
8133 }
8134 }
8135
8136 /// Issue 1631 — release a SERVICE CLIENT entry; the client leaves the
8137 /// graph and any reply it was waiting for is abandoned with it. As
8138 /// [`release_subscription`](Self::release_subscription).
8139 ///
8140 /// # Safety
8141 /// As [`release_subscription`](Self::release_subscription).
8142 pub(crate) unsafe fn release_service_client(&mut self, handle: HandleId) -> bool {
8143 match self.resolve_handle(handle) {
8144 Some(slot) => unsafe { self.release_entry(slot, EntryKind::ServiceClient) },
8145 None => false,
8146 }
8147 }
8148
8149 /// Issue 1667 — release a GUARD CONDITION entry: its callback is dropped and
8150 /// `spin_once` stops polling it; the slot and its arena bytes go to the next
8151 /// registration.
8152 ///
8153 /// Unlike the other release verbs this one has a contract a generation
8154 /// cannot discharge. The [`GuardCondition`] trigger handle holds a pointer
8155 /// to the flag INSIDE the entry, and it is `Clone` + `Send`, so a clone on
8156 /// another thread (an ISR, a worker) would write into whatever reuses those
8157 /// bytes. The C++ `GuardCondition` is non-copyable and the one holder of its
8158 /// trigger handle, which is why its destructor can call this.
8159 ///
8160 /// # Safety
8161 /// No [`GuardCondition`] trigger handle for this entry — the one
8162 /// registration returned, or any clone of it — may be used afterwards.
8163 pub(crate) unsafe fn release_guard_condition(&mut self, handle: HandleId) -> bool {
8164 match self.resolve_handle(handle) {
8165 Some(slot) => unsafe { self.release_entry(slot, EntryKind::GuardCondition) },
8166 None => false,
8167 }
8168 }
8169
8170 /// Find the next free entry slot index.
8171 pub(crate) fn next_entry_slot(&self) -> Result<usize, NodeError> {
8172 self.entries
8173 .iter()
8174 .position(|e| e.is_none())
8175 // Issue 0095 — the callback-entry table (`NROS_EXECUTOR_MAX_CBS`,
8176 // default 4) is full. Distinct from `BufferTooSmall` so the register
8177 // seam can tell the user to raise the knob.
8178 .ok_or(NodeError::ExecutorFull)
8179 }
8180
8181 /// Install a finished [`CallbackMeta`] into its slot.
8182 ///
8183 /// phase-8 (`docs/design/callback_tracing.rst`) — the ONE choke point
8184 /// every registration site funnels through, so the
8185 /// nros_callback_register(handle, kind, name) event is emitted once,
8186 /// here, rather than at the 25 sites that build a `CallbackMeta`. The
8187 /// design's registration half is deliberately EXHAUSTIVE across every
8188 /// `EntryKind` even though the leaf hooks are staged: a callback that was
8189 /// registered but never observed then prints as "registered, not
8190 /// instrumented" instead of being silently absent, so an incomplete hook
8191 /// set announces itself in the output rather than looking like a
8192 /// measurement.
8193 ///
8194 /// It is the assignment — not [`next_entry_slot`](Self::next_entry_slot)
8195 /// — because a slot is claimed BEFORE the fallible work (session lookup,
8196 /// arena allocation, handle creation) that can still return `Err`.
8197 /// Emitting at slot-claim time would announce callbacks that do not
8198 /// exist.
8199 pub(crate) fn emplace_entry(
8200 &mut self,
8201 slot: usize,
8202 mut meta: CallbackMeta,
8203 name: TraceName<'_>,
8204 ) {
8205 // Issue 1631 — record the region the entry's allocation handed out,
8206 // so `release_entry` gives back exactly that. Matched on the OFFSET so
8207 // a stale region (a registration that failed after allocating) is
8208 // never attributed to a different entry.
8209 meta.arena_len = match self.pending_region.take() {
8210 Some(r) if r.offset == meta.offset => super::arena::ArenaLen::new(r.len),
8211 _ => super::arena::ArenaLen::UNRECORDED,
8212 };
8213 trace_register(slot, meta.kind, name);
8214 // phase10-B3 -- a subscription on a route's trigger topic is the
8215 // route's trigger. The table is installed before any node exists.
8216 if let (EntryKind::Subscription, TraceName::Text(topic)) = (meta.kind, &name) {
8217 super::monitor::route_bind(self.monitor_table, topic, slot);
8218 }
8219 // phase-463 W7 — the slot's identity for the host profile, keyed on the
8220 // arena base the dispatch sees (stable for the executor's life).
8221 #[cfg(feature = "profile-mode")]
8222 super::profile::on_register(self.arena.as_ptr() as usize, slot, meta.kind, name);
8223 self.entries[slot] = Some(meta);
8224 // Issue 2018 -- the registering level's own context (0, the default,
8225 // when it has none); a node or group binding still overrides it.
8226 if let Some(b) = self.sched_context_bindings.get_mut(slot) {
8227 *b = super::sched_context::SchedContextId(self.worker.registering_sched);
8228 }
8229 // phase-476 W0 — a new occupant gets a new generation, so every handle
8230 // issued for the slot's previous occupant stops resolving. Owner is
8231 // cleared; a registration made for a node sets it after this
8232 // (`set_entry_owner`).
8233 if let Some(tag) = self.slot_tags.get_mut(slot) {
8234 tag.generation = tag.next_generation();
8235 tag.owner = 0;
8236 tag.rank = 0;
8237 // phase-509 P9b item 4 -- the level being set up owns it.
8238 tag.level = self.worker.registering_level;
8239 tag.reset_flags();
8240 }
8241 }
8242
8243 /// phase-397 W2 — register `slot`'s entity with THIS executor's wake
8244 /// context, through `register` (the entry handle's `register_waker`).
8245 ///
8246 /// Called by every subscription, service and client registration AFTER
8247 /// the entry is written into the arena: the handle's address is stable
8248 /// from then on, and a backend that keeps a pointer into the handle (the
8249 /// cffi route's per-entity `user_data`) must not see it move.
8250 ///
8251 /// Records whether the backend accepted it in `entity_attributed`. A
8252 /// backend that cannot attribute an arrival returns `false`, and the
8253 /// session-scoped wake keeps covering that entry. Without the async wake
8254 /// primitive, or without `rmw-cffi`, there is nothing to wake and this does
8255 /// nothing.
8256 #[allow(unused_variables, unused_mut)]
8257 pub(crate) fn arm_entity_wake(
8258 &mut self,
8259 slot: usize,
8260 register: impl FnOnce(&core::task::Waker) -> bool,
8261 ) {
8262 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8263 {
8264 if self.node_wake.is_none() || slot >= super::entity_wake::SLOTS {
8265 return;
8266 }
8267 self.wake_ctx_ptr();
8268 let Some(waker) = self
8269 .wake_ctx
8270 .as_ref()
8271 .and_then(|ctx| super::entity_wake::waker(ctx, slot))
8272 else {
8273 return;
8274 };
8275 let bit = 1u64 << slot;
8276 if register(&waker) {
8277 self.entity_attributed |= bit;
8278 // W3 — the backend replays nothing that arrived before this
8279 // call, so the first spin asks `has_data` once.
8280 self.entity_recheck |= bit;
8281 } else {
8282 self.entity_attributed &= !bit;
8283 self.entity_recheck &= !bit;
8284 }
8285 }
8286 }
8287
8288 /// [`arm_entity_wake`](Self::arm_entity_wake) for the entry of type `T`
8289 /// at arena `offset`: the one place a registration site's entry is read
8290 /// back to reach its handle.
8291 ///
8292 /// # Safety
8293 ///
8294 /// A `T` was written at `offset` and is the live occupant of `slot`. It
8295 /// stays at that address until its release, which drops the handle (and
8296 /// with it the backend's registration) first.
8297 pub(crate) unsafe fn arm_entry_wake<T>(
8298 &mut self,
8299 slot: usize,
8300 offset: usize,
8301 register: impl FnOnce(&T, &core::task::Waker) -> bool,
8302 ) {
8303 let entry = self
8304 .arena
8305 .as_ptr()
8306 .cast::<u8>()
8307 .wrapping_add(offset)
8308 .cast::<T>();
8309 // SAFETY: the caller's contract — a live `T` at `offset`.
8310 self.arm_entity_wake(slot, |w| register(unsafe { &*entry }, w));
8311 }
8312
8313 /// phase-397 W2 — the ready word, taken: every slot an attributed arrival
8314 /// marked since the last take. `spin_once` consumes it (W3), so a caller
8315 /// that also spins sees only what arrived since the last spin. Tests only.
8316 #[doc(hidden)]
8317 pub fn take_entity_ready_bits(&self) -> u64 {
8318 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8319 if let Some(ctx) = self.wake_ctx.as_ref() {
8320 return ctx.ready.swap(0, core::sync::atomic::Ordering::SeqCst) as u64;
8321 }
8322 0
8323 }
8324
8325 /// phase-397 W2 — the slots whose backend accepted an entity waker.
8326 #[doc(hidden)]
8327 pub fn entity_attributed_slots(&self) -> u64 {
8328 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8329 return self.entity_attributed;
8330 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
8331 0
8332 }
8333
8334 /// phase-397 W3 — how many `has_data` calls the readiness scan has made
8335 /// (wrapping). An attributed slot's readiness comes from its arrival bit,
8336 /// so a spin over attributed entries alone adds nothing here. Tests only;
8337 /// `0` in a build with no entity wake, where it is not counted.
8338 #[doc(hidden)]
8339 pub fn readiness_probe_count(&self) -> u32 {
8340 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
8341 return self.readiness_probes;
8342 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
8343 0
8344 }
8345
8346 /// phase-509 P3 (RFC-0108 D2) -- probe the slots whose backend cannot
8347 /// attribute an arrival (XRCE, uORB) after every callback, so one made
8348 /// ready by a callback runs in the same pass, in rank order, instead of
8349 /// at the next pass's scan. Pushed readiness joins mid-pass either way.
8350 /// The default is `REPROBE_EACH_CALLBACK_DEFAULT` (in `executor/spin.rs`); the phase-509 P3
8351 /// host measurement that chose it is recorded beside that constant.
8352 pub fn set_reprobe_each_callback(&mut self, on: bool) {
8353 self.reprobe_each_callback = on;
8354 }
8355
8356 /// phase-476 W0 — the handle for the CURRENT occupant of `slot`. Every
8357 /// registration returns this, after `emplace_entry` has stamped the slot.
8358 pub(crate) fn handle_for(&self, slot: usize) -> HandleId {
8359 let generation = self.slot_tags.get(slot).map_or(0, |t| t.generation);
8360 HandleId::new(slot, generation)
8361 }
8362
8363 /// phase-476 W0 (issue 1667) — the slot `id` names, if and only if `id` was
8364 /// issued for the entry that occupies it NOW. A handle whose entry was
8365 /// released, or whose slot has since been taken by another registration of
8366 /// any kind, answers `None`, never the next occupant.
8367 pub fn resolve_handle(&self, id: HandleId) -> Option<usize> {
8368 let slot = id.slot();
8369 let live = self.entries.get(slot).is_some_and(|e| e.is_some())
8370 && self
8371 .slot_tags
8372 .get(slot)
8373 .is_none_or(|t| t.flags() & super::types::SlotTag::RELEASE_PENDING == 0);
8374 // Generation 0 is `HandleId::for_owned_slot`: a uniquely-owned holder
8375 // naming its own live slot. Every ISSUED handle carries >= 1.
8376 let current = id.generation() == 0
8377 || self
8378 .slot_tags
8379 .get(slot)
8380 .is_some_and(|t| t.generation == id.generation());
8381 (live && current).then_some(slot)
8382 }
8383
8384 /// Issue 1703 — the slot behind a by-index accessor's `entry_index`
8385 /// argument, which callers hand in in TWO spellings: a bare slot (nros-c's
8386 /// stored `arena_entry_index`, `node_runtime`'s resolved slot, an action's
8387 /// owned slot) or the PACKED handle an FFI issued (`HandleId::to_raw`,
8388 /// what nros-cpp's `handle_id_` and nros-c's `subscription.handle_id`
8389 /// hold since phase-476 W0). A bare slot has generation 0, so both decode
8390 /// through [`HandleId::from_raw`] and resolve here: the slot for a live
8391 /// entry, generation-checked when one was issued, `None` for a stale or
8392 /// foreign handle.
8393 ///
8394 /// The five accessors indexed `entries` with the argument as given. W0 made
8395 /// the FFI value a packed `(generation << 16) | slot`, so every packed
8396 /// caller read past the table and got `None` — `async_send_request` on a
8397 /// C++ callback client returned `INVALID_ARGUMENT` on every call, and the
8398 /// granted-QoS read-backs answered for no entity.
8399 fn slot_of_raw(&self, entry_index: usize) -> Option<usize> {
8400 self.resolve_handle(HandleId::from_raw(entry_index))
8401 .filter(|&slot| !self.running_elsewhere(slot))
8402 }
8403
8404 /// phase-476 W0 — [`resolve_handle`](Self::resolve_handle) as an index that
8405 /// is OUT OF RANGE for a stale handle, so an existing `entries.get(..)`
8406 /// chain answers `None` for it with no further change.
8407 ///
8408 /// phase-509 P9b item 2 -- also out of range while the entry's callback
8409 /// runs in another thread's window (`running_elsewhere`): the accessors
8410 /// that go through here touch the entry's arena data, which that callback
8411 /// owns until it returns. The caller sees what a stale handle gets, and
8412 /// may retry once the callback has returned.
8413 pub(crate) fn live_slot(&self, id: HandleId) -> usize {
8414 self.resolve_handle(id)
8415 .filter(|&slot| !self.running_elsewhere(slot))
8416 .unwrap_or(usize::MAX)
8417 }
8418
8419 /// phase-476 W0 — record that `node` registered the entry `id` names, so
8420 /// [`release_node`](Self::release_node) can find it. `false` when `id` is
8421 /// stale.
8422 pub(crate) fn set_entry_owner(&mut self, id: HandleId, node: crate::executor::NodeId) -> bool {
8423 let Some(slot) = self.resolve_handle(id) else {
8424 return false;
8425 };
8426 let Ok(biased) = u8::try_from(node.index() + 1) else {
8427 return false;
8428 };
8429 self.slot_tags[slot].owner = biased;
8430 true
8431 }
8432
8433 /// phase-476 W0 — release every entry `node` registered, whatever its kind.
8434 /// Returns how many were released.
8435 ///
8436 /// This is what a node's destruction calls. A C++ callback's capture is
8437 /// copied into the arena and commonly holds the node's `this`, so an entry
8438 /// that outlived its node would dispatch into freed memory.
8439 ///
8440 /// # Safety
8441 /// No handle to any of those entries may be used afterwards except through
8442 /// a lookup that checks the generation ([`resolve_handle`](Self::resolve_handle)),
8443 /// which every public lookup on this type does.
8444 pub(crate) unsafe fn release_node(&mut self, node: crate::executor::NodeId) -> usize {
8445 let Ok(biased) = u8::try_from(node.index() + 1) else {
8446 return 0;
8447 };
8448 let mut released = 0;
8449 for slot in 0..self.entries.len() {
8450 if self.slot_tags.get(slot).is_some_and(|t| t.owner == biased) {
8451 let kind = match self.entries[slot].as_ref() {
8452 Some(m) => m.kind,
8453 None => continue,
8454 };
8455 // SAFETY: forwarded from this function's contract.
8456 if unsafe { self.release_entry(slot, kind) } {
8457 released += 1;
8458 }
8459 }
8460 }
8461 released
8462 }
8463
8464 /// Open a subscription: claim a slot, resolve the node's identity, create
8465 /// the backend subscriber, and answer the in-place question ONCE.
8466 ///
8467 /// **phase-456 W8 — the one prologue.** Every registration that creates its
8468 /// own subscriber goes through here, in every language. The two callers
8469 /// that do not are the two that cannot: `add_arena_subscription_callback`
8470 /// is handed a subscriber someone else built (uORB needs a
8471 /// `&'static orb_metadata`, which no `TopicInfo` can express), so there is
8472 /// no topic to resolve and no session to reach — a fourth axis under issue
8473 /// 1319's rows that W8's three-axis decomposition did not predict: WHO
8474 /// CREATED THE SUBSCRIBER. It is a real axis, it is orthogonal to the other
8475 /// three, and it has exactly one member.
8476 ///
8477 /// Everything downstream of this function differs per delivery shape: the
8478 /// arena entry's type, its `try_process`, and whether it has a receive
8479 /// region at all. That part is irreducible and stays at the call site.
8480 pub(crate) fn open_subscription(
8481 &mut self,
8482 req: &SubscriptionRequest<'_>,
8483 ) -> Result<SubscriptionOpen, NodeError> {
8484 let slot = self.next_entry_slot()?;
8485 let (node_name, ns, session_idx) = match req.node_id {
8486 Some(id) => {
8487 let r = self
8488 .nodes
8489 .get(id.index())
8490 .ok_or(NodeError::InvalidSchedContextBinding)?;
8491 (r.name.clone(), r.namespace.clone(), r.session_idx)
8492 }
8493 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
8494 };
8495 let mut topic = TopicInfo::new(req.topic_name, req.type_name, req.type_hash)
8496 .with_domain(self.domain_id)
8497 .with_namespace(&ns);
8498 if !node_name.is_empty() {
8499 topic = topic.with_node_name(&node_name);
8500 }
8501 // Phase 231 (RFC-0038) / phase-402 W2 — hand the backend a
8502 // receive-buffer size so it can size-class its receive storage
8503 // (zenoh-pico: small vs large). Only when the caller actually stated
8504 // one: `with_rx_buffer_hint(0)` would be a claim of "zero bytes", not
8505 // "no opinion".
8506 if let Some(hint) = req.backend_hint {
8507 if hint != 0 {
8508 topic = topic.with_rx_buffer_hint(hint);
8509 }
8510 }
8511 // phase-454 W10/W13 — the depth this registration asks for against the
8512 // depth this system DECLARED for the topic. Before the backend call AND
8513 // before the arena claim, so the region this registration takes and the
8514 // one the build reserved are the same number; unchanged when nothing
8515 // was declared, which is every image with no contract sidecar. See
8516 // `declared_qos::honour` for why Rust TAKES the declared depth where C
8517 // and C++ assert it at the call site.
8518 //
8519 // phase-456 W8 — this used to be `declared_qos::check` here and
8520 // `honour` in each of the twelve hand-rolled prologues this function
8521 // replaced. Collapsing them onto `check` would have silently reverted
8522 // W13 for every Rust registration: `check` validates and returns, while
8523 // `honour` REWRITES the depth the backend is then given. One consulting
8524 // site is the point of this function, and it has to be the honouring
8525 // one.
8526 let qos = crate::declared_qos::honour(topic.type_name, topic.name, req.qos)?;
8527 // phase-519 W3b (RFC-0074) — the subscription's declared arrival rate,
8528 // for a backend that paces delivery itself (XRCE asks its agent for
8529 // `rate_hz x rx_buffer_hint` bytes a second). Unchanged when nothing
8530 // was declared.
8531 let topic = crate::declared_qos::with_declared_arrival_rate(topic);
8532 let age_table = self.age_table;
8533 let (handle, deadline_row) = {
8534 let session = self
8535 .session_at_mut(session_idx)
8536 .ok_or(NodeError::BackendMismatch)?;
8537 // phase-462 W5b — a contract `deadline` the backend cannot judge
8538 // is judged by the silence rule instead, never dropped silently and
8539 // never a boot refusal (`monitor::admit_contract_deadline`).
8540 let (qos, deadline_row) = super::monitor::admit_contract_deadline(
8541 qos,
8542 nros_rmw::Session::supported_qos_policies(session),
8543 age_table,
8544 req.topic_name,
8545 );
8546 let handle = session
8547 .create_subscription(&topic, qos)
8548 .map_err(NodeError::Transport)?;
8549 (handle, deadline_row)
8550 };
8551 if let Some(row) = deadline_row {
8552 row.cell
8553 .deadline_slot
8554 .store(slot as u32 + 1, core::sync::atomic::Ordering::Relaxed);
8555 nros_log::log_info!(
8556 nros_log::get_logger("nros_node"),
8557 "qos: subscription '{}' deadline {} ms is judged by silence-runtime \
8558 (the backend does not judge it)",
8559 req.topic_name,
8560 row.deadline_ms
8561 );
8562 }
8563 // Phase 231 Wave 0.2 (RFC-0038), phase-456 W8 — THE consulting site.
8564 // Deliberately `&&`-guarded by the delivery shape rather than by the
8565 // language: a shape that must hand the callback more than borrowed
8566 // bytes cannot use the capability however capable the backend is.
8567 let in_place = req.in_place_capable && {
8568 use nros_rmw::Subscription as _;
8569 handle.supports_process_in_place()
8570 };
8571 // phase-457 W3 — tell a metadata probe what this CALL SITE answered.
8572 //
8573 // `req.in_place_capable` and NOT `in_place`: the conjunction above
8574 // includes the backend a probe links, which is the RECORDING backend and
8575 // not the one the image ships. The sizing descriptor knows the shipped
8576 // backend from the image's `rmw` name and composes the two halves
8577 // itself; what it cannot see is this half, which is why nine of the
8578 // eleven registration entry points were previously credited with a
8579 // capability they do not have (issue 1340).
8580 //
8581 // AFTER the backend call, so a registration that failed reports
8582 // nothing — a row for a subscription that does not exist would raise the
8583 // probe's count, and an over-count of subscriptions is a real cost in
8584 // every pool that derives from it.
8585 #[cfg(feature = "registration-observer")]
8586 super::registration_observer::observe(super::registration_observer::ObservedRegistration {
8587 topic: topic.name,
8588 type_name: topic.type_name,
8589 in_place_capable: req.in_place_capable,
8590 // Issue 1648 -- the slot this registration is about to claim, read
8591 // off the request rather than inferred: `DEFAULT_RX_BUF_SIZE`
8592 // (`RX_BUF`) is the closure buffer (`unbounded`), anything else a
8593 // stated bound.
8594 claims_closure_buffer: req.slot_bytes == crate::config::DEFAULT_RX_BUF_SIZE,
8595 });
8596 Ok(SubscriptionOpen {
8597 slot,
8598 handle,
8599 qos,
8600 in_place,
8601 slot_bytes: req.slot_bytes,
8602 })
8603 }
8604
8605 /// Typed buffered subscription core (the `node_mut(id).subscription(t)
8606 /// .typed::<M>()` builder lowers here). Routes the typed subscription
8607 /// through the [`NodeId`]'s session + identity (rclcpp `add_node` pattern).
8608 ///
8609 /// Phase 403 W2 -- `rx_bytes` says how many bytes each buffer slot claims
8610 /// from the arena.
8611 ///
8612 /// phase-392 W3c -- `Some(n)` is verbatim; **`None` now means DERIVE from
8613 /// `M`**, falling back to `RX_BUF` only where no bound is reachable (an
8614 /// unbounded type, or a backend whose `MessageForRmw` carries no schema).
8615 /// It used to mean `RX_BUF` unconditionally, so a subscription that named no
8616 /// number was charged the global for every slot and a consumer who did not
8617 /// know `.rx_buffer_from_type()` existed paid it silently. The opt-out is
8618 /// now the explicit one: `.rx_buffer::<N>()` passes `Some(N)`.
8619 ///
8620 /// It is a RUNTIME `usize` and not a second const generic because on THIS
8621 /// path `RX_BUF` was never an array length -- it reaches
8622 /// `buffered_region_size`, `TripleBuffer::init` and `SpscRing::init`, all of
8623 /// which take a plain `usize`. That is the only reason W2's per-type sizing
8624 /// is expressible here at all: `Sub<{ M::BOUND }>` is
8625 /// `error: generic parameters may not be used in const operations` on stable
8626 /// (rustc 1.97.1), so the sibling `register_subscription_with_info_sized_inner`
8627 /// / `..._with_safety_sized_inner`, whose entries really do hold
8628 /// `[u8; RX_BUF]`, still need `nros::rx_buffer_for!` at the call site.
8629 pub(crate) fn register_subscription_buffered_on<M, F, const RX_BUF: usize>(
8630 &mut self,
8631 node_id: super::node_record::NodeId,
8632 topic_name: &str,
8633 qos: QoSProfile,
8634 callback: F,
8635 group: Option<&str>,
8636 rx_bytes: Option<usize>,
8637 ) -> Result<HandleId, NodeError>
8638 where
8639 M: crate::rmw_type_registry::MessageForRmw + 'static,
8640 F: FnMut(&M) + 'static,
8641 {
8642 type Entry<M, F> = SubBufferedEntry<M, F>;
8643
8644 // RFC-0088 / phase-421 W1 — the message's declared format must be the
8645 // one the linked backend speaks. Universal: the const lives on
8646 // `RosMessage`, which `MessageForRmw` requires under every backend.
8647 crate::format_check::assert_message_format::<M>();
8648 // Phase 212.K.7.6.b — see `create_publisher_on`.
8649 crate::rmw_type_registry::register_type::<M>()?;
8650
8651 // W3b.5 — contracted-endpoint age hook (None = free).
8652 let age_mon = self.age_lookup::<M>(topic_name);
8653
8654 // Phase 403 W2 -- one number for the whole allocation. The region size,
8655 // the strategy's slot size and the pointer arithmetic must agree, so
8656 // they read the same local rather than each spelling `RX_BUF`.
8657 //
8658 // phase-392 W3c -- and `None` no longer means `RX_BUF`, it means DERIVE.
8659 // This is the one choke point every default registration funnels
8660 // through, so putting the derivation here is what makes it the default
8661 // at all four of them (`create_subscription`, `create_subscription_in_group`,
8662 // the builder's `build()`, and the `/clock` time source) instead of at
8663 // whichever one a wave happened to touch. An explicit `Some(n)` -- what
8664 // `.rx_buffer::<N>()` and `.rx_buffer_from_type()` pass -- is still
8665 // verbatim, so the opt-out and the opt-in both bypass this line.
8666 let slot_bytes = crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF);
8667
8668 let open = self.open_subscription(&SubscriptionRequest {
8669 node_id: Some(node_id),
8670 topic_name,
8671 type_name: <M as RosMessage>::TYPE_NAME,
8672 type_hash: <M as RosMessage>::TYPE_HASH,
8673 qos,
8674 // Phase 392 W3a — the TYPE's bound, not `RX_BUF`. The arena slot
8675 // size says nothing about the message: a 64-byte type and a 4 KiB
8676 // type both hinted the same number, so the class was chosen from a
8677 // value unrelated to what arrives. Falls back to `RX_BUF` for an
8678 // unbounded type, where no bound exists to state (phase 380).
8679 backend_hint: Some(crate::rmw_type_registry::subscription_rx_hint::<M>(RX_BUF)),
8680 slot_bytes,
8681 // A typed Rust callback takes `&M` and nothing else, so borrowed
8682 // bytes are enough: deserialize straight from the backend's slot.
8683 in_place_capable: true,
8684 })?;
8685 let SubscriptionOpen {
8686 slot,
8687 handle,
8688 qos,
8689 in_place,
8690 slot_bytes,
8691 } = open;
8692
8693 // Phase 231 Wave 0.2 (RFC-0038) — in-place dispatch when the backend
8694 // advertises it: deserialize straight from the borrowed receive slot,
8695 // no arena buffer (copy #1 removed). Else the buffered path below.
8696 if in_place {
8697 let entry_offset = self.arena_alloc::<SubInplaceEntry<M, F>>()?;
8698 unsafe {
8699 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8700 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubInplaceEntry<M, F>;
8701 core::ptr::write(
8702 entry_ptr,
8703 SubInplaceEntry {
8704 handle,
8705 callback,
8706 age_mon,
8707 _phantom: PhantomData,
8708 },
8709 );
8710 }
8711 let meta = CallbackMeta {
8712 offset: entry_offset,
8713 kind: EntryKind::Subscription,
8714 arena_len: super::arena::ArenaLen::UNRECORDED,
8715 try_process: sub_inplace_try_process::<M, F>,
8716 has_data: sub_inplace_has_data::<M, F>,
8717 pre_sample: no_pre_sample,
8718 invocation: InvocationMode::OnNewData,
8719 drop_fn: drop_entry::<SubInplaceEntry<M, F>>,
8720 };
8721 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8722 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
8723 // SAFETY: a `SubInplaceEntry<M, F>` was written at `entry_offset` just above.
8724 unsafe {
8725 self.arm_entry_wake::<SubInplaceEntry<M, F>>(slot, entry_offset, |e, w| {
8726 nros_rmw::Subscription::register_waker(&e.handle, w)
8727 })
8728 };
8729 self.apply_node_default_sched(slot, Some(node_id), group);
8730 return Ok(self.handle_for(slot));
8731 }
8732
8733 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
8734
8735 let (entry_offset, trailing_offset) =
8736 self.arena_alloc_with_trailing::<Entry<M, F>>(trailing_bytes)?;
8737
8738 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8739
8740 let buffer = if qos.depth <= 1 {
8741 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
8742 } else {
8743 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
8744 };
8745
8746 unsafe {
8747 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8748 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<M, F>;
8749 core::ptr::write(
8750 entry_ptr,
8751 Entry {
8752 handle,
8753 buffer,
8754 callback,
8755 age_mon,
8756 _phantom: PhantomData,
8757 },
8758 );
8759 }
8760
8761 let meta = CallbackMeta {
8762 offset: entry_offset,
8763 kind: EntryKind::Subscription,
8764 arena_len: super::arena::ArenaLen::UNRECORDED,
8765 try_process: sub_buffered_try_process::<M, F>,
8766 has_data: sub_buffered_has_data::<M, F>,
8767 pre_sample: no_pre_sample,
8768 invocation: InvocationMode::OnNewData,
8769 drop_fn: drop_entry::<Entry<M, F>>,
8770 };
8771 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
8772 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
8773 // SAFETY: a `Entry<M, F>` was written at `entry_offset` just above.
8774 unsafe {
8775 self.arm_entry_wake::<Entry<M, F>>(slot, entry_offset, |e, w| {
8776 nros_rmw::Subscription::register_waker(&e.handle, w)
8777 })
8778 };
8779 // Phase 104.C.4 — apply Node's default SchedContext.
8780 self.apply_node_default_sched(slot, Some(node_id), group);
8781 Ok(self.handle_for(slot))
8782 }
8783
8784 /// Generic (type-erased) buffered subscription core (the
8785 /// `node_mut(id).subscription(t).generic(ty, hash)` builder lowers here).
8786 /// Routes the subscriber creation through the [`NodeId`]'s
8787 /// session + identity (rclcpp `add_node` pattern).
8788 ///
8789 /// Use this in bridge code where two Nodes bind to different RMW
8790 /// backends:
8791 ///
8792 /// ```ignore
8793 /// let node_in = exec.node_builder("ingress").rmw("zenoh").build()?;
8794 /// let pub_out = exec.with_node(node_out, |n| {
8795 /// n.create_publisher_raw("/fwd", TYPE, HASH)
8796 /// })??;
8797 /// exec.register_subscription_buffered_raw_on::<_, 1024>(
8798 /// node_in, "/src", TYPE, HASH, qos(), None,
8799 /// move |bytes: &[u8]| { let _ = pub_out.publish_serialized(bytes); },
8800 /// )?;
8801 /// ```
8802 #[allow(clippy::too_many_arguments)]
8803 pub(crate) fn register_subscription_buffered_raw_on<F, const RX_BUF: usize>(
8804 &mut self,
8805 node_id: super::node_record::NodeId,
8806 topic_name: &str,
8807 type_name: &str,
8808 type_hash: &str,
8809 qos: QoSProfile,
8810 backend_hint: Option<usize>,
8811 callback: F,
8812 ) -> Result<HandleId, NodeError>
8813 where
8814 F: FnMut(&[u8]) + 'static,
8815 {
8816 // Issue #52 — fold the node's baked overrides for this topic before the
8817 // backend-compat check runs inside `create_subscription`.
8818 //
8819 // **This is the one registration that does it**, which phase-456 W8
8820 // noticed while collapsing the twelve prologues into one and did NOT
8821 // change: the other eleven reach `create_subscription` with the QoS
8822 // their caller passed. Whether that is a defect or a deliberate limit
8823 // of the generic bridge path is a question with a measurable answer and
8824 // no answer here, so the asymmetry stays where it was, stated.
8825 let overrides = self
8826 .nodes
8827 .get(node_id.index())
8828 .ok_or(NodeError::InvalidSchedContextBinding)?
8829 .qos_overrides;
8830 let qos = super::node_record::apply_qos_override_codes(
8831 qos,
8832 topic_name,
8833 nros_rmw::QoSOverrideRole::Subscription,
8834 overrides,
8835 );
8836
8837 let SubscriptionOpen {
8838 slot,
8839 handle,
8840 qos,
8841 in_place,
8842 slot_bytes,
8843 } = self.open_subscription(&SubscriptionRequest {
8844 node_id: Some(node_id),
8845 topic_name,
8846 type_name,
8847 type_hash,
8848 qos,
8849 // Issue 2208 -- the caller's statement of the type's bound. A
8850 // type-erased caller that knows the type (the declarative
8851 // runtime, from its entity metadata) states it; one that does not
8852 // passes `None`, "stated nothing".
8853 backend_hint,
8854 slot_bytes: RX_BUF,
8855 // Issue 1340 — `FnMut(&[u8])` IS a borrowed-bytes callback, which is
8856 // exactly what `process_raw_in_place` hands over, so this shape takes
8857 // the in-place row the way the C raw path does.
8858 //
8859 // This used to be `false` because the buffered entry is shared with
8860 // `Self::add_arena_subscription_callback` and "an in-place twin would
8861 // have to serve both". It does not: that caller supplies its own
8862 // subscriber and never reaches `open_subscription`, so no in-place
8863 // report can arrive there. The branch below is local to this entry
8864 // point, and the shared buffered emplace is untouched.
8865 //
8866 // phase-457 W5 (issue 1522) — spelled as the SHAPE rather than as a
8867 // literal, because the declarative registrar lowers here and the
8868 // metadata probe must state the same bool without registering. One
8869 // definition (`DeclaredSubscriptionShape::in_place_capable`), two
8870 // readers, so the row the build prices and the claim made here
8871 // moved together when it flipped.
8872 in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRaw
8873 .in_place_capable(),
8874 })?;
8875
8876 if in_place {
8877 // No receive region: the backend's slot is the buffer for the
8878 // duration of the callback. This is the whole of issue 1340's saving
8879 // on the generic path — `buffered_region_size(depth, RX_BUF)` is
8880 // never reserved.
8881 type Entry<F> = super::arena::SubInplaceRawEntry<F>;
8882 let entry_offset = self.arena_alloc::<Entry<F>>()?;
8883 unsafe {
8884 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8885 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
8886 core::ptr::write(entry_ptr, Entry { handle, callback });
8887 }
8888 let meta = CallbackMeta {
8889 offset: entry_offset,
8890 kind: EntryKind::Subscription,
8891 arena_len: super::arena::ArenaLen::UNRECORDED,
8892 try_process: super::arena::sub_inplace_raw_try_process::<F>,
8893 has_data: super::arena::sub_inplace_raw_has_data::<F>,
8894 pre_sample: no_pre_sample,
8895 invocation: InvocationMode::OnNewData,
8896 drop_fn: drop_entry::<Entry<F>>,
8897 };
8898 // Same trace name as the buffered arm, so a trace reads the same
8899 // whichever dispatch the backend chose.
8900 self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
8901 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
8902 // SAFETY: a `Entry<F>` was written at `entry_offset` just above.
8903 unsafe {
8904 self.arm_entry_wake::<Entry<F>>(slot, entry_offset, |e, w| {
8905 nros_rmw::Subscription::register_waker(&e.handle, w)
8906 })
8907 };
8908 } else {
8909 self.emplace_raw_buffered_subscription(slot, handle, qos, slot_bytes, callback)?;
8910 }
8911 // Phase 104.C.4 — apply Node's default SchedContext.
8912 self.apply_node_default_sched(slot, Some(node_id), None);
8913 Ok(self.handle_for(slot))
8914 }
8915
8916 /// Register a borrowed (zero-copy) buffered subscription (Phase 229.6,
8917 /// issue 0007 / RFC-0033 `borrowed` mode).
8918 ///
8919 /// `B` is the code-generated borrowed-message marker (e.g. `ImageViewable`)
8920 /// implementing [`ViewableMessage`](nros_core::ViewableMessage); the
8921 /// callback receives `&B::View<'a>` — a lifetime-carrying message whose
8922 /// unbounded sequence/string fields borrow directly from the receive buffer
8923 /// (no `heapless::Vec` copy). The view is valid only for the callback's
8924 /// duration.
8925 ///
8926 /// **Triple-buffer only.** A borrowed view must reference exactly one
8927 /// well-defined buffer slot for the callback's duration; an SPSC ring
8928 /// (`qos.depth > 1`) keeps several samples in flight with no single such
8929 /// slot. `qos.depth > 1` is therefore rejected with
8930 /// [`TransportError::Unsupported`].
8931 pub(crate) fn register_subscription_buffered_borrowed_on<B, F, const RX_BUF: usize>(
8932 &mut self,
8933 node_id: super::node_record::NodeId,
8934 topic_name: &str,
8935 qos: QoSProfile,
8936 callback: F,
8937 ) -> Result<HandleId, NodeError>
8938 where
8939 B: nros_core::ViewableMessage + 'static,
8940 F: for<'a> FnMut(&B::View<'a>) + 'static,
8941 {
8942 type Entry<B, F> = SubBufferedViewEntry<B, F>;
8943
8944 // Borrowed views require a single well-defined slot (triple buffer).
8945 if qos.depth > 1 {
8946 return Err(NodeError::Transport(TransportError::Unsupported));
8947 }
8948
8949 let SubscriptionOpen { slot, handle, .. } =
8950 self.open_subscription(&SubscriptionRequest {
8951 node_id: Some(node_id),
8952 topic_name,
8953 type_name: <B as ViewableMessage>::TYPE_NAME,
8954 type_hash: <B as ViewableMessage>::TYPE_HASH,
8955 qos,
8956 backend_hint: None,
8957 slot_bytes: RX_BUF,
8958 // The view borrows the arena's own triple-buffer slot for the
8959 // callback's duration and `ViewableMessage::deserialize_view` is
8960 // what produces it, so there is no `FnMut(&[u8])` here to hand a
8961 // backend slot to. Making this shape in-place is a real option and
8962 // a separate change: it would move the borrow from the arena to the
8963 // backend's ring, which is a lifetime argument this item did not
8964 // make.
8965 in_place_capable: false,
8966 })?;
8967
8968 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, RX_BUF);
8969 let (entry_offset, trailing_offset) =
8970 self.arena_alloc_with_trailing::<Entry<B, F>>(trailing_bytes)?;
8971 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
8972
8973 // depth <= 1 guaranteed above → always triple buffer.
8974 let buffer = BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, RX_BUF) });
8975
8976 unsafe {
8977 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
8978 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<B, F>;
8979 core::ptr::write(
8980 entry_ptr,
8981 Entry {
8982 handle,
8983 buffer,
8984 callback,
8985 _phantom: PhantomData,
8986 },
8987 );
8988 }
8989
8990 let meta = CallbackMeta {
8991 offset: entry_offset,
8992 kind: EntryKind::Subscription,
8993 arena_len: super::arena::ArenaLen::UNRECORDED,
8994 try_process: sub_buffered_view_try_process::<B, F>,
8995 has_data: sub_buffered_view_has_data::<B, F>,
8996 pre_sample: no_pre_sample,
8997 invocation: InvocationMode::OnNewData,
8998 drop_fn: drop_entry::<Entry<B, F>>,
8999 };
9000 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9001 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9002 // SAFETY: a `Entry<B, F>` was written at `entry_offset` just above.
9003 unsafe {
9004 self.arm_entry_wake::<Entry<B, F>>(slot, entry_offset, |e, w| {
9005 nros_rmw::Subscription::register_waker(&e.handle, w)
9006 })
9007 };
9008 self.apply_node_default_sched(slot, Some(node_id), None);
9009 Ok(self.handle_for(slot))
9010 }
9011
9012 /// Register a raw (type-erased) buffered subscription whose callback
9013 /// also receives a [`RawMessageInfo`](nros_core::RawMessageInfo)
9014 /// carrying the sample's wire **attachment** (Phase 189.M1).
9015 ///
9016 /// Backs the `node.subscription(t).generic(..).message_info().build(cb)`
9017 /// builder — the cross-RMW bridge reads the bridge_origin tag from
9018 /// `info.attachment()` for echo suppression. One sample per
9019 /// `spin_once`; the attachment is staged in a flat per-entry buffer
9020 /// (cap `RAW_INFO_ATT_CAP`).
9021 pub fn register_subscription_buffered_raw_info_on<F, const RX_BUF: usize>(
9022 &mut self,
9023 node_id: super::node_record::NodeId,
9024 topic_name: &str,
9025 type_name: &str,
9026 type_hash: &str,
9027 qos: QoSProfile,
9028 callback: F,
9029 ) -> Result<HandleId, NodeError>
9030 where
9031 F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static,
9032 {
9033 type Entry<F> = SubBufferedRawInfoEntry<F>;
9034
9035 let SubscriptionOpen {
9036 slot,
9037 handle,
9038 slot_bytes,
9039 ..
9040 } = self.open_subscription(&SubscriptionRequest {
9041 node_id: Some(node_id),
9042 topic_name,
9043 type_name,
9044 type_hash,
9045 qos,
9046 backend_hint: None,
9047 slot_bytes: RX_BUF,
9048 // The callback also receives the sample's wire ATTACHMENT, which
9049 // `process_raw_in_place` does not carry: it hands over borrowed
9050 // payload bytes and nothing else. Not a limitation of the backend.
9051 in_place_capable: false,
9052 })?;
9053
9054 // Issue 1860 -- in the trailing region, as the C twin. A raw subscription
9055 // has no type to derive from, so the size is still `RX_BUF`.
9056 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
9057 unsafe {
9058 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9059 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9060 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
9061 core::ptr::write(
9062 entry_ptr,
9063 Entry {
9064 handle,
9065 buffer,
9066 att: [0u8; super::arena::RAW_INFO_ATT_CAP],
9067 callback,
9068 },
9069 );
9070 }
9071
9072 let meta = CallbackMeta {
9073 offset,
9074 kind: EntryKind::Subscription,
9075 arena_len: super::arena::ArenaLen::UNRECORDED,
9076 try_process: sub_buffered_raw_info_try_process::<F>,
9077 has_data: sub_buffered_raw_info_has_data::<F>,
9078 pre_sample: no_pre_sample,
9079 invocation: InvocationMode::OnNewData,
9080 drop_fn: drop_entry::<Entry<F>>,
9081 };
9082 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9083 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9084 // SAFETY: a `Entry<F>` was written at `offset` just above.
9085 unsafe {
9086 self.arm_entry_wake::<Entry<F>>(slot, offset, |e, w| {
9087 nros_rmw::Subscription::register_waker(&e.handle, w)
9088 })
9089 };
9090 self.apply_node_default_sched(slot, Some(node_id), None);
9091 Ok(self.handle_for(slot))
9092 }
9093
9094 /// Phase 250 (Wave 2) — register a generic (type-erased) raw subscription
9095 /// that surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC +
9096 /// sequence gap/dup) alongside the raw CDR bytes
9097 /// (`FnMut(&[u8], &IntegrityStatus)`). The type-erased analog of
9098 /// `register_subscription_with_safety_sized_inner`: the validator lives in
9099 /// the `RmwSubscriber` (`take_validated`), so the subscriber is created
9100 /// plainly and no `register_type::<M>()` is needed (the declarative `Node`
9101 /// path is generic). Used by the declarative runtime's `.safety()` opt-in.
9102 #[cfg(feature = "safety-e2e")]
9103 #[allow(clippy::too_many_arguments)]
9104 pub fn register_subscription_buffered_raw_safety_on<F, const RX_BUF: usize>(
9105 &mut self,
9106 node_id: super::node_record::NodeId,
9107 topic_name: &str,
9108 type_name: &str,
9109 type_hash: &str,
9110 qos: QoSProfile,
9111 backend_hint: Option<usize>,
9112 callback: F,
9113 ) -> Result<HandleId, NodeError>
9114 where
9115 F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
9116 {
9117 use super::arena::{
9118 SubBufferedRawSafetyEntry, sub_buffered_raw_safety_has_data,
9119 sub_buffered_raw_safety_try_process,
9120 };
9121 type Entry<F> = SubBufferedRawSafetyEntry<F>;
9122
9123 let SubscriptionOpen {
9124 slot,
9125 handle,
9126 slot_bytes,
9127 ..
9128 } = self.open_subscription(&SubscriptionRequest {
9129 node_id: Some(node_id),
9130 topic_name,
9131 type_name,
9132 type_hash,
9133 qos,
9134 backend_hint, // Issue 2208 -- as the basic raw path.
9135 slot_bytes: RX_BUF,
9136 // The sample is taken through `take_validated`, which computes the
9137 // integrity status as it copies. `process_raw_in_place` has no
9138 // validating form, so this shape cannot use it.
9139 //
9140 // phase-457 W5 (issue 1522) — as the SHAPE, for the reason its
9141 // sibling in `register_subscription_buffered_raw_on` gives.
9142 in_place_capable: super::declared_shape::DeclaredSubscriptionShape::BufferedRawSafety
9143 .in_place_capable(),
9144 })?;
9145
9146 // Issue 1860 -- in the trailing region; untyped, so still `RX_BUF` bytes.
9147 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<F>>(slot_bytes)?;
9148 unsafe {
9149 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9150 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9151 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
9152 core::ptr::write(
9153 entry_ptr,
9154 Entry {
9155 handle,
9156 buffer,
9157 callback,
9158 },
9159 );
9160 }
9161
9162 let meta = CallbackMeta {
9163 offset,
9164 kind: EntryKind::Subscription,
9165 arena_len: super::arena::ArenaLen::UNRECORDED,
9166 try_process: sub_buffered_raw_safety_try_process::<F>,
9167 has_data: sub_buffered_raw_safety_has_data::<F>,
9168 pre_sample: no_pre_sample,
9169 invocation: InvocationMode::OnNewData,
9170 drop_fn: drop_entry::<Entry<F>>,
9171 };
9172 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9173 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9174 // SAFETY: a `Entry<F>` was written at `offset` just above.
9175 unsafe {
9176 self.arm_entry_wake::<Entry<F>>(slot, offset, |e, w| {
9177 nros_rmw::Subscription::register_waker(&e.handle, w)
9178 })
9179 };
9180 self.apply_node_default_sched(slot, Some(node_id), None);
9181 Ok(self.handle_for(slot))
9182 }
9183
9184 /// Register a raw byte-shaped callback against a pre-built
9185 /// `RmwSubscriber` handle.
9186 ///
9187 /// Backend-agnostic primitive — the caller is responsible for
9188 /// obtaining the handle by whatever route the active backend
9189 /// supports:
9190 ///
9191 /// - **Generic ROS-typed flow**: call `Session::create_subscription`
9192 /// on `self.session_mut()` with a [`TopicInfo`]. The
9193 /// `node_mut(id).subscription(t).generic(ty, hash)` builder is the
9194 /// convenience wrapper for this path.
9195 /// - **Backend-specific flow** (e.g. uORB needs `&'static orb_metadata`):
9196 /// reach into the concrete session via [`Self::session_mut`] and
9197 /// call its backend-specific create method, then hand the handle
9198 /// here. `nros-px4::uorb::create_subscription_with_callback` is
9199 /// the example.
9200 ///
9201 /// The arena-store + vtable wiring is identical to
9202 /// `register_subscription_buffered_raw_on`; the only thing that varies is
9203 /// where the handle came from. Callback fires on every message
9204 /// delivery during [`spin_once`](Self::spin_once); bytes are
9205 /// passed as `&[u8]`.
9206 pub fn add_arena_subscription_callback<F, const RX_BUF: usize>(
9207 &mut self,
9208 handle: session::RmwSubscriber,
9209 qos: QoSProfile,
9210 callback: F,
9211 ) -> Result<HandleId, NodeError>
9212 where
9213 F: FnMut(&[u8]) + 'static,
9214 {
9215 let slot = self.next_entry_slot()?;
9216 self.emplace_raw_buffered_subscription(slot, handle, qos, RX_BUF, callback)?;
9217 Ok(self.handle_for(slot))
9218 }
9219
9220 /// Write a raw buffered subscription entry into an already-claimed slot —
9221 /// phase-456 W8.
9222 ///
9223 /// The shared tail of [`Self::add_arena_subscription_callback`] and
9224 /// `register_subscription_buffered_raw_on`, which differ only in where the
9225 /// subscriber came from: one is handed a live `RmwSubscriber`, the other
9226 /// resolves a topic through [`Self::open_subscription`] and gets one back.
9227 /// Both then build exactly this entry, and they used to build it twice —
9228 /// the bridge path by CALLING the other, which meant it claimed a slot,
9229 /// resolved a topic, and then let the callee claim a second slot it did not
9230 /// need.
9231 fn emplace_raw_buffered_subscription<F>(
9232 &mut self,
9233 slot: usize,
9234 handle: session::RmwSubscriber,
9235 qos: QoSProfile,
9236 slot_bytes: usize,
9237 callback: F,
9238 ) -> Result<(), NodeError>
9239 where
9240 F: FnMut(&[u8]) + 'static,
9241 {
9242 type Entry<F> = SubBufferedRawEntry<F>;
9243
9244 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
9245
9246 let (entry_offset, trailing_offset) =
9247 self.arena_alloc_with_trailing::<Entry<F>>(trailing_bytes)?;
9248
9249 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
9250
9251 let buffer = if qos.depth <= 1 {
9252 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
9253 } else {
9254 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
9255 };
9256
9257 unsafe {
9258 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9259 let entry_ptr = arena_ptr.add(entry_offset) as *mut Entry<F>;
9260 core::ptr::write(
9261 entry_ptr,
9262 Entry {
9263 handle,
9264 buffer,
9265 callback,
9266 },
9267 );
9268 }
9269
9270 let meta = CallbackMeta {
9271 offset: entry_offset,
9272 kind: EntryKind::Subscription,
9273 arena_len: super::arena::ArenaLen::UNRECORDED,
9274 try_process: sub_buffered_raw_try_process::<F>,
9275 has_data: sub_buffered_raw_has_data::<F>,
9276 pre_sample: no_pre_sample,
9277 invocation: InvocationMode::OnNewData,
9278 drop_fn: drop_entry::<Entry<F>>,
9279 };
9280 self.emplace_entry(slot, meta, TraceName::Slot("sub", slot));
9281 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9282 // SAFETY: a `Entry<F>` was written at `entry_offset` just above.
9283 unsafe {
9284 self.arm_entry_wake::<Entry<F>>(slot, entry_offset, |e, w| {
9285 nros_rmw::Subscription::register_waker(&e.handle, w)
9286 })
9287 };
9288 Ok(())
9289 }
9290
9291 pub(crate) fn register_subscription_with_info_sized_inner<M, F, const RX_BUF: usize>(
9292 &mut self,
9293 node_id: Option<super::node_record::NodeId>,
9294 topic_name: &str,
9295 qos: QoSProfile,
9296 callback: F,
9297 // Issue 1860 -- `Some(n)` is a size the caller NAMED (`.rx_buffer::<N>()`),
9298 // used verbatim; `None` derives it from `M` exactly as the buffered path
9299 // does, falling back to `RX_BUF` only where `M` states no bound.
9300 rx_bytes: Option<usize>,
9301 ) -> Result<HandleId, NodeError>
9302 where
9303 M: crate::rmw_type_registry::MessageForRmw + 'static,
9304 F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static,
9305 {
9306 type Entry<M, F> = SubInfoEntry<M, F>;
9307
9308 // Phase 212.K.7.6.b — see `create_publisher_on`.
9309 crate::rmw_type_registry::register_type::<M>()?;
9310
9311 let SubscriptionOpen {
9312 slot,
9313 handle,
9314 slot_bytes,
9315 ..
9316 } = self.open_subscription(&SubscriptionRequest {
9317 node_id,
9318 topic_name,
9319 type_name: <M as RosMessage>::TYPE_NAME,
9320 type_hash: <M as RosMessage>::TYPE_HASH,
9321 qos,
9322 backend_hint: None,
9323 slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
9324 // The entry's `pre_sample` hook stages a `MessageInfo` beside the
9325 // payload before the callback runs; an in-place drain has no
9326 // pre-sample step to hang it on.
9327 in_place_capable: false,
9328 })?;
9329
9330 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
9331
9332 unsafe {
9333 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9334 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9335 let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
9336 core::ptr::write(
9337 entry_ptr,
9338 Entry {
9339 handle,
9340 buffer,
9341 sampled_len: 0,
9342 callback,
9343 _phantom: PhantomData,
9344 },
9345 );
9346 }
9347
9348 let meta = CallbackMeta {
9349 offset,
9350 kind: EntryKind::Subscription,
9351 arena_len: super::arena::ArenaLen::UNRECORDED,
9352 try_process: sub_info_try_process::<M, F>,
9353 has_data: sub_info_has_data::<M, F>,
9354 pre_sample: sub_info_pre_sample::<M, F>,
9355 invocation: InvocationMode::OnNewData,
9356 drop_fn: drop_entry::<Entry<M, F>>,
9357 };
9358 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9359 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9360 // SAFETY: a `Entry<M, F>` was written at `offset` just above.
9361 unsafe {
9362 self.arm_entry_wake::<Entry<M, F>>(slot, offset, |e, w| {
9363 nros_rmw::Subscription::register_waker(&e.handle, w)
9364 })
9365 };
9366 self.apply_node_default_sched(slot, node_id, None);
9367 Ok(self.handle_for(slot))
9368 }
9369
9370 #[cfg(feature = "safety-e2e")]
9371 pub(crate) fn register_subscription_with_safety_sized_inner<M, F, const RX_BUF: usize>(
9372 &mut self,
9373 node_id: Option<super::node_record::NodeId>,
9374 topic_name: &str,
9375 qos: QoSProfile,
9376 callback: F,
9377 // Issue 1860 -- as `register_subscription_with_info_sized_inner`.
9378 rx_bytes: Option<usize>,
9379 ) -> Result<HandleId, NodeError>
9380 where
9381 M: crate::rmw_type_registry::MessageForRmw + 'static,
9382 F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static,
9383 {
9384 type Entry<M, F> = SubSafetyEntry<M, F>;
9385
9386 // Phase 212.K.7.6.b — see `create_publisher_on`.
9387 crate::rmw_type_registry::register_type::<M>()?;
9388
9389 let SubscriptionOpen {
9390 slot,
9391 handle,
9392 slot_bytes,
9393 ..
9394 } = self.open_subscription(&SubscriptionRequest {
9395 node_id,
9396 topic_name,
9397 type_name: <M as RosMessage>::TYPE_NAME,
9398 type_hash: <M as RosMessage>::TYPE_HASH,
9399 qos,
9400 backend_hint: None,
9401 slot_bytes: crate::rmw_type_registry::subscription_slot_bytes::<M>(rx_bytes, RX_BUF),
9402 // Same as the raw safety sibling: the status comes out of
9403 // `take_validated`, which is a copying take.
9404 in_place_capable: false,
9405 })?;
9406
9407 let (offset, trailing) = self.arena_alloc_with_trailing::<Entry<M, F>>(slot_bytes)?;
9408
9409 unsafe {
9410 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9411 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing), slot_bytes);
9412 let entry_ptr = arena_ptr.add(offset) as *mut Entry<M, F>;
9413 core::ptr::write(
9414 entry_ptr,
9415 Entry {
9416 handle,
9417 buffer,
9418 sampled_len: 0,
9419 callback,
9420 _phantom: PhantomData,
9421 },
9422 );
9423 }
9424
9425 let meta = CallbackMeta {
9426 offset,
9427 kind: EntryKind::Subscription,
9428 arena_len: super::arena::ArenaLen::UNRECORDED,
9429 try_process: sub_safety_try_process::<M, F>,
9430 has_data: sub_safety_has_data::<M, F>,
9431 pre_sample: sub_safety_pre_sample::<M, F>,
9432 invocation: InvocationMode::OnNewData,
9433 drop_fn: drop_entry::<Entry<M, F>>,
9434 };
9435 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
9436 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9437 // SAFETY: a `Entry<M, F>` was written at `offset` just above.
9438 unsafe {
9439 self.arm_entry_wake::<Entry<M, F>>(slot, offset, |e, w| {
9440 nros_rmw::Subscription::register_waker(&e.handle, w)
9441 })
9442 };
9443 self.apply_node_default_sched(slot, node_id, None);
9444 Ok(self.handle_for(slot))
9445 }
9446
9447 /// Register a service callback with the default buffer size.
9448 ///
9449 /// The callback is stored in the arena and invoked during [`spin_once()`](Self::spin_once).
9450 pub fn register_service<Svc, F>(
9451 &mut self,
9452 service_name: &str,
9453 callback: F,
9454 ) -> Result<HandleId, NodeError>
9455 where
9456 Svc: RosService + 'static,
9457 Svc::Request: crate::rmw_type_registry::MessageForRmw,
9458 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9459 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9460 {
9461 self.register_service_sized::<Svc, F, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, callback)
9462 }
9463
9464 /// Register a service callback with custom request/reply buffer sizes.
9465 pub fn register_service_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
9466 &mut self,
9467 service_name: &str,
9468 callback: F,
9469 ) -> Result<HandleId, NodeError>
9470 where
9471 Svc: RosService + 'static,
9472 Svc::Request: crate::rmw_type_registry::MessageForRmw,
9473 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9474 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9475 {
9476 type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
9477
9478 // Phase 212.K.7.7.b — register both halves of the service round-trip
9479 // under cyclonedds. No-op for other RMWs. Mirrors the K.7.6.b hook
9480 // on `Node::create_service_sized`.
9481 crate::rmw_type_registry::register_type::<Svc::Request>()?;
9482 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
9483
9484 let slot = self.next_entry_slot()?;
9485 let node_name: heapless::String<64> = self.node_name.clone();
9486 let ns: heapless::String<64> = self.namespace.clone();
9487 let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
9488 .with_domain(self.domain_id)
9489 .with_namespace(&ns);
9490 if !node_name.is_empty() {
9491 info = info.with_node_name(&node_name);
9492 }
9493 let handle = self
9494 .session
9495 .create_service(&info, QoSProfile::services_default())
9496 .map_err(NodeError::Transport)?;
9497
9498 let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
9499
9500 // SAFETY: same guarantees as register_subscription_sized.
9501 unsafe {
9502 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9503 let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
9504 core::ptr::write(
9505 entry_ptr,
9506 Entry {
9507 handle,
9508 req_buffer: [0u8; REQ_BUF],
9509 reply_buffer: [0u8; REPLY_BUF],
9510 callback,
9511 _phantom: PhantomData,
9512 },
9513 );
9514 }
9515
9516 let meta = CallbackMeta {
9517 offset,
9518 kind: EntryKind::Service,
9519 arena_len: super::arena::ArenaLen::UNRECORDED,
9520 try_process: srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
9521 has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
9522 pre_sample: no_pre_sample,
9523 invocation: InvocationMode::OnNewData,
9524 drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
9525 };
9526 self.emplace_entry(slot, meta, TraceName::Text(service_name));
9527 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9528 // SAFETY: a `Entry<Svc, F, REQ_BUF, REPLY_BUF>` was written at `offset` just above.
9529 unsafe {
9530 self.arm_entry_wake::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>(slot, offset, |e, w| {
9531 nros_rmw::ServiceTrait::register_waker(&e.handle, w)
9532 })
9533 };
9534 Ok(self.handle_for(slot))
9535 }
9536
9537 /// Phase 104.C.3.3.a — Node-aware variant of
9538 /// [`register_service_sized`](Self::register_service_sized).
9539 pub fn register_service_sized_on<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
9540 &mut self,
9541 node_id: super::node_record::NodeId,
9542 service_name: &str,
9543 qos: QoSProfile,
9544 callback: F,
9545 ) -> Result<HandleId, NodeError>
9546 where
9547 Svc: RosService + 'static,
9548 Svc::Request: crate::rmw_type_registry::MessageForRmw,
9549 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9550 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9551 {
9552 self.register_service_sized_on_inner::<Svc, F, REQ_BUF, REPLY_BUF>(
9553 node_id,
9554 service_name,
9555 qos,
9556 callback,
9557 srv_try_process::<Svc, F, REQ_BUF, REPLY_BUF>,
9558 )
9559 }
9560
9561 /// Issue 1942 — [`register_service_sized_on`](Self::register_service_sized_on)
9562 /// for a callback that also receives the request's
9563 /// [`ServiceInfo`](nros_core::ServiceInfo) — rclrs's `(request, ServiceInfo)`
9564 /// service callback. A field the backend cannot report arrives as `None`.
9565 pub fn register_service_with_info_sized_on<
9566 Svc,
9567 F,
9568 const REQ_BUF: usize,
9569 const REPLY_BUF: usize,
9570 >(
9571 &mut self,
9572 node_id: super::node_record::NodeId,
9573 service_name: &str,
9574 qos: QoSProfile,
9575 callback: F,
9576 ) -> Result<HandleId, NodeError>
9577 where
9578 Svc: RosService + 'static,
9579 Svc::Request: crate::rmw_type_registry::MessageForRmw,
9580 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9581 F: FnMut(&Svc::Request, &nros_core::ServiceInfo) -> Svc::Reply + 'static,
9582 {
9583 self.register_service_sized_on_inner::<Svc, F, REQ_BUF, REPLY_BUF>(
9584 node_id,
9585 service_name,
9586 qos,
9587 callback,
9588 srv_try_process_with_info::<Svc, F, REQ_BUF, REPLY_BUF>,
9589 )
9590 }
9591
9592 /// The one body behind both callback shapes: they share [`SrvEntry`] and
9593 /// differ only in the dispatch function.
9594 fn register_service_sized_on_inner<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
9595 &mut self,
9596 node_id: super::node_record::NodeId,
9597 service_name: &str,
9598 qos: QoSProfile,
9599 callback: F,
9600 try_process: unsafe fn(*mut u8, u64, u8) -> Result<bool, TransportError>,
9601 ) -> Result<HandleId, NodeError>
9602 where
9603 Svc: RosService + 'static,
9604 Svc::Request: crate::rmw_type_registry::MessageForRmw,
9605 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9606 F: 'static,
9607 {
9608 type Entry<Svc, F, const RQ: usize, const RP: usize> = SrvEntry<Svc, F, RQ, RP>;
9609
9610 // Phase 212.K.7.7.b — see `register_service_sized`.
9611 crate::rmw_type_registry::register_type::<Svc::Request>()?;
9612 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
9613
9614 let slot = self.next_entry_slot()?;
9615 let (node_name, ns, session_idx) = {
9616 let r = self
9617 .nodes
9618 .get(node_id.index())
9619 .ok_or(NodeError::InvalidSchedContextBinding)?;
9620 (r.name.clone(), r.namespace.clone(), r.session_idx)
9621 };
9622 let mut info = ServiceInfo::new(service_name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
9623 .with_domain(self.domain_id)
9624 .with_namespace(&ns);
9625 if !node_name.is_empty() {
9626 info = info.with_node_name(&node_name);
9627 }
9628 let handle = {
9629 let session = self
9630 .session_at_mut(session_idx)
9631 .ok_or(NodeError::BackendMismatch)?;
9632 // Phase 193.5 — validate against the backend's supported policies
9633 // (no silent downgrade); request/reply effectively requires RELIABLE.
9634 super::node::validate_qos_or_report(
9635 &qos,
9636 session.supported_qos_policies(),
9637 "service",
9638 service_name,
9639 )?;
9640 session
9641 .create_service(&info, qos)
9642 .map_err(NodeError::Transport)?
9643 };
9644
9645 let offset = self.arena_alloc::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>()?;
9646 unsafe {
9647 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9648 let entry_ptr = arena_ptr.add(offset) as *mut Entry<Svc, F, REQ_BUF, REPLY_BUF>;
9649 core::ptr::write(
9650 entry_ptr,
9651 Entry {
9652 handle,
9653 req_buffer: [0u8; REQ_BUF],
9654 reply_buffer: [0u8; REPLY_BUF],
9655 callback,
9656 _phantom: PhantomData,
9657 },
9658 );
9659 }
9660
9661 let meta = CallbackMeta {
9662 offset,
9663 kind: EntryKind::Service,
9664 arena_len: super::arena::ArenaLen::UNRECORDED,
9665 try_process,
9666 has_data: srv_has_data::<Svc, F, REQ_BUF, REPLY_BUF>,
9667 pre_sample: no_pre_sample,
9668 invocation: InvocationMode::OnNewData,
9669 drop_fn: drop_entry::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>,
9670 };
9671 self.emplace_entry(slot, meta, TraceName::Text(service_name));
9672 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
9673 // SAFETY: a `Entry<Svc, F, REQ_BUF, REPLY_BUF>` was written at `offset` just above.
9674 unsafe {
9675 self.arm_entry_wake::<Entry<Svc, F, REQ_BUF, REPLY_BUF>>(slot, offset, |e, w| {
9676 nros_rmw::ServiceTrait::register_waker(&e.handle, w)
9677 })
9678 };
9679 self.apply_node_default_sched(slot, Some(node_id), None);
9680 Ok(self.handle_for(slot))
9681 }
9682
9683 /// Phase 104.C.3.3.a — Node-aware variant of
9684 /// [`register_service`](Self::register_service).
9685 pub fn register_service_on<Svc, F>(
9686 &mut self,
9687 node_id: super::node_record::NodeId,
9688 service_name: &str,
9689 callback: F,
9690 ) -> Result<HandleId, NodeError>
9691 where
9692 Svc: RosService + 'static,
9693 Svc::Request: crate::rmw_type_registry::MessageForRmw,
9694 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
9695 F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
9696 {
9697 self.register_service_sized_on::<
9698 Svc,
9699 F,
9700 { crate::config::DEFAULT_RX_BUF_SIZE },
9701 { crate::config::DEFAULT_RX_BUF_SIZE },
9702 >(node_id, service_name, QoSProfile::services_default(), callback)
9703 }
9704
9705 // ========================================================================
9706 // Timer registration
9707 // ========================================================================
9708
9709 /// The ONE timer registration in the tree (phase-430 W4).
9710 ///
9711 /// Every timer verb — the four `Executor::register_timer*` entry points
9712 /// below, `Node::create_timer_in_group` / `create_timer_on_clock*`, the
9713 /// declarative `nros::Node` timer, and the C/C++ FFI that lower to them —
9714 /// constructs its arena entry HERE. Until W4 there were four copies of this
9715 /// body differing only in `oneshot`, `clock_source` and whether the sched
9716 /// binding was applied, and phase-425's clock axis reached exactly one of
9717 /// them; that is how a `nros::main!` component could declare a timer but
9718 /// not a ROS-time one.
9719 ///
9720 /// `node_id` / `group` feed `apply_node_default_sched` (phase 273: group
9721 /// table > node default > SC 0). `node_id == None` is the legacy unscoped
9722 /// path and makes that call a no-op.
9723 pub(crate) fn register_timer_entry<F>(
9724 &mut self,
9725 node_id: Option<super::node_record::NodeId>,
9726 period: TimerDuration,
9727 source: TimerClockSource,
9728 oneshot: bool,
9729 group: Option<&str>,
9730 callback: F,
9731 ) -> Result<HandleId, NodeError>
9732 where
9733 F: FnMut() + 'static,
9734 {
9735 let mut callback = callback;
9736 self.register_timer_entry_with(
9737 node_id,
9738 period,
9739 source,
9740 oneshot,
9741 true,
9742 group,
9743 None,
9744 core::ptr::null_mut(),
9745 move |_| move |_: &TimerCallInfo| callback(),
9746 )
9747 }
9748
9749 /// [`register_timer_entry`](Self::register_timer_entry), with a CAPTURE
9750 /// the arena holds — phase-476 W2.
9751 ///
9752 /// The callback is BUILT after the allocation, by `make`, from the context
9753 /// pointer the entry should dispatch with: `place_capture`'s copy of
9754 /// `capture` when there is one, otherwise `context` unchanged. That is what
9755 /// lets the capture ride in the timer entry's own trailing region (so a
9756 /// release frees it) while the closure that reads it is still a plain
9757 /// `TimerEntry<F>`.
9758 ///
9759 /// Issue 1974 — the stored callback takes the dispatcher's
9760 /// [`TimerCallInfo`]; a plain `FnMut()` is adapted by ignoring it, so there
9761 /// is one entry type and one dispatch whichever flavour the caller wrote.
9762 /// `autostart == false` builds the entry CANCELLED (rcl's
9763 /// `rcl_timer_init2(..., autostart = false)`): it never fires until
9764 /// [`reset_timer`](Self::reset_timer) starts it.
9765 #[allow(clippy::too_many_arguments)]
9766 pub(crate) fn register_timer_entry_with<F, M>(
9767 &mut self,
9768 node_id: Option<super::node_record::NodeId>,
9769 period: TimerDuration,
9770 source: TimerClockSource,
9771 oneshot: bool,
9772 autostart: bool,
9773 group: Option<&str>,
9774 capture: Option<&[u8]>,
9775 context: *mut core::ffi::c_void,
9776 make: M,
9777 ) -> Result<HandleId, NodeError>
9778 where
9779 F: FnMut(&TimerCallInfo) + 'static,
9780 M: FnOnce(*mut core::ffi::c_void) -> F,
9781 {
9782 let slot = self.next_entry_slot()?;
9783 let capture_len = Self::capture_trailing_len(capture);
9784 // No capture keeps the exact pre-W2 allocation, so a timer that captures
9785 // nothing costs what it always did.
9786 let (offset, context) = if capture_len == 0 {
9787 (self.arena_alloc::<TimerEntry<F>>()?, context)
9788 } else {
9789 let (offset, at) = self.arena_alloc_with_trailing::<TimerEntry<F>>(capture_len)?;
9790 (offset, self.place_capture(at, capture, context))
9791 };
9792 let callback = make(context);
9793
9794 unsafe {
9795 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
9796 let entry_ptr = arena_ptr.add(offset) as *mut TimerEntry<F>;
9797 core::ptr::write(
9798 entry_ptr,
9799 TimerEntry {
9800 period_us: period.as_micros(),
9801 elapsed_us: 0,
9802 overruns: 0,
9803 overruns_reported: 0,
9804 oneshot,
9805 fired: false,
9806 cancelled: !autostart,
9807 overrun_policy: TimerOverrunPolicy::default(),
9808 clock_source: source,
9809 // Seeded HERE rather than on the first poll: a zero would
9810 // make the first delta the whole epoch, which `Skip` would
9811 // then coalesce into one immediate activation and `CatchUp`
9812 // into a replay burst of ~10^9 periods. `Steady` reads no
9813 // clock at all, so its seed is 0 and is never read.
9814 last_clock_ns: source.now_ns(),
9815 callback,
9816 },
9817 );
9818 }
9819
9820 let meta = CallbackMeta {
9821 offset,
9822 kind: EntryKind::Timer,
9823 arena_len: super::arena::ArenaLen::UNRECORDED,
9824 try_process: timer_try_process::<F>,
9825 has_data: always_ready,
9826 pre_sample: no_pre_sample,
9827 invocation: InvocationMode::Always,
9828 drop_fn: drop_entry::<TimerEntry<F>>,
9829 };
9830 self.emplace_entry(slot, meta, TraceName::TimerPeriod(period.as_micros()));
9831 // Phase 273 — apply group sched binding (group > node default > SC 0).
9832 self.apply_node_default_sched(slot, node_id, group);
9833 Ok(self.handle_for(slot))
9834 }
9835
9836 /// Register a repeating timer callback.
9837 ///
9838 /// The callback fires every `period` milliseconds during [`spin_once()`](Self::spin_once).
9839 /// The timer delta is approximated by the `timeout_ms` argument to `spin_once`.
9840 pub fn register_timer<F>(
9841 &mut self,
9842 period: TimerDuration,
9843 callback: F,
9844 ) -> Result<HandleId, NodeError>
9845 where
9846 F: FnMut() + 'static,
9847 {
9848 self.register_timer_entry(
9849 None,
9850 period,
9851 TimerClockSource::Steady,
9852 false,
9853 None,
9854 callback,
9855 )
9856 }
9857
9858 /// Register a repeating timer driven by a CLOCK rather than by the spin
9859 /// delta — phase-425 W4, the shape rclcpp spells
9860 /// `create_timer(node, clock, period, cb)`.
9861 ///
9862 /// [`register_timer`](Self::register_timer) is the wall timer: it consumes
9863 /// the executor's monotonic spin delta and no simulator can slow it down.
9864 /// This one reads `source` on every poll and advances by the difference, so
9865 /// a [`TimerClockSource::Ros`] timer follows `/clock` — it stops while the
9866 /// simulator is paused, halves with a bag replayed at 0.5x, and restarts
9867 /// its period on a backwards jump instead of stalling for the length of it.
9868 ///
9869 /// With no `/clock` source installed, a `Ros` timer reads system time and
9870 /// behaves like a wall timer with NTP steps, which is the same fallback
9871 /// `rclcpp::Clock` has: a node written for simulation still runs standalone.
9872 ///
9873 /// The node-level spellings of this are `Node::create_timer_on_clock`
9874 /// and the declarative `DeclaredNode::create_timer_on_clock` (phase-430 W4);
9875 /// all of them reach `register_timer_entry`, which is the only place a
9876 /// timer entry is built.
9877 pub fn register_timer_on_clock<F>(
9878 &mut self,
9879 period: TimerDuration,
9880 source: TimerClockSource,
9881 callback: F,
9882 ) -> Result<HandleId, NodeError>
9883 where
9884 F: FnMut() + 'static,
9885 {
9886 self.register_timer_entry(None, period, source, false, None, callback)
9887 }
9888
9889 /// Register a one-shot timer callback.
9890 ///
9891 /// The callback fires once after `delay` milliseconds, then becomes inert.
9892 pub fn register_timer_oneshot<F>(
9893 &mut self,
9894 delay: TimerDuration,
9895 callback: F,
9896 ) -> Result<HandleId, NodeError>
9897 where
9898 F: FnMut() + 'static,
9899 {
9900 self.register_timer_entry(None, delay, TimerClockSource::Steady, true, None, callback)
9901 }
9902
9903 /// phase-476 W2 — register a timer whose callback is a C function pointer
9904 /// and whose CAPTURE the arena holds: the `nros-cpp` road for a C++
9905 /// lambda, the timer twin of
9906 /// [`add_arena_subscription_c_callback_with_capture`](Self::add_arena_subscription_c_callback_with_capture).
9907 ///
9908 /// `capture`'s bytes are copied into the timer entry's trailing region and
9909 /// `callback` is called with a pointer to that copy, so the caller keeps
9910 /// nothing — which is what lets the C++ side hand back a two-word handle
9911 /// instead of a heap cell the node had to keep alive. Releasing the timer
9912 /// releases the capture with it.
9913 ///
9914 /// `source` picks the clock: [`TimerClockSource::Steady`] is the wall timer
9915 /// that advances with the spin delta.
9916 ///
9917 /// `capture_drop`, when given, is called with the same pointer when the
9918 /// entry is dropped (released, or the executor torn down), BEFORE its bytes
9919 /// go back to the arena. A C++ capture is a callable that may own something
9920 /// (a `std::shared_ptr`, a `std::string`), and its bytes were MOVED here by
9921 /// copy, so this is the only place its destructor can run.
9922 pub(crate) fn register_timer_c_capturing(
9923 &mut self,
9924 node_id: Option<super::node_record::NodeId>,
9925 period: TimerDuration,
9926 source: TimerClockSource,
9927 callback: unsafe extern "C" fn(*mut core::ffi::c_void),
9928 capture: Option<&[u8]>,
9929 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
9930 ) -> Result<HandleId, NodeError> {
9931 self.register_timer_entry_with(
9932 node_id,
9933 period,
9934 source,
9935 false,
9936 true,
9937 None,
9938 capture,
9939 core::ptr::null_mut(),
9940 move |ctx| {
9941 let held = super::arena::CapturedCallback {
9942 callback,
9943 guard: super::arena::CaptureGuard {
9944 ctx,
9945 drop: capture_drop,
9946 },
9947 };
9948 move |_: &TimerCallInfo| held.call()
9949 },
9950 )
9951 }
9952
9953 /// [`register_timer_c_capturing`](Self::register_timer_c_capturing) with
9954 /// the dispatcher's [`TimerCallInfo`] handed to the C callback, a callback
9955 /// group, and rcl's `autostart` — issue 1974, the road a C++
9956 /// `TimerInfoCallbackType` lambda and `create_wall_timer(..., autostart)`
9957 /// take.
9958 ///
9959 /// `I` is the FFI's own `#[repr(C)]` mirror of the info struct (the C++
9960 /// header is generated without includes, so it cannot name this crate's
9961 /// type); it is built from the executor's `TimerCallInfo` by `From` on each
9962 /// activation — a copy of two words, never a second computation.
9963 ///
9964 /// `autostart == false` registers the timer cancelled; `reset_timer`
9965 /// starts it. `group` binds it as `register_timer_on` does.
9966 #[allow(clippy::too_many_arguments)]
9967 pub(crate) fn register_timer_c_capturing_with_info<I>(
9968 &mut self,
9969 node_id: Option<super::node_record::NodeId>,
9970 period: TimerDuration,
9971 source: TimerClockSource,
9972 autostart: bool,
9973 group: Option<&str>,
9974 callback: unsafe extern "C" fn(*const I, *mut core::ffi::c_void),
9975 capture: Option<&[u8]>,
9976 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
9977 ) -> Result<HandleId, NodeError>
9978 where
9979 I: From<TimerCallInfo> + 'static,
9980 {
9981 self.register_timer_entry_with(
9982 node_id,
9983 period,
9984 source,
9985 false,
9986 autostart,
9987 group,
9988 capture,
9989 core::ptr::null_mut(),
9990 move |ctx| {
9991 let guard = super::arena::CaptureGuard {
9992 ctx,
9993 drop: capture_drop,
9994 };
9995 move |info: &TimerCallInfo| {
9996 // Borrow the WHOLE guard: a closure naming only `guard.ctx`
9997 // would capture that `Copy` field alone and let the guard
9998 // (whose `Drop` runs the capture's destructor) die here.
9999 let guard = &guard;
10000 let ffi_info = I::from(*info);
10001 // SAFETY: the registrant's contract — `callback` reads its
10002 // capture through the pointer it is given, and the info
10003 // pointer is valid for the duration of the call.
10004 unsafe { callback(&ffi_info, guard.ctx) }
10005 }
10006 },
10007 )
10008 }
10009
10010 /// Register a repeating timer whose callback receives the dispatcher's
10011 /// [`TimerCallInfo`] — issue 1974, the Rust counterpart of Jazzy's
10012 /// `rclcpp::TimerInfoCallbackType` / `rcl_timer_call_info_t`.
10013 ///
10014 /// `source` picks the clock both the period and the two call times are
10015 /// measured on. `autostart == false` creates the timer CANCELLED (rcl's
10016 /// `rcl_timer_init2(..., autostart)`): it does not fire until
10017 /// [`reset_timer`](Self::reset_timer) starts it, and its first period
10018 /// counts from that reset.
10019 ///
10020 /// ```ignore
10021 /// executor.register_timer_with_info(
10022 /// TimerDuration::from_millis(10),
10023 /// TimerClockSource::Steady,
10024 /// true,
10025 /// |info| jitter.record(info.lateness_ns()),
10026 /// )?;
10027 /// ```
10028 pub fn register_timer_with_info<F>(
10029 &mut self,
10030 period: TimerDuration,
10031 source: TimerClockSource,
10032 autostart: bool,
10033 callback: F,
10034 ) -> Result<HandleId, NodeError>
10035 where
10036 F: FnMut(&TimerCallInfo) + 'static,
10037 {
10038 self.register_timer_with_info_on(None, period, source, autostart, None, callback)
10039 }
10040
10041 /// [`register_timer_with_info`](Self::register_timer_with_info) bound to a
10042 /// node and an optional callback group — the executor primitive behind
10043 /// `Node::create_timer_with_info` and the C `rcl_timer_init2` / `rclc_timer_init_default2` /
10044 /// `rclc_timer_init_with_info` timers (issue 1974). `node_id`/`group` feed
10045 /// the sched binding exactly as [`register_timer_on`](Self::register_timer_on).
10046 pub fn register_timer_with_info_on<F>(
10047 &mut self,
10048 node_id: Option<super::node_record::NodeId>,
10049 period: TimerDuration,
10050 source: TimerClockSource,
10051 autostart: bool,
10052 group: Option<&str>,
10053 callback: F,
10054 ) -> Result<HandleId, NodeError>
10055 where
10056 F: FnMut(&TimerCallInfo) + 'static,
10057 {
10058 self.register_timer_entry_with(
10059 node_id,
10060 period,
10061 source,
10062 false,
10063 autostart,
10064 group,
10065 None,
10066 core::ptr::null_mut(),
10067 move |_| callback,
10068 )
10069 }
10070
10071 /// Phase 273 (RFC-0047) — register a repeating timer callback bound to a
10072 /// specific node and optional callback group. The group name is threaded to
10073 /// `apply_node_default_sched` so the seeded `group_sched_table` assigns
10074 /// the timer's callback to the group's `SchedContext`. When `group` is
10075 /// `None` the node's `default_sched` applies (phase-272 behavior).
10076 ///
10077 /// This is the executor-level primitive called by the Rust `_in_group` API
10078 /// (`Node::create_timer_in_group`) and the C/C++ group-aware timer FFI.
10079 pub fn register_timer_on<F>(
10080 &mut self,
10081 node_id: Option<super::node_record::NodeId>,
10082 period: TimerDuration,
10083 callback: F,
10084 group: Option<&str>,
10085 ) -> Result<HandleId, NodeError>
10086 where
10087 F: FnMut() + 'static,
10088 {
10089 self.register_timer_entry(
10090 node_id,
10091 period,
10092 TimerClockSource::Steady,
10093 false,
10094 group,
10095 callback,
10096 )
10097 }
10098
10099 // ========================================================================
10100 // Raw callback registration (for C API)
10101 // ========================================================================
10102
10103 /// The kept C-FFI subscription core (Phase 189.M2.b): registers a
10104 /// raw `RawSubscriptionCallback` fn-ptr + `context` against an
10105 /// optional node's session. The Rust ergonomic surface is the
10106 /// `node.subscription(t)` builder (closures); this is the single
10107 /// primitive the `nros-c` thin wrapper lowers to. `node_id == None`
10108 /// is the legacy single-node path.
10109 #[allow(clippy::too_many_arguments)]
10110 pub(crate) fn add_arena_subscription_c_callback<const RX_BUF: usize>(
10111 &mut self,
10112 node_id: Option<super::node_record::NodeId>,
10113 topic_name: &str,
10114 type_name: &str,
10115 type_hash: &str,
10116 qos: QoSProfile,
10117 callback: RawSubscriptionCallback,
10118 context: *mut core::ffi::c_void,
10119 group: Option<&str>,
10120 rx_buffer_hint: usize,
10121 ) -> Result<HandleId, NodeError> {
10122 self.add_arena_subscription_c_callback_with_capture::<RX_BUF>(
10123 node_id,
10124 topic_name,
10125 type_name,
10126 type_hash,
10127 qos,
10128 callback,
10129 context,
10130 group,
10131 rx_buffer_hint,
10132 None,
10133 None,
10134 )
10135 }
10136
10137 /// The same registration, with the caller's CAPTURE held by the arena —
10138 /// phase-456 W1.
10139 ///
10140 /// `context` is one pointer, which carries a C++ `[this]` capture and not a
10141 /// `[this, state]` or a `[obj, method]`. When `capture` is `Some`, its
10142 /// bytes are copied into the arena and `context` is repointed at that copy,
10143 /// so the caller keeps nothing and nothing the caller owns is referenced
10144 /// after this returns. When it is `None` the behaviour is exactly the
10145 /// pre-existing one, which is why the plain entry point above is a wrapper
10146 /// rather than a second implementation.
10147 ///
10148 /// **phase-456 W8 changed two things here.** The capture is a RUNTIME
10149 /// length — see `Self::place_capture` — so there is no constant the C++
10150 /// side has to match and no `BufferTooSmall` reachable only by drifting
10151 /// from it. And this path now reaches the in-place dispatch that the Rust
10152 /// typed path has had since phase-231: `RawSubscriptionCallback` is
10153 /// `(const uint8_t*, size_t, void*)`, so the backend's borrowed receive
10154 /// slot IS the argument, and a C or C++ subscription on zenoh or XRCE
10155 /// claims no receive region at all.
10156 #[allow(clippy::too_many_arguments)]
10157 pub(crate) fn add_arena_subscription_c_callback_with_capture<const RX_BUF: usize>(
10158 &mut self,
10159 node_id: Option<super::node_record::NodeId>,
10160 topic_name: &str,
10161 type_name: &str,
10162 type_hash: &str,
10163 qos: QoSProfile,
10164 callback: RawSubscriptionCallback,
10165 context: *mut core::ffi::c_void,
10166 group: Option<&str>,
10167 // phase-402 W2 / issue 0896 — bytes the caller expects to receive; 0 =
10168 // no opinion. A size-classing backend (zenoh-pico) routes on this, and
10169 // a subscription that states nothing takes the SMALL class whatever its
10170 // message type. The C path had no way to say it until the options
10171 // struct existed.
10172 rx_buffer_hint: usize,
10173 capture: Option<&[u8]>,
10174 // phase-476 W2 — called on the arena's copy of `capture` when the entry
10175 // is dropped; see `CaptureGuard`. `None` when the capture owns nothing.
10176 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10177 ) -> Result<HandleId, NodeError> {
10178 // phase-403 W3/W5 -- size the arena slot from the TYPE, not from the
10179 // image-wide default. `rx_buffer_hint` already arrives here (phase-402
10180 // routed it to the backend's payload class and stopped); spending it on
10181 // the allocation as well is what makes the buffer per-type.
10182 //
10183 // This is the whole saving on the raw path. `RX_BUF` is
10184 // DEFAULT_RX_BUF_SIZE, so every slot -- publishers and timers included --
10185 // was charged the largest subscription's buffer. Measured on
10186 // mr-canhubk344: 36 handles at the correct 2052-byte bound wanted a
10187 // 242096-byte arena of a 77968-byte region; only 13 of those handles
10188 // receive anything.
10189 //
10190 // 0 keeps the old behaviour, and it means "this CALLER stated nothing"
10191 // rather than "this type is unbounded" -- an unbounded type is a build
10192 // error now, so a zero here is a caller that did not ask, never a type
10193 // that could not answer.
10194 let slot_bytes = if rx_buffer_hint != 0 {
10195 rx_buffer_hint
10196 } else {
10197 RX_BUF
10198 };
10199
10200 let SubscriptionOpen {
10201 slot,
10202 handle,
10203 qos,
10204 in_place,
10205 slot_bytes,
10206 } = self.open_subscription(&SubscriptionRequest {
10207 node_id,
10208 topic_name,
10209 type_name,
10210 type_hash,
10211 qos,
10212 backend_hint: Some(rx_buffer_hint),
10213 slot_bytes,
10214 // phase-456 W8 — `RawSubscriptionCallback` takes borrowed bytes and
10215 // a context, which is exactly what `process_raw_in_place` hands it.
10216 in_place_capable: true,
10217 })?;
10218
10219 // The capture is placed AFTER the backend call and BEFORE either entry
10220 // is written, so both dispatch shapes get the same context and a failed
10221 // `create_subscription` costs no arena bytes. phase-476 W0 — it rides
10222 // at the end of the entry's own trailing region, so releasing the entry
10223 // gives it back.
10224 let capture_len = Self::capture_trailing_len(capture);
10225
10226 if in_place {
10227 let (entry_offset, capture_at) =
10228 self.arena_alloc_with_trailing::<super::arena::SubInplaceRawCEntry>(capture_len)?;
10229 let context = self.place_capture(capture_at, capture, context);
10230 unsafe {
10231 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10232 let entry_ptr =
10233 arena_ptr.add(entry_offset) as *mut super::arena::SubInplaceRawCEntry;
10234 core::ptr::write(
10235 entry_ptr,
10236 super::arena::SubInplaceRawCEntry {
10237 handle,
10238 callback,
10239 context,
10240 capture: super::arena::CaptureGuard {
10241 ctx: context,
10242 drop: capture_drop,
10243 },
10244 age_mon: None,
10245 },
10246 );
10247 }
10248 let meta = CallbackMeta {
10249 offset: entry_offset,
10250 kind: EntryKind::Subscription,
10251 arena_len: super::arena::ArenaLen::UNRECORDED,
10252 try_process: super::arena::sub_inplace_raw_c_try_process,
10253 has_data: super::arena::sub_inplace_raw_c_has_data,
10254 pre_sample: no_pre_sample,
10255 invocation: InvocationMode::OnNewData,
10256 drop_fn: drop_entry::<super::arena::SubInplaceRawCEntry>,
10257 };
10258 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10259 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10260 // SAFETY: a `super::arena::SubInplaceRawCEntry` was written at `entry_offset` just above.
10261 unsafe {
10262 self.arm_entry_wake::<super::arena::SubInplaceRawCEntry>(
10263 slot,
10264 entry_offset,
10265 |e, w| nros_rmw::Subscription::register_waker(&e.handle, w),
10266 )
10267 };
10268 self.apply_node_default_sched(slot, node_id, group);
10269 return Ok(self.handle_for(slot));
10270 }
10271
10272 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, slot_bytes);
10273
10274 // The ring first, then the capture on the next `u64` boundary after it.
10275 let ring_len = trailing_bytes.next_multiple_of(core::mem::align_of::<u64>());
10276 let (entry_offset, trailing_offset) =
10277 self.arena_alloc_with_trailing::<SubBufferedRawCEntry>(ring_len + capture_len)?;
10278 let context = self.place_capture(trailing_offset + ring_len, capture, context);
10279
10280 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
10281
10282 let buffer = if qos.depth <= 1 {
10283 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, slot_bytes) })
10284 } else {
10285 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, slot_bytes, qos.depth as usize) })
10286 };
10287
10288 unsafe {
10289 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10290 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedRawCEntry;
10291 core::ptr::write(
10292 entry_ptr,
10293 SubBufferedRawCEntry {
10294 handle,
10295 buffer,
10296 callback,
10297 context,
10298 capture: super::arena::CaptureGuard {
10299 ctx: context,
10300 drop: capture_drop,
10301 },
10302 age_mon: None,
10303 },
10304 );
10305 }
10306
10307 let meta = CallbackMeta {
10308 offset: entry_offset,
10309 kind: EntryKind::Subscription,
10310 arena_len: super::arena::ArenaLen::UNRECORDED,
10311 try_process: sub_buffered_raw_c_try_process,
10312 has_data: sub_buffered_raw_c_has_data,
10313 pre_sample: no_pre_sample,
10314 invocation: InvocationMode::OnNewData,
10315 drop_fn: drop_entry::<SubBufferedRawCEntry>,
10316 };
10317 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10318 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10319 // SAFETY: a `SubBufferedRawCEntry` was written at `entry_offset` just above.
10320 unsafe {
10321 self.arm_entry_wake::<SubBufferedRawCEntry>(slot, entry_offset, |e, w| {
10322 nros_rmw::Subscription::register_waker(&e.handle, w)
10323 })
10324 };
10325 self.apply_node_default_sched(slot, node_id, group);
10326 Ok(self.handle_for(slot))
10327 }
10328
10329 /// phase-417 W5.a (RFC-0089 stage 5) — the TYPED C-FFI subscription core:
10330 /// the same registration as [`Self::add_arena_subscription_c_callback`],
10331 /// plus caller-owned message storage and the erased deserialiser for its
10332 /// type, so the callback receives a DESERIALISED message instead of CDR
10333 /// bytes.
10334 ///
10335 /// This is the Rust half of rclc's
10336 /// `rclc_executor_add_subscription(executor, subscription, msg, callback,
10337 /// invocation)`. rclc has no allocator on that path either — it makes the
10338 /// CALLER own the storage, which is exactly what `msg` is here. The C
10339 /// ledger recorded our byte-oriented delivery as forced by "no allocator";
10340 /// the compared surface operates under the same constraint, so the reason
10341 /// was false and the divergence was avoidable (issue 1022 / W-C3).
10342 ///
10343 /// The raw path is untouched and stays: a byte-oriented subscriber is a
10344 /// legitimate thing to want, and this is additive.
10345 ///
10346 /// # Contract on failure
10347 /// If `deserialize` returns non-zero the callback is **not** invoked, the
10348 /// sample is dropped, `try_process` reports
10349 /// `TransportError::DeserializationError` (so it lands in
10350 /// `SpinOnceResult::subscription_errors`), and a rate-limited `nros_log`
10351 /// error names the length and the return code. A message that does not fit
10352 /// the caller's storage arrives here as exactly that failure: both the
10353 /// bounded-string and bounded-sequence decoders refuse rather than
10354 /// truncate, so there is no silent short read.
10355 ///
10356 /// # Safety
10357 /// `msg` must point to storage of the type `deserialize` writes, valid for
10358 /// as long as the subscription is registered. Nothing here drops it.
10359 #[allow(clippy::too_many_arguments)]
10360 pub(crate) unsafe fn add_arena_subscription_c_typed_callback<const RX_BUF: usize>(
10361 &mut self,
10362 node_id: Option<super::node_record::NodeId>,
10363 topic_name: &str,
10364 type_name: &str,
10365 type_hash: &str,
10366 qos: QoSProfile,
10367 msg: core::ptr::NonNull<core::ffi::c_void>,
10368 deserialize: RawMessageDeserializeFn,
10369 callback: TypedSubscriptionCallback,
10370 context: *mut core::ffi::c_void,
10371 group: Option<&str>,
10372 // Same meaning as on the raw path: bytes the caller expects to receive,
10373 // 0 = no opinion. A typed caller normally HAS an answer — the generated
10374 // `<Msg>_RX_MAX_SERIALIZED_SIZE` — which is why the C entry point takes
10375 // it rather than defaulting.
10376 rx_buffer_hint: usize,
10377 ) -> Result<HandleId, NodeError> {
10378 let SubscriptionOpen {
10379 slot,
10380 handle,
10381 qos,
10382 slot_bytes: rx_bytes,
10383 ..
10384 } = self.open_subscription(&SubscriptionRequest {
10385 node_id,
10386 topic_name,
10387 type_name,
10388 type_hash,
10389 qos,
10390 backend_hint: Some(rx_buffer_hint),
10391 // phase-403 W3/W5, unchanged: the hint sizes the arena slot, and 0
10392 // means "this caller stated nothing", never "this type is zero
10393 // bytes".
10394 slot_bytes: if rx_buffer_hint != 0 {
10395 rx_buffer_hint
10396 } else {
10397 RX_BUF
10398 },
10399 // The callback receives the caller's DESERIALISED storage, written
10400 // by `deserialize` from the sample's bytes, so borrowed bytes would
10401 // be enough and this shape could take the in-place row. It does not
10402 // yet: `SubBufferedTypedCEntry` also owns the `msg` pointer and the
10403 // deserialiser, so an in-place twin is a second entry type rather
10404 // than a flag, and phase-456 W8's acceptance is the RAW C path.
10405 // Stated here rather than left blank because a `false` nobody wrote
10406 // down is what this whole item is about.
10407 in_place_capable: false,
10408 })?;
10409
10410 let (_slot_count, trailing_bytes) = buffered_region_size(qos.depth, rx_bytes);
10411
10412 let (entry_offset, trailing_offset) =
10413 self.arena_alloc_with_trailing::<SubBufferedTypedCEntry>(trailing_bytes)?;
10414
10415 let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
10416
10417 let buffer = if qos.depth <= 1 {
10418 BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, rx_bytes) })
10419 } else {
10420 BufferStrategy::Ring(unsafe { SpscRing::init(buf_ptr, rx_bytes, qos.depth as usize) })
10421 };
10422
10423 unsafe {
10424 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10425 let entry_ptr = arena_ptr.add(entry_offset) as *mut SubBufferedTypedCEntry;
10426 core::ptr::write(
10427 entry_ptr,
10428 SubBufferedTypedCEntry {
10429 handle,
10430 buffer,
10431 msg: msg.as_ptr(),
10432 deserialize,
10433 callback,
10434 context,
10435 },
10436 );
10437 }
10438
10439 let meta = CallbackMeta {
10440 offset: entry_offset,
10441 kind: EntryKind::Subscription,
10442 arena_len: super::arena::ArenaLen::UNRECORDED,
10443 try_process: sub_buffered_typed_c_try_process,
10444 has_data: sub_buffered_typed_c_has_data,
10445 pre_sample: no_pre_sample,
10446 invocation: InvocationMode::OnNewData,
10447 drop_fn: drop_entry::<SubBufferedTypedCEntry>,
10448 };
10449 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10450 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10451 // SAFETY: a `SubBufferedTypedCEntry` was written at `entry_offset` just above.
10452 unsafe {
10453 self.arm_entry_wake::<SubBufferedTypedCEntry>(slot, entry_offset, |e, w| {
10454 nros_rmw::Subscription::register_waker(&e.handle, w)
10455 })
10456 };
10457 self.apply_node_default_sched(slot, node_id, group);
10458 Ok(self.handle_for(slot))
10459 }
10460
10461 /// Issue 2036 — register a raw subscription whose closure receives each
10462 /// sample's [`MessageInfo`](nros_core::MessageInfo) (`None` from a backend
10463 /// that reports none): the raw twin of the typed `with_info` path, for a
10464 /// caller that deserializes itself. Backs the C++
10465 /// `(const M&, const rclcpp::MessageInfo&)` subscription callback
10466 /// (`nros_cpp_subscription_register_message_info`).
10467 ///
10468 /// `rx_buffer_hint` sizes both the backend's payload class and the arena
10469 /// slot, as on [`add_arena_subscription_c_info_callback`](crate::executor::ffi::ExecutorFfi::add_arena_subscription_c_info_callback);
10470 /// 0 falls back to `DEFAULT_RX_BUF_SIZE`.
10471 #[allow(clippy::too_many_arguments)]
10472 pub fn add_arena_subscription_msg_info_closure<F>(
10473 &mut self,
10474 node_id: Option<super::node_record::NodeId>,
10475 topic_name: &str,
10476 type_name: &str,
10477 type_hash: &str,
10478 qos: QoSProfile,
10479 rx_buffer_hint: usize,
10480 callback: F,
10481 ) -> Result<HandleId, NodeError>
10482 where
10483 F: FnMut(&[u8], Option<&nros_core::MessageInfo>) + 'static,
10484 {
10485 type Entry<F> = SubBufferedRawMsgInfoEntry<F>;
10486
10487 let SubscriptionOpen {
10488 slot,
10489 handle,
10490 slot_bytes: rx_bytes,
10491 ..
10492 } = self.open_subscription(&SubscriptionRequest {
10493 node_id,
10494 topic_name,
10495 type_name,
10496 type_hash,
10497 qos,
10498 backend_hint: Some(rx_buffer_hint),
10499 slot_bytes: if rx_buffer_hint != 0 {
10500 rx_buffer_hint
10501 } else {
10502 crate::config::DEFAULT_RX_BUF_SIZE
10503 },
10504 // The metadata comes with a COPYING take (`take_serialized_with_info`).
10505 in_place_capable: false,
10506 })?;
10507
10508 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry<F>>(rx_bytes)?;
10509 unsafe {
10510 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10511 let buffer = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
10512 let entry_ptr = arena_ptr.add(offset) as *mut Entry<F>;
10513 core::ptr::write(
10514 entry_ptr,
10515 Entry {
10516 handle,
10517 buffer,
10518 callback,
10519 },
10520 );
10521 }
10522
10523 let meta = CallbackMeta {
10524 offset,
10525 kind: EntryKind::Subscription,
10526 arena_len: super::arena::ArenaLen::UNRECORDED,
10527 try_process: sub_buffered_raw_msg_info_try_process::<F>,
10528 has_data: sub_buffered_raw_msg_info_has_data::<F>,
10529 pre_sample: no_pre_sample,
10530 invocation: InvocationMode::OnNewData,
10531 drop_fn: drop_entry::<Entry<F>>,
10532 };
10533 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10534 // SAFETY: an `Entry<F>` was written at `offset` just above.
10535 unsafe {
10536 self.arm_entry_wake::<Entry<F>>(slot, offset, |e, w| {
10537 nros_rmw::Subscription::register_waker(&e.handle, w)
10538 })
10539 };
10540 self.apply_node_default_sched(slot, node_id, None);
10541 Ok(self.handle_for(slot))
10542 }
10543
10544 /// Phase 189.M3.4 — register a raw C-fn-ptr subscription whose callback
10545 /// also receives the sample's wire **attachment**
10546 /// ([`RawSubscriptionInfoCallback`]: `(data, len, attachment, att_len,
10547 /// context)`) — the C analog of the Rust
10548 /// `node.subscription(t).generic(..).message_info()` builder. Backs the C
10549 /// FFI `rclc_executor_add_subscription_raw_with_info`. Flat per-entry
10550 /// payload + attachment buffers (cap `RAW_INFO_ATT_CAP`);
10551 /// one sample per `spin_once`.
10552 #[allow(clippy::too_many_arguments)]
10553 pub(crate) fn add_arena_subscription_c_info_callback<const RX_BUF: usize>(
10554 &mut self,
10555 node_id: Option<super::node_record::NodeId>,
10556 topic_name: &str,
10557 type_name: &str,
10558 type_hash: &str,
10559 qos: QoSProfile,
10560 callback: RawSubscriptionInfoCallback,
10561 context: *mut core::ffi::c_void,
10562 // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
10563 // opinion. It now buys BOTH halves: the BACKEND's payload size-class
10564 // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
10565 // survives only as the fallback for a caller that states nothing, which
10566 // is exactly its role on the plain C path.
10567 rx_buffer_hint: usize,
10568 ) -> Result<HandleId, NodeError> {
10569 type Entry = SubBufferedRawInfoCEntry;
10570
10571 let SubscriptionOpen {
10572 slot,
10573 handle,
10574 slot_bytes: rx_bytes,
10575 ..
10576 } = self.open_subscription(&SubscriptionRequest {
10577 node_id,
10578 topic_name,
10579 type_name,
10580 type_hash,
10581 qos,
10582 backend_hint: Some(rx_buffer_hint),
10583 // phase-408 W5b — the hint sizes the ARENA too, not just the
10584 // backend's size class. 0 still means "this caller stated nothing",
10585 // so `RX_BUF` -- `DEFAULT_RX_BUF_SIZE` at every call site -- is what
10586 // it falls back to, and an unhinted registration claims the same
10587 // bytes it always did.
10588 slot_bytes: if rx_buffer_hint != 0 {
10589 rx_buffer_hint
10590 } else {
10591 RX_BUF
10592 },
10593 // Carries the wire attachment — see the Rust `_raw_info_on` twin.
10594 in_place_capable: false,
10595 })?;
10596
10597 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
10598 unsafe {
10599 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10600 let buf = super::arena::TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
10601 let entry_ptr = arena_ptr.add(offset) as *mut Entry;
10602 core::ptr::write(
10603 entry_ptr,
10604 Entry {
10605 handle,
10606 buffer: buf,
10607 att: [0u8; super::arena::RAW_INFO_ATT_CAP],
10608 callback,
10609 context,
10610 },
10611 );
10612 }
10613
10614 let meta = CallbackMeta {
10615 offset,
10616 kind: EntryKind::Subscription,
10617 arena_len: super::arena::ArenaLen::UNRECORDED,
10618 try_process: sub_buffered_raw_info_c_try_process,
10619 has_data: sub_buffered_raw_info_c_has_data,
10620 pre_sample: no_pre_sample,
10621 invocation: InvocationMode::OnNewData,
10622 drop_fn: drop_entry::<Entry>,
10623 };
10624 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10625 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10626 // SAFETY: a `Entry` was written at `offset` just above.
10627 unsafe {
10628 self.arm_entry_wake::<Entry>(slot, offset, |e, w| {
10629 nros_rmw::Subscription::register_waker(&e.handle, w)
10630 })
10631 };
10632 self.apply_node_default_sched(slot, node_id, None);
10633 Ok(self.handle_for(slot))
10634 }
10635
10636 /// Phase 269 W3 — register a raw C-fn-ptr subscription whose callback
10637 /// ALSO surfaces the sample's E2E integrity status (CRC + sequence gap/dup)
10638 /// alongside the CDR bytes — the C/C++ component-callback analog of Rust's
10639 /// `register_subscription_buffered_raw_safety_on` (`FnMut(&[u8], &IntegrityStatus)`).
10640 ///
10641 /// The executor validates the sample via `take_validated` and unpacks the
10642 /// [`nros_rmw::IntegrityStatus`] into three plain scalars (gap, duplicate,
10643 /// crc_valid) before calling `callback`. This avoids introducing a
10644 /// cbindgen-visible struct at the executor layer; the C/C++ headers pack them
10645 /// back into their local integrity-status typedef.
10646 ///
10647 /// Requires the `safety-e2e` feature. Backed by `SubBufferedRawSafetyCEntry`.
10648 #[cfg(feature = "safety-e2e")]
10649 #[allow(clippy::too_many_arguments)]
10650 pub(crate) fn add_arena_subscription_c_validated_callback<const RX_BUF: usize>(
10651 &mut self,
10652 node_id: Option<super::node_record::NodeId>,
10653 topic_name: &str,
10654 type_name: &str,
10655 type_hash: &str,
10656 qos: QoSProfile,
10657 callback: super::types::RawSubscriptionSafetyCallback,
10658 context: *mut core::ffi::c_void,
10659 // phase-408 W5a/W5b — bytes the caller expects to receive; 0 = no
10660 // opinion. It now buys BOTH halves: the BACKEND's payload size-class
10661 // routing (W5a, below) and the ARENA slot (W5b, further down). `RX_BUF`
10662 // survives only as the fallback for a caller that states nothing, which
10663 // is exactly its role on the plain C path.
10664 rx_buffer_hint: usize,
10665 ) -> Result<HandleId, NodeError> {
10666 use super::arena::{
10667 SubBufferedRawSafetyCEntry, TrailingBuf, sub_buffered_raw_safety_c_has_data,
10668 sub_buffered_raw_safety_c_try_process,
10669 };
10670 type Entry = SubBufferedRawSafetyCEntry;
10671
10672 let SubscriptionOpen {
10673 slot,
10674 handle,
10675 slot_bytes: rx_bytes,
10676 ..
10677 } = self.open_subscription(&SubscriptionRequest {
10678 node_id,
10679 topic_name,
10680 type_name,
10681 type_hash,
10682 qos,
10683 backend_hint: Some(rx_buffer_hint),
10684 // phase-408 W5b — same as the info sibling: one flat slot in the
10685 // trailing region, sized from the hint, `RX_BUF` only as the
10686 // stated-nothing fallback.
10687 slot_bytes: if rx_buffer_hint != 0 {
10688 rx_buffer_hint
10689 } else {
10690 RX_BUF
10691 },
10692 // Carries the integrity status, which comes from `take_validated`.
10693 in_place_capable: false,
10694 })?;
10695
10696 let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry>(rx_bytes)?;
10697 unsafe {
10698 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10699 let buf = TrailingBuf::init(arena_ptr.add(trailing_offset), rx_bytes);
10700 let entry_ptr = arena_ptr.add(offset) as *mut Entry;
10701 core::ptr::write(
10702 entry_ptr,
10703 Entry {
10704 handle,
10705 buffer: buf,
10706 callback,
10707 context,
10708 },
10709 );
10710 }
10711
10712 let meta = CallbackMeta {
10713 offset,
10714 kind: EntryKind::Subscription,
10715 arena_len: super::arena::ArenaLen::UNRECORDED,
10716 try_process: sub_buffered_raw_safety_c_try_process,
10717 has_data: sub_buffered_raw_safety_c_has_data,
10718 pre_sample: no_pre_sample,
10719 invocation: InvocationMode::OnNewData,
10720 drop_fn: drop_entry::<Entry>,
10721 };
10722 self.emplace_entry(slot, meta, TraceName::Text(topic_name));
10723 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10724 // SAFETY: a `Entry` was written at `offset` just above.
10725 unsafe {
10726 self.arm_entry_wake::<Entry>(slot, offset, |e, w| {
10727 nros_rmw::Subscription::register_waker(&e.handle, w)
10728 })
10729 };
10730 self.apply_node_default_sched(slot, node_id, None);
10731 Ok(self.handle_for(slot))
10732 }
10733
10734 /// Register a raw (untyped) service callback.
10735 ///
10736 /// Register a raw (untyped) service callback with the default buffer size.
10737 ///
10738 /// The callback receives and produces CDR bytes without typed
10739 /// deserialization/serialization. Used by the C API wrapper.
10740 pub(crate) fn register_service_raw(
10741 &mut self,
10742 service_name: &str,
10743 service_type: &str,
10744 service_hash: &str,
10745 callback: RawServiceCallback,
10746 context: *mut core::ffi::c_void,
10747 ) -> Result<HandleId, NodeError> {
10748 self.register_service_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(
10749 service_name,
10750 service_type,
10751 service_hash,
10752 QoSProfile::services_default(),
10753 callback,
10754 context,
10755 )
10756 }
10757
10758 /// Register a raw (untyped) service callback with custom buffer sizes + QoS.
10759 ///
10760 /// `REQ_BUF` and `REPLY_BUF` set the stack-allocated CDR buffers
10761 /// for the request and reply respectively. Increase for services
10762 /// with large payloads (e.g., parameter services). `qos` applies to both
10763 /// the request + reply endpoints (Phase 193.2c).
10764 #[allow(clippy::too_many_arguments)]
10765 pub(crate) fn register_service_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
10766 &mut self,
10767 service_name: &str,
10768 service_type: &str,
10769 service_hash: &str,
10770 qos: QoSProfile,
10771 callback: RawServiceCallback,
10772 context: *mut core::ffi::c_void,
10773 ) -> Result<HandleId, NodeError> {
10774 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceCallback>(
10775 None,
10776 service_name,
10777 service_type,
10778 service_hash,
10779 qos,
10780 callback,
10781 context,
10782 None,
10783 None,
10784 )
10785 }
10786
10787 /// Phase 104.C.3.3.a — Node-aware variant of
10788 /// [`Self::register_service_raw_sized`]. C-FFI path.
10789 #[allow(clippy::too_many_arguments)]
10790 pub(crate) fn register_service_raw_sized_on<const REQ_BUF: usize, const REPLY_BUF: usize>(
10791 &mut self,
10792 node_id: super::node_record::NodeId,
10793 service_name: &str,
10794 service_type: &str,
10795 service_hash: &str,
10796 qos: QoSProfile,
10797 callback: RawServiceCallback,
10798 context: *mut core::ffi::c_void,
10799 ) -> Result<HandleId, NodeError> {
10800 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceCallback>(
10801 Some(node_id),
10802 service_name,
10803 service_type,
10804 service_hash,
10805 qos,
10806 callback,
10807 context,
10808 None,
10809 None,
10810 )
10811 }
10812
10813 /// phase-488 — [`Self::register_service_raw_sized`] / `_on` with the
10814 /// handler's CAPTURE copied into the entry's own region, as
10815 /// [`Self::add_arena_subscription_c_callback_with_capture`] does for a
10816 /// subscription. The dispatch context becomes the arena's copy, and
10817 /// `capture_drop` (when given) destroys it with the entry. This is what lets a
10818 /// C++ service handler carry state (`std::bind(&Node::on_x, this, _1, _2)`,
10819 /// a capturing lambda): a context word holds a `[this]` and not a
10820 /// pointer-to-member plus its object.
10821 #[allow(clippy::too_many_arguments)]
10822 pub(crate) fn register_service_raw_capturing<const REQ_BUF: usize, const REPLY_BUF: usize>(
10823 &mut self,
10824 node_id: Option<super::node_record::NodeId>,
10825 service_name: &str,
10826 service_type: &str,
10827 service_hash: &str,
10828 qos: QoSProfile,
10829 callback: RawServiceCallback,
10830 capture: Option<&[u8]>,
10831 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10832 ) -> Result<HandleId, NodeError> {
10833 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceCallback>(
10834 node_id,
10835 service_name,
10836 service_type,
10837 service_hash,
10838 qos,
10839 callback,
10840 core::ptr::null_mut(),
10841 capture,
10842 capture_drop,
10843 )
10844 }
10845
10846 /// Issue 2081 — a raw service whose callback also receives the request's
10847 /// id (client writer GUID + client sequence number), rclc's
10848 /// `rclc_service_callback_with_request_id_t` shape in CDR bytes. The id is
10849 /// the one the backend reported for the request being dispatched
10850 /// ([`ServiceTrait::request_info`](nros_rmw::ServiceTrait::request_info)),
10851 /// all zeros when it reported none.
10852 ///
10853 /// One entry point for every registration shape: `node_id` as
10854 /// `register_service_raw_sized_on` (or `None`), and `capture` as
10855 /// `register_service_raw_capturing` (or `None`, with `context`
10856 /// passed through instead).
10857 #[allow(clippy::too_many_arguments)]
10858 pub fn register_service_raw_with_request_id_sized<
10859 const REQ_BUF: usize,
10860 const REPLY_BUF: usize,
10861 >(
10862 &mut self,
10863 node_id: Option<super::node_record::NodeId>,
10864 service_name: &str,
10865 service_type: &str,
10866 service_hash: &str,
10867 qos: QoSProfile,
10868 callback: RawServiceRequestIdCallback,
10869 context: *mut core::ffi::c_void,
10870 capture: Option<&[u8]>,
10871 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10872 ) -> Result<HandleId, NodeError> {
10873 self.register_service_raw_sized_inner::<REQ_BUF, REPLY_BUF, RawServiceRequestIdCallback>(
10874 node_id,
10875 service_name,
10876 service_type,
10877 service_hash,
10878 qos,
10879 callback,
10880 context,
10881 capture,
10882 capture_drop,
10883 )
10884 }
10885
10886 #[allow(clippy::too_many_arguments)]
10887 fn register_service_raw_sized_inner<
10888 const REQ_BUF: usize,
10889 const REPLY_BUF: usize,
10890 C: super::arena::RawServiceDispatch,
10891 >(
10892 &mut self,
10893 node_id: Option<super::node_record::NodeId>,
10894 service_name: &str,
10895 service_type: &str,
10896 service_hash: &str,
10897 qos: QoSProfile,
10898 callback: C,
10899 context: *mut core::ffi::c_void,
10900 capture: Option<&[u8]>,
10901 capture_drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
10902 ) -> Result<HandleId, NodeError> {
10903 let slot = self.next_entry_slot()?;
10904 let (node_name, ns, session_idx) = match node_id {
10905 Some(id) => {
10906 let r = self
10907 .nodes
10908 .get(id.index())
10909 .ok_or(NodeError::InvalidSchedContextBinding)?;
10910 (r.name.clone(), r.namespace.clone(), r.session_idx)
10911 }
10912 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
10913 };
10914 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
10915 .with_domain(self.domain_id)
10916 .with_namespace(&ns);
10917 if !node_name.is_empty() {
10918 info = info.with_node_name(&node_name);
10919 }
10920 let handle = {
10921 let session = self
10922 .session_at_mut(session_idx)
10923 .ok_or(NodeError::BackendMismatch)?;
10924 // Phase 193.5 — validate against the backend's supported policies
10925 // (no silent downgrade); request/reply effectively requires RELIABLE.
10926 super::node::validate_qos_or_report(
10927 &qos,
10928 session.supported_qos_policies(),
10929 "service",
10930 service_name,
10931 )?;
10932 session
10933 .create_service(&info, qos)
10934 .map_err(NodeError::Transport)?
10935 };
10936
10937 // phase-488 — a capture rides at the end of the entry's own region, as
10938 // a subscription's does, so releasing the entry gives it back. No
10939 // capture keeps `arena_alloc`, so a C caller's arena use does not move.
10940 let capture_len = Self::capture_trailing_len(capture);
10941 let (offset, context) = if capture_len == 0 {
10942 (
10943 self.arena_alloc::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>()?,
10944 context,
10945 )
10946 } else {
10947 let (offset, capture_at) =
10948 self.arena_alloc_with_trailing::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>(capture_len)?;
10949 (offset, self.place_capture(capture_at, capture, context))
10950 };
10951
10952 unsafe {
10953 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
10954 let entry_ptr = arena_ptr.add(offset) as *mut SrvRawEntry<REQ_BUF, REPLY_BUF, C>;
10955 core::ptr::write(
10956 entry_ptr,
10957 SrvRawEntry {
10958 handle,
10959 req_buffer: [0u8; REQ_BUF],
10960 reply_buffer: [0u8; REPLY_BUF],
10961 callback,
10962 context,
10963 capture: super::arena::CaptureGuard {
10964 ctx: context,
10965 drop: capture_drop,
10966 },
10967 },
10968 );
10969 }
10970
10971 let meta = CallbackMeta {
10972 offset,
10973 kind: EntryKind::Service,
10974 arena_len: super::arena::ArenaLen::UNRECORDED,
10975 try_process: srv_raw_try_process::<REQ_BUF, REPLY_BUF, C>,
10976 has_data: srv_raw_has_data::<REQ_BUF, REPLY_BUF, C>,
10977 pre_sample: no_pre_sample,
10978 invocation: InvocationMode::OnNewData,
10979 drop_fn: drop_entry::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>,
10980 };
10981 self.emplace_entry(slot, meta, TraceName::Text(service_name));
10982 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
10983 // SAFETY: a `SrvRawEntry<REQ_BUF, REPLY_BUF, C>` was written at `offset` just above.
10984 unsafe {
10985 self.arm_entry_wake::<SrvRawEntry<REQ_BUF, REPLY_BUF, C>>(slot, offset, |e, w| {
10986 nros_rmw::ServiceTrait::register_waker(&e.handle, w)
10987 })
10988 };
10989 self.apply_node_default_sched(slot, node_id, None);
10990 Ok(self.handle_for(slot))
10991 }
10992
10993 // ========================================================================
10994 // Raw service client registration (Phase 82)
10995 // ========================================================================
10996
10997 /// Register a raw (untyped) service client with the default reply
10998 /// buffer size.
10999 ///
11000 /// The client is owned by the executor's arena. Each `spin_once`
11001 /// dispatch polls the in-flight reply slot via `take_response_raw`
11002 /// and fires the registered callback when the response arrives.
11003 /// Used by the C API thin wrapper — see Phase 82.
11004 pub(crate) fn register_service_client_raw(
11005 &mut self,
11006 service_name: &str,
11007 service_type: &str,
11008 service_hash: &str,
11009 callback: Option<RawResponseCallback>,
11010 context: *mut core::ffi::c_void,
11011 ) -> Result<HandleId, NodeError> {
11012 self.register_service_client_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
11013 service_name,
11014 service_type,
11015 service_hash,
11016 QoSProfile::services_default(),
11017 callback,
11018 context,
11019 )
11020 }
11021
11022 /// Register a raw service client with a custom reply buffer size + QoS.
11023 ///
11024 /// `qos` applies to the client's request + reply endpoints (Phase 193.3b);
11025 /// defaults to [`QoSProfile::services_default`] via the convenience
11026 /// wrapper.
11027 #[allow(clippy::too_many_arguments)]
11028 pub(crate) fn register_service_client_raw_sized<const REPLY_BUF: usize>(
11029 &mut self,
11030 service_name: &str,
11031 service_type: &str,
11032 service_hash: &str,
11033 qos: QoSProfile,
11034 callback: Option<RawResponseCallback>,
11035 context: *mut core::ffi::c_void,
11036 ) -> Result<HandleId, NodeError> {
11037 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
11038 None,
11039 service_name,
11040 service_type,
11041 service_hash,
11042 qos,
11043 callback,
11044 context,
11045 None,
11046 )
11047 }
11048
11049 /// Phase 104.C.3.3.a — Node-aware variant of
11050 /// [`Self::register_service_client_raw_sized`]. Routes the client
11051 /// creation through the named Node's session.
11052 #[allow(clippy::too_many_arguments)]
11053 pub(crate) fn register_service_client_raw_sized_on<const REPLY_BUF: usize>(
11054 &mut self,
11055 node_id: super::node_record::NodeId,
11056 service_name: &str,
11057 service_type: &str,
11058 service_hash: &str,
11059 qos: QoSProfile,
11060 callback: Option<RawResponseCallback>,
11061 context: *mut core::ffi::c_void,
11062 ) -> Result<HandleId, NodeError> {
11063 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
11064 Some(node_id),
11065 service_name,
11066 service_type,
11067 service_hash,
11068 qos,
11069 callback,
11070 context,
11071 None,
11072 )
11073 }
11074
11075 /// phase-515 C3 — a raw service client whose response callback reads a
11076 /// CAPTURE the arena holds, the client twin of
11077 /// [`register_service_raw_capturing`](Self::register_service_raw_capturing).
11078 ///
11079 /// rclc's typed `rclc_executor_add_client(executor, client, response_msg,
11080 /// callback)` needs three words at dispatch — the caller's response
11081 /// storage, the type's deserialiser and the callback — and the raw entry
11082 /// has one context word. The capture is copied to the end of the entry's
11083 /// own region and `callback` is handed a pointer to that copy, so the C
11084 /// side keeps nothing and releasing the client releases the capture.
11085 /// `rclc_client_call` swaps its own one-shot pair in and out of the entry
11086 /// and restores this one, so a typed client can still be called
11087 /// synchronously.
11088 #[allow(clippy::too_many_arguments)]
11089 pub(crate) fn register_service_client_raw_capturing<const REPLY_BUF: usize>(
11090 &mut self,
11091 node_id: Option<super::node_record::NodeId>,
11092 service_name: &str,
11093 service_type: &str,
11094 service_hash: &str,
11095 qos: QoSProfile,
11096 callback: Option<RawResponseCallback>,
11097 capture: Option<&[u8]>,
11098 ) -> Result<HandleId, NodeError> {
11099 self.register_service_client_raw_sized_inner::<REPLY_BUF>(
11100 node_id,
11101 service_name,
11102 service_type,
11103 service_hash,
11104 qos,
11105 callback,
11106 core::ptr::null_mut(),
11107 capture,
11108 )
11109 }
11110
11111 #[allow(clippy::too_many_arguments)]
11112 fn register_service_client_raw_sized_inner<const REPLY_BUF: usize>(
11113 &mut self,
11114 node_id: Option<super::node_record::NodeId>,
11115 service_name: &str,
11116 service_type: &str,
11117 service_hash: &str,
11118 qos: QoSProfile,
11119 callback: Option<RawResponseCallback>,
11120 context: *mut core::ffi::c_void,
11121 capture: Option<&[u8]>,
11122 ) -> Result<HandleId, NodeError> {
11123 let slot = self.next_entry_slot()?;
11124 let (node_name, ns, session_idx) = match node_id {
11125 Some(id) => {
11126 let r = self
11127 .nodes
11128 .get(id.index())
11129 .ok_or(NodeError::InvalidSchedContextBinding)?;
11130 (r.name.clone(), r.namespace.clone(), r.session_idx)
11131 }
11132 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
11133 };
11134 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
11135 .with_domain(self.domain_id)
11136 .with_namespace(&ns);
11137 if !node_name.is_empty() {
11138 info = info.with_node_name(&node_name);
11139 }
11140 let handle = {
11141 let session = self
11142 .session_at_mut(session_idx)
11143 .ok_or(NodeError::BackendMismatch)?;
11144 // Phase 193.5 — validate against the backend's supported policies
11145 // (no silent downgrade); request/reply effectively requires RELIABLE.
11146 super::node::validate_qos_or_report(
11147 &qos,
11148 session.supported_qos_policies(),
11149 "client",
11150 service_name,
11151 )?;
11152 session
11153 .create_client(&info, qos)
11154 .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
11155 };
11156
11157 // phase-515 C3 — a capture rides at the end of the entry's own region,
11158 // as a service's does (phase-488). No capture keeps `arena_alloc`, so a
11159 // client registered without one uses exactly the arena it always did.
11160 let capture_len = Self::capture_trailing_len(capture);
11161 let (offset, context) = if capture_len == 0 {
11162 (
11163 self.arena_alloc::<ServiceClientRawArenaEntry<REPLY_BUF>>()?,
11164 context,
11165 )
11166 } else {
11167 let (offset, capture_at) = self
11168 .arena_alloc_with_trailing::<ServiceClientRawArenaEntry<REPLY_BUF>>(capture_len)?;
11169 (offset, self.place_capture(capture_at, capture, context))
11170 };
11171 unsafe {
11172 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11173 let entry_ptr = arena_ptr.add(offset) as *mut ServiceClientRawArenaEntry<REPLY_BUF>;
11174 core::ptr::write(
11175 entry_ptr,
11176 ServiceClientRawArenaEntry {
11177 handle,
11178 reply_buffer: [0u8; REPLY_BUF],
11179 pending: false,
11180 reply_ready: core::sync::atomic::AtomicBool::new(false),
11181 callback,
11182 request_id_callback: None,
11183 context,
11184 sequences: super::arena::ClientSequences::new(),
11185 },
11186 );
11187 }
11188
11189 let meta = CallbackMeta {
11190 offset,
11191 kind: EntryKind::ServiceClient,
11192 arena_len: super::arena::ArenaLen::UNRECORDED,
11193 try_process: service_client_raw_try_process::<REPLY_BUF>,
11194 has_data: always_ready,
11195 pre_sample: no_pre_sample,
11196 invocation: InvocationMode::Always,
11197 drop_fn: drop_entry::<ServiceClientRawArenaEntry<REPLY_BUF>>,
11198 };
11199 self.emplace_entry(slot, meta, TraceName::Text(service_name));
11200 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
11201 // SAFETY: a `ServiceClientRawArenaEntry<REPLY_BUF>` was written at `offset` just above.
11202 unsafe {
11203 self.arm_entry_wake::<ServiceClientRawArenaEntry<REPLY_BUF>>(slot, offset, |e, w| {
11204 nros_rmw::ClientTrait::register_waker(&e.handle, w)
11205 })
11206 };
11207 self.apply_node_default_sched(slot, node_id, None);
11208 Ok(self.handle_for(slot))
11209 }
11210
11211 /// RFC-0041 / Phase 239.1 — register a **typed callback** service client.
11212 /// The reply is eager-drained at `spin_once` and dispatched to `callback` as
11213 /// a deserialized `Svc::Reply`. Returns the scheduling [`HandleId`] and a
11214 /// `*mut` to the arena entry's send header (used to build the typed
11215 /// [`ServiceClientCallback`](super::handles::ServiceClientCallback)).
11216 #[allow(clippy::too_many_arguments)]
11217 pub(crate) fn register_service_client_callback<Svc, F, const REPLY_BUF: usize>(
11218 &mut self,
11219 node_id: Option<super::node_record::NodeId>,
11220 service_name: &str,
11221 service_type: &str,
11222 service_hash: &str,
11223 qos: QoSProfile,
11224 callback: F,
11225 ) -> Result<(HandleId, *mut ServiceClientSendHeader<REPLY_BUF>), NodeError>
11226 where
11227 Svc: nros_core::RosService + 'static,
11228 // Issue 2148 — `ReplyOnly(closure)` or `ReplyWithInfo(closure)`.
11229 F: super::arena::ClientReplyCallback<Svc::Reply> + 'static,
11230 {
11231 let slot = self.next_entry_slot()?;
11232 let (node_name, ns, session_idx) = match node_id {
11233 Some(id) => {
11234 let r = self
11235 .nodes
11236 .get(id.index())
11237 .ok_or(NodeError::InvalidSchedContextBinding)?;
11238 (r.name.clone(), r.namespace.clone(), r.session_idx)
11239 }
11240 None => (self.node_name.clone(), self.namespace.clone(), 0u8),
11241 };
11242 let mut info = ServiceInfo::new(service_name, service_type, service_hash)
11243 .with_domain(self.domain_id)
11244 .with_namespace(&ns);
11245 if !node_name.is_empty() {
11246 info = info.with_node_name(&node_name);
11247 }
11248 let handle = {
11249 let session = self
11250 .session_at_mut(session_idx)
11251 .ok_or(NodeError::BackendMismatch)?;
11252 super::node::validate_qos_or_report(
11253 &qos,
11254 session.supported_qos_policies(),
11255 "client",
11256 service_name,
11257 )?;
11258 session
11259 .create_client(&info, qos)
11260 .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?
11261 };
11262
11263 let offset = self.arena_alloc::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>()?;
11264 let hdr_ptr = unsafe {
11265 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11266 let entry_ptr =
11267 arena_ptr.add(offset) as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>;
11268 core::ptr::write(
11269 entry_ptr,
11270 ServiceClientCallbackEntry {
11271 hdr: ServiceClientSendHeader {
11272 handle,
11273 reply_buffer: [0u8; REPLY_BUF],
11274 pending: false,
11275 reply_ready: core::sync::atomic::AtomicBool::new(false),
11276 sequences: super::arena::ClientSequences::new(),
11277 },
11278 callback,
11279 _phantom: core::marker::PhantomData,
11280 },
11281 );
11282 &mut (*entry_ptr).hdr as *mut ServiceClientSendHeader<REPLY_BUF>
11283 };
11284
11285 let meta = CallbackMeta {
11286 offset,
11287 kind: EntryKind::ServiceClient,
11288 arena_len: super::arena::ArenaLen::UNRECORDED,
11289 try_process: service_client_callback_try_process::<Svc, F, REPLY_BUF>,
11290 has_data: always_ready,
11291 pre_sample: no_pre_sample,
11292 invocation: InvocationMode::Always,
11293 drop_fn: drop_entry::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>,
11294 };
11295 self.emplace_entry(slot, meta, TraceName::Text(service_name));
11296 // phase-397 W2 — this entry's arrivals mark its slot and wake this executor.
11297 // SAFETY: a `ServiceClientCallbackEntry<Svc, F, REPLY_BUF>` was written at `offset` just above.
11298 unsafe {
11299 self.arm_entry_wake::<ServiceClientCallbackEntry<Svc, F, REPLY_BUF>>(
11300 slot,
11301 offset,
11302 |e, w| nros_rmw::ClientTrait::register_waker(&e.hdr.handle, w),
11303 )
11304 };
11305 self.apply_node_default_sched(slot, node_id, None);
11306 Ok((self.handle_for(slot), hdr_ptr))
11307 }
11308
11309 // ========================================================================
11310 // Guard condition registration
11311 // ========================================================================
11312
11313 /// Register a guard condition with a callback, on no particular node.
11314 ///
11315 /// Returns both the [`HandleId`] for trigger configuration and a
11316 /// [`GuardCondition`] for triggering from other threads.
11317 ///
11318 /// **This is the ours-only PRIMITIVE, not the verb a user reaches for.**
11319 /// phase-417 W4.e settled that the NODE owns a guard condition in all three
11320 /// languages, so the spelling an application writes is
11321 /// [`Node::create_guard_condition`](crate::executor::Node::create_guard_condition)
11322 /// — which is this function with a node bound, so the guard inherits that
11323 /// node's `SchedContext` like every other entity the node creates. This one
11324 /// stays because the executor is what owns the arena the flag lives in, and
11325 /// because an image with no node at all (a bare `Executor::open` plus a
11326 /// wake source) still has to be able to make one.
11327 pub fn register_guard_condition<F>(
11328 &mut self,
11329 callback: F,
11330 ) -> Result<(HandleId, GuardCondition), NodeError>
11331 where
11332 F: FnMut() + 'static,
11333 {
11334 self.register_guard_condition_on(None, callback)
11335 }
11336
11337 /// The node-bound form of [`register_guard_condition`](Self::register_guard_condition)
11338 /// — phase-417 W4.e.
11339 ///
11340 /// `node_id == None` is the legacy nodeless path and behaves exactly as
11341 /// `register_guard_condition` always did. With a node, the registered slot
11342 /// takes that node's default `SchedContext`, which is what "the node owns
11343 /// it" has to mean for a callback that shares one executor with every other
11344 /// tier in the image (RFC-0047).
11345 pub fn register_guard_condition_on<F>(
11346 &mut self,
11347 node_id: Option<super::node_record::NodeId>,
11348 callback: F,
11349 ) -> Result<(HandleId, GuardCondition), NodeError>
11350 where
11351 F: FnMut() + 'static,
11352 {
11353 let slot = self.next_entry_slot()?;
11354 let offset = self.arena_alloc::<GuardConditionEntry<F>>()?;
11355
11356 unsafe {
11357 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11358 let entry_ptr = arena_ptr.add(offset) as *mut GuardConditionEntry<F>;
11359 core::ptr::write(
11360 entry_ptr,
11361 GuardConditionEntry {
11362 flag: portable_atomic::AtomicBool::new(false),
11363 callback,
11364 },
11365 );
11366
11367 // Create a handle pointing to the flag in the arena
11368 let flag_ptr = &(*entry_ptr).flag as *const portable_atomic::AtomicBool;
11369 #[allow(unused_mut)]
11370 let mut guard_handle = GuardCondition::new(flag_ptr);
11371 // Phase 124.B.5 — wire the wake callback so trigger()
11372 // also signals the executor's wake_cv.
11373 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
11374 {
11375 let ctx = self.wake_ctx_ptr();
11376 guard_handle.set_wake_cb(nros_rmw_runtime_wake_cb, ctx);
11377 }
11378
11379 let meta = CallbackMeta {
11380 offset,
11381 kind: EntryKind::GuardCondition,
11382 arena_len: super::arena::ArenaLen::UNRECORDED,
11383 try_process: guard_try_process::<F>,
11384 has_data: guard_has_data::<F>,
11385 pre_sample: no_pre_sample,
11386 invocation: InvocationMode::OnNewData,
11387 drop_fn: drop_entry::<GuardConditionEntry<F>>,
11388 };
11389 self.emplace_entry(slot, meta, TraceName::Slot("guard", slot));
11390 self.apply_node_default_sched(slot, node_id, None);
11391
11392 Ok((self.handle_for(slot), guard_handle))
11393 }
11394 }
11395
11396 /// phase-467 Row 8 — a guard condition the BACKEND triggers when the ROS
11397 /// graph changes. Upstream's `rcl_node_get_graph_guard_condition`, under
11398 /// this project's guard-condition shape.
11399 ///
11400 /// **The verb is upstream's, the shape is ours, and the difference is
11401 /// stated rather than silent.** `rcl_node_get_graph_guard_condition`
11402 /// RETURNS a borrowed handle to a guard the node owns, which the caller
11403 /// then adds to a wait set. RFC-0018/0019 forbids returning an owned
11404 /// handle across the C seam, we decline wait sets (RFC-0002: one executor
11405 /// per RTOS task), and a guard condition here is created into CALLER
11406 /// storage with its callback bound at creation. So this CREATES one —
11407 /// exactly as [`register_guard_condition_on`] does — and differs from it
11408 /// only in who pulls the trigger.
11409 ///
11410 /// Order matters and is the reason this is one verb rather than two. The
11411 /// backend is asked whether it HAS a graph before a handle slot is
11412 /// consumed, so a caller on XRCE or uORB gets `Unsupported` and an
11413 /// unchanged executor, never a guard condition nothing will ever fire.
11414 ///
11415 /// [`register_guard_condition_on`]: Self::register_guard_condition_on
11416 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
11417 pub fn register_graph_change_guard_on<F>(
11418 &mut self,
11419 node_id: Option<super::node_record::NodeId>,
11420 callback: F,
11421 ) -> Result<(HandleId, GuardCondition), NodeError>
11422 where
11423 F: FnMut() + 'static,
11424 {
11425 // The probe FIRST — see the doc comment. `supports_*` asks by
11426 // clearing, which is idempotent and costs nothing.
11427 if !self.supports_graph_change_callback() {
11428 return Err(NodeError::Transport(TransportError::Unsupported));
11429 }
11430 let (handle_id, guard) = self.register_guard_condition_on(node_id, callback)?;
11431 self.install_graph_change_guard(&guard)?;
11432 Ok((handle_id, guard))
11433 }
11434
11435 /// The nodeless form of
11436 /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on),
11437 /// for an image with a session and no node — the same pairing
11438 /// [`register_guard_condition`](Self::register_guard_condition) has.
11439 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
11440 pub fn register_graph_change_guard<F>(
11441 &mut self,
11442 callback: F,
11443 ) -> Result<(HandleId, GuardCondition), NodeError>
11444 where
11445 F: FnMut() + 'static,
11446 {
11447 self.register_graph_change_guard_on(None, callback)
11448 }
11449
11450 /// The same two verbs on a build with no `rmw-cffi` backend seam, or no
11451 /// allocator — they REFUSE rather than disappear.
11452 ///
11453 /// The cfg'd-out spelling was the first shape and it was wrong in a way
11454 /// `just check fast` cannot see: `rclc_node_get_graph_guard_condition` is
11455 /// a C ABI symbol and calls this unconditionally, so a feature
11456 /// combination without the seam stopped `nros-c` from COMPILING
11457 /// (`check-build`'s `workspace-features`, which is the lane that unifies
11458 /// nothing and therefore sees each combination as a user would).
11459 ///
11460 /// Refusing is also the honest answer rather than a compile fix. The
11461 /// graph-change edge is installed on a SESSION through the backend seam;
11462 /// without that seam there is no backend to install it on, and the
11463 /// executor-owned target needs an allocator for the box the backend holds
11464 /// a pointer to. `Unsupported` is what a caller gets from a backend with
11465 /// no graph too, so the C entry point answers `RCL_RET_UNSUPPORTED` in
11466 /// both cases and a ported program sees one behaviour, not two.
11467 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
11468 pub fn register_graph_change_guard_on<F>(
11469 &mut self,
11470 node_id: Option<super::node_record::NodeId>,
11471 callback: F,
11472 ) -> Result<(HandleId, GuardCondition), NodeError>
11473 where
11474 F: FnMut() + 'static,
11475 {
11476 let _ = (node_id, callback);
11477 Err(NodeError::Transport(TransportError::Unsupported))
11478 }
11479
11480 /// The nodeless form on a build with no backend seam — see
11481 /// [`register_graph_change_guard_on`](Self::register_graph_change_guard_on).
11482 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
11483 pub fn register_graph_change_guard<F>(
11484 &mut self,
11485 callback: F,
11486 ) -> Result<(HandleId, GuardCondition), NodeError>
11487 where
11488 F: FnMut() + 'static,
11489 {
11490 self.register_graph_change_guard_on(None, callback)
11491 }
11492
11493 // ========================================================================
11494 // Timer control methods
11495 // ========================================================================
11496
11497 /// Cancel a timer. A cancelled timer will not fire but still accumulates
11498 /// elapsed time. The timer can be restarted with [`reset_timer()`](Self::reset_timer).
11499 ///
11500 /// phase-509 P9b item 2 -- while the timer's callback runs in another
11501 /// thread's window, the cancel is recorded and applied when it returns.
11502 pub fn cancel_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
11503 if self.defer_timer_op(id, super::types::SlotTag::CANCEL_PENDING)? {
11504 return Ok(());
11505 }
11506 let meta = self
11507 .entries
11508 .get(self.live_slot(id))
11509 .and_then(|e| e.as_ref())
11510 .ok_or(NodeError::BufferTooSmall)?;
11511 if !matches!(meta.kind, EntryKind::Timer) {
11512 return Err(NodeError::BufferTooSmall);
11513 }
11514 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11515 // SAFETY: meta.offset points to a valid TimerEntry<F> which shares
11516 // layout with TimerHeader for its initial fields (both #[repr(C)]).
11517 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
11518 header.cancelled = true;
11519 Ok(())
11520 }
11521
11522 /// Reset a timer. Clears the cancelled state and resets the elapsed time
11523 /// to zero, so the timer starts a fresh period.
11524 ///
11525 /// phase-509 P9b item 2 -- deferred like [`Self::cancel_timer`].
11526 pub fn reset_timer(&mut self, id: HandleId) -> Result<(), NodeError> {
11527 if self.defer_timer_op(id, super::types::SlotTag::RESET_PENDING)? {
11528 return Ok(());
11529 }
11530 let meta = self
11531 .entries
11532 .get(self.live_slot(id))
11533 .and_then(|e| e.as_ref())
11534 .ok_or(NodeError::BufferTooSmall)?;
11535 if !matches!(meta.kind, EntryKind::Timer) {
11536 return Err(NodeError::BufferTooSmall);
11537 }
11538 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11539 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
11540 header.cancelled = false;
11541 header.elapsed_us = 0;
11542 // Issue 1974 — a CLOCK timer advances by `now - last_clock_ns`, and a
11543 // cancelled timer is not polled, so its last reading is from when it
11544 // was cancelled (or, for `autostart = false`, registered). Without a
11545 // re-seed the first poll after this reset would credit the whole
11546 // cancelled interval as one step and fire at once — "a fresh period"
11547 // would hold for wall timers only. `Steady` reads no clock; its seed
11548 // stays 0 and is never read.
11549 header.last_clock_ns = header.clock_source.now_ns();
11550 Ok(())
11551 }
11552
11553 /// phase-509 P9b item 2 -- record a cancel (`op` = `CANCEL_PENDING`) or a
11554 /// reset (`RESET_PENDING`) of the timer `id` when its callback runs in
11555 /// another thread's window, and answer `true`; `false` when it is not,
11556 /// and the caller applies the operation now. The later of the two wins.
11557 fn defer_timer_op(&mut self, id: HandleId, op: u8) -> Result<bool, NodeError> {
11558 let Some(slot) = self.resolve_handle(id) else {
11559 return Ok(false);
11560 };
11561 if !self.running_elsewhere(slot) {
11562 return Ok(false);
11563 }
11564 if !self.entries[slot]
11565 .as_ref()
11566 .is_some_and(|m| matches!(m.kind, EntryKind::Timer))
11567 {
11568 return Err(NodeError::BufferTooSmall);
11569 }
11570 let tag = &self.slot_tags[slot];
11571 tag.clear(super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING);
11572 tag.set(op);
11573 Ok(true)
11574 }
11575
11576 /// phase-509 P9b item 2 -- apply the cancel or reset deferred while the
11577 /// timer in `slot` ran (`defer_timer_op`). Under the lock, its callback
11578 /// returned.
11579 fn apply_deferred_timer_op(&mut self, slot: usize) {
11580 let Some(tag) = self.slot_tags.get(slot) else {
11581 return;
11582 };
11583 let was =
11584 tag.clear(super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING);
11585 if was & (super::types::SlotTag::CANCEL_PENDING | super::types::SlotTag::RESET_PENDING) == 0
11586 {
11587 return;
11588 }
11589 let id = self.handle_for(slot);
11590 if was & super::types::SlotTag::CANCEL_PENDING != 0 {
11591 let _ = self.cancel_timer(id);
11592 } else {
11593 let _ = self.reset_timer(id);
11594 }
11595 }
11596
11597 /// Check if a timer is cancelled.
11598 ///
11599 /// phase-509 P9b item 2 -- a cancel or reset still pending behind the
11600 /// timer's running callback is the answer, as if it had applied.
11601 pub fn timer_is_canceled(&self, id: HandleId) -> bool {
11602 if let Some(slot) = self.resolve_handle(id)
11603 && self.running_elsewhere(slot)
11604 {
11605 let f = self.slot_tags[slot].flags();
11606 if f & super::types::SlotTag::CANCEL_PENDING != 0 {
11607 return true;
11608 }
11609 if f & super::types::SlotTag::RESET_PENDING != 0 {
11610 return false;
11611 }
11612 }
11613 // `resolve_handle`, not `live_slot`: a running callback's dispatch
11614 // never writes `cancelled` (only `cancel_timer` / `reset_timer` do,
11615 // and they are deferred while it runs elsewhere), so the flag is read
11616 // as a plain field, through a raw pointer, with no reference to the
11617 // entry the callback holds.
11618 let meta = match self
11619 .entries
11620 .get(self.resolve_handle(id).unwrap_or(usize::MAX))
11621 .and_then(|e| e.as_ref())
11622 {
11623 Some(m) if matches!(m.kind, EntryKind::Timer) => m,
11624 _ => return false,
11625 };
11626 let arena_ptr = self.arena.as_ptr() as *const u8;
11627 // SAFETY: `meta.offset` is this live entry's slot inside the arena.
11628 let header = unsafe { arena_ptr.add(meta.offset) as *const TimerHeader };
11629 // SAFETY: a Timer entry's arena slot starts with its `TimerHeader`;
11630 // see above for why the read races no write.
11631 unsafe { core::ptr::addr_of!((*header).cancelled).read() }
11632 }
11633
11634 /// Get the period of a timer in milliseconds (truncated — see
11635 /// [`Self::timer_period_us`]), or `None` if the handle is not a
11636 /// valid timer.
11637 pub fn timer_period_ms(&self, id: HandleId) -> Option<u64> {
11638 let meta = self
11639 .entries
11640 .get(self.live_slot(id))
11641 .and_then(|e| e.as_ref())
11642 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11643 let arena_ptr = self.arena.as_ptr() as *const u8;
11644 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11645 Some(header.period_us / 1000)
11646 }
11647
11648 /// Get the period of a timer in microseconds, or `None` if the
11649 /// handle is not a valid timer.
11650 pub fn timer_period_us(&self, id: HandleId) -> Option<u64> {
11651 let meta = self
11652 .entries
11653 .get(self.live_slot(id))
11654 .and_then(|e| e.as_ref())
11655 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11656 let arena_ptr = self.arena.as_ptr() as *const u8;
11657 // SAFETY: same layout invariant as `timer_period_ms`.
11658 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11659 Some(header.period_us)
11660 }
11661
11662 /// Which clock advances this timer, or `None` if the handle is not a valid
11663 /// timer.
11664 ///
11665 /// issue 1975 — the state `rclcpp::TimerBase::get_clock()` reports. The
11666 /// header field is the one `arena::timer_try_process` reads, so the answer
11667 /// is the dispatcher's own rather than a copy a wrapper kept beside it: a
11668 /// wall timer answers [`TimerClockSource::Steady`], a clock timer the
11669 /// source it was registered with.
11670 pub fn timer_clock_source(&self, id: HandleId) -> Option<TimerClockSource> {
11671 let meta = self
11672 .entries
11673 .get(self.live_slot(id))
11674 .and_then(|e| e.as_ref())
11675 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11676 let arena_ptr = self.arena.as_ptr() as *const u8;
11677 // SAFETY: same layout invariant as `timer_period_us`.
11678 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11679 Some(header.clock_source)
11680 }
11681
11682 /// Swap a registered timer's period, in microseconds, returning the one
11683 /// it had — or `None` if the handle is not a valid timer.
11684 ///
11685 /// `TimerHeader::period_us` is the number `arena::timer_try_process`
11686 /// compares `elapsed_us` against, so this writes the period the
11687 /// DISPATCHER honours rather than a second copy of it (RFC-0019). The C
11688 /// entry point `rcl_timer_exchange_period` also keeps
11689 /// `rcl_timer_t::period_ns` in step, because that field is the
11690 /// registration input a later `rclc_executor_add_timer` would re-read.
11691 ///
11692 /// `elapsed_us` is deliberately NOT rewound, which is rcl's own behaviour:
11693 /// `rcl_timer_exchange_period` swaps the period and touches nothing else,
11694 /// so a timer shortened below its accumulated elapsed time becomes ready
11695 /// at once and one lengthened past it simply waits longer. A caller that
11696 /// wants the new period to start fresh calls
11697 /// [`reset_timer`](Self::reset_timer) after it.
11698 ///
11699 /// phase-417 W5.c — the Rust half of `c:timer_exchange_period`, the last
11700 /// timer row the C ledger filed as `gap`. The answer lives here and only
11701 /// the spelling lives in the wrapper, the same split
11702 /// [`timer_elapsed_us`](Self::timer_elapsed_us) made.
11703 pub(crate) fn exchange_timer_period_us(
11704 &mut self,
11705 id: HandleId,
11706 new_period_us: u64,
11707 ) -> Option<u64> {
11708 let offset = self
11709 .entries
11710 .get(self.live_slot(id))
11711 .and_then(|e| e.as_ref())
11712 .filter(|m| matches!(m.kind, EntryKind::Timer))?
11713 .offset;
11714 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11715 // SAFETY: same layout invariant as `timer_period_us`.
11716 let header = unsafe { &mut *(arena_ptr.add(offset) as *mut TimerHeader) };
11717 let old_period_us = header.period_us;
11718 header.period_us = new_period_us;
11719 Some(old_period_us)
11720 }
11721
11722 /// Set a timer's overrun policy (issue #505). Timers default to
11723 /// [`TimerOverrunPolicy::Skip`]; switch to
11724 /// [`TimerOverrunPolicy::CatchUp`] for timers whose every activation
11725 /// is a unit of work that must not be lost.
11726 pub fn set_timer_overrun_policy(
11727 &mut self,
11728 id: HandleId,
11729 policy: TimerOverrunPolicy,
11730 ) -> Result<(), NodeError> {
11731 let meta = self
11732 .entries
11733 .get(self.live_slot(id))
11734 .and_then(|e| e.as_ref())
11735 .ok_or(NodeError::BufferTooSmall)?;
11736 if !matches!(meta.kind, EntryKind::Timer) {
11737 return Err(NodeError::BufferTooSmall);
11738 }
11739 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
11740 // SAFETY: same layout invariant as `cancel_timer`.
11741 let header = unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
11742 header.overrun_policy = policy;
11743 Ok(())
11744 }
11745
11746 /// Periods this timer missed and dropped under
11747 /// [`TimerOverrunPolicy::Skip`] (issue #505), or `None` if the handle
11748 /// is not a valid timer.
11749 ///
11750 /// Monotonic and saturating. A growing count is the on-target signal
11751 /// that a tier is not keeping up with its declared cadence — the
11752 /// symptom an external observer would otherwise have to infer by
11753 /// differencing timestamps.
11754 pub fn timer_overruns(&self, id: HandleId) -> Option<u32> {
11755 let meta = self
11756 .entries
11757 .get(self.live_slot(id))
11758 .and_then(|e| e.as_ref())
11759 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11760 let arena_ptr = self.arena.as_ptr() as *const u8;
11761 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11762 Some(header.overruns)
11763 }
11764
11765 /// Microseconds accumulated since this timer last fired, or `None` if the
11766 /// handle is not a valid timer.
11767 ///
11768 /// This is `TimerHeader::elapsed_us` — the SAME counter
11769 /// `arena::timer_try_process` compares against the period and rewinds on
11770 /// every activation, so it is the executor's own answer rather than a
11771 /// second one derived from a clock. `None` is not "zero elapsed": a caller
11772 /// that cannot tell them apart reports a timer that has just fired and a
11773 /// handle that is not a timer identically.
11774 ///
11775 /// phase-417 W5.c — added because the state was already here and nothing
11776 /// exposed it, which is why `c:timer_get_time_since_last_call` and
11777 /// `c:timer_is_ready` were both filed as gaps. The C forwarders
11778 /// (`rcl_timer_get_time_since_last_call`, `rcl_timer_is_ready`) read
11779 /// this; RFC-0019 puts the answer in Rust and only the spelling in the
11780 /// wrapper.
11781 pub(crate) fn timer_elapsed_us(&self, id: HandleId) -> Option<u64> {
11782 let meta = self
11783 .entries
11784 .get(self.live_slot(id))
11785 .and_then(|e| e.as_ref())
11786 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11787 let arena_ptr = self.arena.as_ptr() as *const u8;
11788 // SAFETY: same layout invariant as `timer_period_us`.
11789 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11790 Some(header.elapsed_us)
11791 }
11792
11793 /// Would this timer fire on the next dispatch pass? `None` if the handle
11794 /// is not a valid timer.
11795 ///
11796 /// The predicate is `arena::timer_try_process`'s own guard, in the same
11797 /// order: a cancelled timer is never ready, a one-shot that already fired
11798 /// is never ready again, and otherwise readiness is `elapsed >= period`.
11799 /// Keeping the two in one shape is the point — a readiness answer that
11800 /// disagrees with the dispatcher is worse than no answer at all.
11801 pub(crate) fn timer_is_ready(&self, id: HandleId) -> Option<bool> {
11802 let meta = self
11803 .entries
11804 .get(self.live_slot(id))
11805 .and_then(|e| e.as_ref())
11806 .filter(|m| matches!(m.kind, EntryKind::Timer))?;
11807 let arena_ptr = self.arena.as_ptr() as *const u8;
11808 // SAFETY: same layout invariant as `timer_period_us`.
11809 let header = unsafe { &*(arena_ptr.add(meta.offset) as *const TimerHeader) };
11810 if header.cancelled || (header.oneshot && header.fired) {
11811 return Some(false);
11812 }
11813 Some(header.elapsed_us >= header.period_us)
11814 }
11815
11816 /// Nanoseconds until this timer next fires — NEGATIVE when it is overdue.
11817 /// `None` if the handle is not a valid timer.
11818 ///
11819 /// phase-417 G6. THE computation, and it has to be: rcl's own pair are
11820 /// `next_call_time` and `next_call_time - now`, so upstream cannot have
11821 /// them disagree either. Before this it lived in `nros-c`
11822 /// (~~`remaining_period_ns`~~ + `time_until_next_call_ns_of`), and the C++
11823 /// half of `cpp:Timer::time_until_trigger` would have been a second
11824 /// derivation of the same subtraction in a second crate — the shape
11825 /// CLAUDE.md's "fix the CLASS, add ONE shared helper rather than a second
11826 /// spelling" rule names. `nros-c` forwards here now; so does `nros-cpp`.
11827 ///
11828 /// **The unit is nanoseconds and the RESOLUTION is microseconds.** The
11829 /// arena's timer accounting is microsecond-based (issue #505), so this is a
11830 /// microsecond quantity scaled by 1000. The unit is rcl's; the resolution
11831 /// is ours.
11832 ///
11833 /// Saturating rather than wrapping: `period_us` and `elapsed_us` are both
11834 /// `u64`, so their difference in nanoseconds can leave `i64` in either
11835 /// direction, and a wrap would turn "overdue by a lot" into "due in a
11836 /// long time" — the sign is the whole answer here.
11837 pub fn timer_time_until_next_call_ns(&self, id: HandleId) -> Option<i64> {
11838 let period_us = self.timer_period_us(id)?;
11839 let elapsed_us = self.timer_elapsed_us(id)?;
11840 let delta_ns = (period_us as i128 - elapsed_us as i128).saturating_mul(1_000);
11841 Some(if delta_ns > i64::MAX as i128 {
11842 i64::MAX
11843 } else if delta_ns < i64::MIN as i128 {
11844 i64::MIN
11845 } else {
11846 delta_ns as i64
11847 })
11848 }
11849
11850 // ========================================================================
11851 // spin_once (three-phase: readiness -> trigger -> dispatch)
11852 // ========================================================================
11853
11854 /// Drive I/O and dispatch registered callbacks once.
11855 ///
11856 /// Three-phase execution:
11857 /// 1. **Readiness scan** — query each handle's `has_data()`.
11858 /// 2. **Trigger evaluation** — check if the executor-level trigger passes.
11859 /// 3. **Dispatch** — invoke callbacks according to their `InvocationMode`.
11860 ///
11861 /// Returns a [`SpinOnceResult`] with counts of processed items and errors.
11862 ///
11863 /// # Arguments
11864 /// * `timeout` — upper bound on the I/O wait. Saturated at
11865 /// `i32::MAX` ms (~24 days) for the underlying transport call.
11866 ///
11867 /// Phase 84.D7: unified on `core::time::Duration`. The previous
11868 /// `timeout_ms: i32` signature had a latent footgun where
11869 /// `spin_once(-1)` silently froze timers while still polling I/O;
11870 /// `Duration` has no negative sentinel.
11871 /// Consecutive `drive_io` failures on the primary session (issue 0324).
11872 ///
11873 /// `0` means the last drive succeeded. A value that keeps climbing across
11874 /// spins is a session that is no longer doing I/O — router gone, lease
11875 /// expired, socket closed — which otherwise presents as a node that spins
11876 /// `Ok(())` forever while publishing nowhere and firing no callbacks.
11877 ///
11878 /// Transient failures happen, so a single non-zero reading is not a fault;
11879 /// a threshold (say, "more than a few consecutive spins") is the useful
11880 /// signal. Extra (bridge / multi-domain) sessions are best-effort and are
11881 /// deliberately NOT counted here.
11882 pub(crate) fn session_io_failures(&self) -> u32 {
11883 self.consecutive_io_failures
11884 }
11885
11886 /// Whether the primary session drove I/O successfully on the last spin.
11887 ///
11888 /// Convenience over [`ExecutorFfi::session_io_failures`](crate::executor::ffi::ExecutorFfi::session_io_failures) for the common
11889 /// "is my transport alive?" check.
11890 pub fn session_io_healthy(&self) -> bool {
11891 self.consecutive_io_failures == 0
11892 }
11893
11894 /// Drive I/O once, then dispatch EVERY callback that is ready — the tick
11895 /// primitive the rest of the family is built from.
11896 ///
11897 /// OURS-ONLY (RFC-0089): rclcpp's `spin_once` executes the NEXT ready item
11898 /// and has no timeout; rclpy's is `spin_once(node, timeout_sec)`. Ours is a
11899 /// blocking wait with a budget, which an RTOS task that must not spin-poll
11900 /// needs and which neither upstream has a verb for. What it DOES when work
11901 /// is ready is rclcpp's `spin_some` — the whole ready set, one pass
11902 /// (RFC-0002 §3) — so [`spin_some`](Self::spin_some) is the drain verb and
11903 /// this is the waiting one.
11904 ///
11905 /// `timeout` is the upper bound on the I/O wait. Since phase-436 W2 it is
11906 /// carried in MICROSECONDS all the way to the park (issue 1193 — an
11907 /// `as_millis()` here truncated `from_micros(500)` to `0`, and a zero
11908 /// timeout selects the non-blocking path, so a sub-millisecond `spin()`
11909 /// was a 100 % CPU loop with no warning); the conversion to whatever the
11910 /// platform primitive accepts happens once, at the park, and rounds UP.
11911 /// `Duration` has no negative sentinel, which is what the pre-phase-84
11912 /// `timeout_ms: i32` signature used to silently mean "freeze the timers
11913 /// and poll I/O forever".
11914 pub fn spin_once(&mut self, timeout: core::time::Duration) -> SpinOnceResult {
11915 let mut discarded = None;
11916 self.spin_once_capturing(timeout, &mut discarded)
11917 }
11918
11919 /// [`spin_once`](Self::spin_once), plus the FIRST failing callback's
11920 /// error.
11921 ///
11922 /// phase-427 — [`SpinOnceResult`] counts errors and drops their values,
11923 /// and `SpinOnceResult` is `Copy`, so widening it to carry a
11924 /// (non-`Copy`, `alloc`-variant-bearing) [`TransportError`] would change a
11925 /// public type every consumer copies. The value is needed in exactly one
11926 /// place — `SpinOptions::stop_on_first_error`, where `spin` has to return
11927 /// the first error rather than a count — so it is threaded out through an
11928 /// out-parameter that lives in the caller's frame. Nothing is added to
11929 /// `Executor`, so a no-alloc image pays nothing for it.
11930 ///
11931 /// `first_error` is written at most once: subsequent failures in the same
11932 /// pass are counted in the result and their values dropped, exactly as
11933 /// before.
11934 fn spin_once_capturing(
11935 &mut self,
11936 timeout: core::time::Duration,
11937 first_error: &mut Option<TransportError>,
11938 ) -> SpinOnceResult {
11939 // SAFETY: `self` is exclusive for the whole call, and an owned pass
11940 // never releases the executor's lock (`PassMode::Owned`), so nothing
11941 // else reaches the executor while it runs.
11942 unsafe { Self::pass(self, PassMode::Owned, timeout, first_error) }
11943 }
11944
11945 /// One pass, over a raw executor pointer (phase-509 P9b item 2, RFC-0108
11946 /// "As built in P9b"). See [`PassMode`]: an owned pass is the
11947 /// `spin_once` every image ran before; a shared pass releases the
11948 /// executor's lock around each user callback, and a band pass runs one
11949 /// worker's band.
11950 ///
11951 /// No `&mut Self` lives across a callback window: `s` is re-derived from
11952 /// `this` after each one, and nothing borrowed from the executor before a
11953 /// window is used after it.
11954 ///
11955 /// # Safety
11956 /// `this` points at a live executor. For `PassMode::Owned`, nothing else
11957 /// reaches it for the call. Otherwise the executor's lock is installed and
11958 /// held exactly once by the calling thread, and every other thread that
11959 /// reaches the executor takes the lock first.
11960 unsafe fn pass(
11961 this: *mut Self,
11962 mut mode: PassMode<'_>,
11963 timeout: core::time::Duration,
11964 first_error: &mut Option<TransportError>,
11965 ) -> SpinOnceResult {
11966 // SAFETY: the caller's contract; re-derived after every window.
11967 #[allow(unused_mut)]
11968 let mut s: &mut Self = unsafe { &mut *this };
11969 // Issue 1705 — releases deferred behind a dispatch that has since
11970 // finished are completed here, before this pass looks at the entries.
11971 s.reap_deferred_releases();
11972 // phase-509 P9b item 2 -- the lock this pass releases around each
11973 // user callback (and, item 3, around its park): only a shared or band
11974 // pass, and only when the lock can tell the callback's thread from
11975 // the others. `None` -- an owned pass, or no lock -- runs the whole
11976 // pass under the caller's hold.
11977 let window_lock = match mode {
11978 PassMode::Owned => None,
11979 _ => s.exec_lock.filter(|l| l.opens_windows()),
11980 };
11981 // phase-509 P9b item 2 -- the owned and shared passes run the
11982 // executor's own worker's prelude (sim time, services, jitter, the
11983 // park, the transport drive); a band pass runs its own clock and
11984 // (item 3) its own park.
11985 //
11986 // SAFETY: the caller's contract is the preludes'; `s` is not used
11987 // across the call and is derived again after it.
11988 let (now_us_this_spin, delta_us) = match &mut mode {
11989 PassMode::Band(w) => unsafe { Self::band_prelude(this, window_lock, w, timeout) },
11990 _ => unsafe { Self::owned_prelude(this, window_lock, timeout) },
11991 };
11992 // SAFETY: the prelude returns holding the lock again.
11993 s = unsafe { &mut *this };
11994 // phase-521 W8 (c) -- the park slot yielded to its framework's
11995 // scheduler: nothing is due (the bound was not zero) and nothing is
11996 // pending, so there is nothing to scan. The tier runs again when its
11997 // work item is scheduled.
11998 if core::mem::take(&mut s.worker.park_yielded) {
11999 return SpinOnceResult::new();
12000 }
12001 // phase-509 P9b item 2 -- this pass's band (every level for the
12002 // one-level worker) and its slots. The scan, the sweep and the drain
12003 // see only these, so a worker never takes another band's work.
12004 let (level, band) = match &mode {
12005 PassMode::Band(w) => (w.level, w.band),
12006 _ => (s.worker.level, s.worker.band),
12007 };
12008 let band_mask = s.band_slots(level, band);
12009 let is_band_pass = matches!(mode, PassMode::Band(_));
12010 let window_thread = window_lock.and_then(|l| l.current_thread()).unwrap_or(0);
12011
12012 let arena_ptr = s.arena.as_mut_ptr() as *mut u8;
12013
12014 // Phase 1: Readiness scan (Phase 110.A.b — backed by FifoReadySet).
12015 //
12016 // `bits` carries data-readiness only (used by trigger eval +
12017 // by `InvocationMode::OnNewData`). `always_mask` carries the
12018 // `InvocationMode::Always` entries that fire regardless of
12019 // data presence. The dispatcher drains `bits | always_mask`
12020 // after the trigger passes. phase-509 P2 -- in dispatch-level
12021 // order (the sched context's bucket, and inside `Normal` the
12022 // node's rank), registration order (lowest bit first) among
12023 // equals, so an image whose nodes hold one rank is bit-identical
12024 // to the pre-refactor `for (i, meta) in entries.iter().enumerate()`
12025 // loop.
12026 let mut bits: u64 = 0;
12027 let mut count: usize = 0;
12028 let mut non_timer_mask: u64 = 0;
12029 // Issue 2162 -- the guard conditions. A ready guard passes
12030 // `Trigger::Any` like a ready subscription does (rclc's
12031 // `rclc_executor_trigger_any` counts every handle), but an IDLE guard
12032 // never holds `Any` or `All` back, so it stays out of `non_timer_mask`.
12033 let mut guard_mask: u64 = 0;
12034 let mut always_mask: u64 = 0;
12035
12036 // phase-397 W3 (RFC-0084) — an ATTRIBUTED slot's readiness is pushed,
12037 // not probed. Its arrivals set its bit in the ready word (W2), so the
12038 // bit is read and `has_data` is not called. Three cases still ask:
12039 //
12040 // - a slot in `entity_recheck`, which may hold data no arrival will
12041 // announce again (see the field);
12042 // - a subscription with a status-event callback (issue 2060,
12043 // `entity_status_polled`), whose backend polls those events there;
12044 // - a slot whose backend cannot attribute (XRCE, uORB, a timer, a
12045 // guard condition), exactly as before.
12046 //
12047 // The bit means the data ARRIVED, not that it is still PRESENT: an
12048 // arrival that races a drain may dispatch to an empty queue, and every
12049 // `try_process` already answers `Ok(false)` to that. A bit cleared
12050 // before its dispatch would lose a message; one that over-reports
12051 // costs one empty call.
12052 //
12053 // The word is taken AFTER the wait and the drive above, so it holds
12054 // every arrival that ended this spin's park, including those a
12055 // single-threaded backend delivered inside `drive_io`. One that lands
12056 // after the take stays in the word and has signalled the wake, so the
12057 // next spin does not park on it.
12058 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
12059 let (attributed, arrived, recheck) = {
12060 let attributed = s.entity_attributed;
12061 // phase-509 P9b item 2 -- only this band's arrivals: another
12062 // band's bits stay in the word for its worker.
12063 let arrived = s
12064 .wake_ctx
12065 .as_ref()
12066 .map_or(0, |ctx| take_band_bits(&ctx.ready, band_mask));
12067 let recheck = s.entity_recheck & band_mask;
12068 s.entity_recheck &= !band_mask;
12069 // Issue 2060 — a slot whose status events are polled from
12070 // `has_data` is asked every pass, like a recheck that never
12071 // drains. It is folded in AFTER the clear, so it never leaks into
12072 // `entity_recheck` and leaves the moment its entry is released.
12073 let recheck = recheck | (s.entity_status_polled & band_mask);
12074 (attributed, arrived & attributed, recheck & attributed)
12075 };
12076 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
12077 let (attributed, arrived, recheck) = (0u64, 0u64, 0u64);
12078 // Attributed slots the scan could not settle (held mid-dispatch).
12079 let mut carry: u64 = 0;
12080 let mut probes: u32 = 0;
12081
12082 for (i, meta) in s.entries.iter().enumerate() {
12083 let Some(meta) = meta else { continue };
12084 let bit = 1u64 << i;
12085 if band_mask & bit == 0 {
12086 continue;
12087 }
12088 if Self::slot_held(s.slot_tags, i) {
12089 carry |= (arrived | recheck) & bit;
12090 continue;
12091 }
12092 {
12093 let pushed = attributed & bit != 0;
12094 let ready = if pushed && arrived & bit != 0 {
12095 true
12096 } else if pushed && recheck & bit == 0 {
12097 false
12098 } else {
12099 probes = probes.wrapping_add(1);
12100 let data_ptr = unsafe { arena_ptr.add(meta.offset) as *const u8 };
12101 unsafe { (meta.has_data)(data_ptr) }
12102 };
12103 if ready {
12104 bits |= bit;
12105 }
12106 match meta.kind {
12107 EntryKind::Timer => {}
12108 EntryKind::GuardCondition => guard_mask |= bit,
12109 _ => non_timer_mask |= bit,
12110 }
12111 if matches!(meta.invocation, InvocationMode::Always) {
12112 always_mask |= 1u64 << i;
12113 }
12114 count += 1;
12115 }
12116 }
12117 // Issue 2146 -- a DEFERRED slot that is ready is queued for the
12118 // host's drain (and its notifier called), never dispatched here, and
12119 // takes no part in the trigger. Taken out before the recheck below:
12120 // the drain asks for the recheck when it runs the callback.
12121 if s.deferred_mask != 0 {
12122 bits = s.defer_ready(bits);
12123 non_timer_mask &= !s.deferred_mask;
12124 always_mask &= !s.deferred_mask;
12125 }
12126 // Every attributed slot that was ready is asked again next spin: a
12127 // dispatch takes what one call takes, and a trigger that does not
12128 // pass dispatches nothing, so either may leave data behind.
12129 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
12130 {
12131 s.entity_recheck |= carry | (bits & attributed);
12132 s.readiness_probes = s.readiness_probes.wrapping_add(probes);
12133 }
12134 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
12135 let _ = (carry, probes);
12136
12137 // phase-462 W5b — an armed deadline row's arrival evidence.
12138 if bits != 0 && !s.age_table.is_empty() {
12139 super::monitor::credit_arrivals(s.age_table, bits);
12140 }
12141
12142 let snapshot = ReadinessSnapshot { bits, count };
12143
12144 // Phase 2: Trigger evaluation
12145 let trigger_passes = match &s.trigger {
12146 // Issue 2162 -- a triggered guard beside an idle subscription
12147 // waited for unrelated traffic (possibly forever) while `Any`
12148 // read only `non_timer_mask`. The `non_timer_mask == 0` arm is
12149 // unchanged: a timer/guard-only executor still passes every spin.
12150 Trigger::Any => bits & (non_timer_mask | guard_mask) != 0 || non_timer_mask == 0,
12151 Trigger::All => bits & non_timer_mask == non_timer_mask,
12152 Trigger::One(id) => snapshot.is_ready(*id),
12153 Trigger::AllOf(set) => snapshot.all_ready(*set),
12154 Trigger::AnyOf(set) => snapshot.any_ready(*set),
12155 Trigger::Always => true,
12156 Trigger::Predicate(f) => f(&snapshot),
12157 Trigger::RawPredicate { callback, context } => {
12158 // Convert ReadinessSnapshot bitmask to a bool array for the C callback
12159 let mut ready_array = [false; 64];
12160 for (i, slot) in ready_array
12161 .iter_mut()
12162 .enumerate()
12163 .take(snapshot.count.min(64))
12164 {
12165 *slot = snapshot.bits & (1u64 << i) != 0;
12166 }
12167 // SAFETY: The callback and context are provided by the C API caller.
12168 // The ready_array is valid for snapshot.count elements.
12169 unsafe { callback(ready_array.as_ptr(), snapshot.count, *context) }
12170 }
12171 };
12172
12173 if !trigger_passes {
12174 // Timers still need delta accumulation even when trigger doesn't pass
12175 //
12176 // phase-8 — `.enumerate()` (rather than a bare `.flatten()`) because
12177 // `try_process` now carries the entry's slot index for the callback
12178 // trace hooks. This sweep FIRES timer callbacks, so it is a real
12179 // dispatch path and must attribute them like the drain below does.
12180 //
12181 // phase-474 I6 -- and it must TIME them like the drain does. This
12182 // is the path every timer takes on a node that also subscribes
12183 // (the default `Trigger::Any` fails whenever no subscription has
12184 // a sample), and it measured nothing, so `max-latency-runtime`
12185 // never judged a timer path: the safety island's 250 ms overrun of
12186 // a 206 ms path stored no verdict. One clock read before and one
12187 // after each timer's `try_process`, only when a latency contract
12188 // exists and a clock is injected; the publish-count snapshot is a
12189 // fixed array on the stack, nothing allocates. The measured span
12190 // is callback entry to exit, as in the drains; release jitter is
12191 // `release-jitter-runtime`'s.
12192 let mon_table = s.monitor_table;
12193 // phase10-B3 -- the route rule reads the same clock and snapshot:
12194 // a timer tick is where a sampled route's output is published.
12195 let lat_clock = s.clock_us_fn.filter(|_| {
12196 mon_table.iter().any(|m| m.max_latency_ms > 0)
12197 || super::monitor::route_active(mon_table)
12198 });
12199 for i in 0..s.entries.len() {
12200 let Some(meta) = s.entries[i].as_ref() else {
12201 continue;
12202 };
12203 if matches!(meta.kind, EntryKind::Timer)
12204 && band_mask & (1u64 << i) != 0
12205 && !Self::slot_held(s.slot_tags, i)
12206 {
12207 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
12208 let try_process = meta.try_process;
12209 let counts_before = snapshot_pub_counts(mon_table, lat_clock.is_some());
12210 let start_us = lat_clock.map(|c| c());
12211 Self::begin_dispatch(s.slot_tags, i);
12212 // phase-509 P9b item 2 -- the callback window: see the
12213 // drain below.
12214 let mut rec = super::exec_lock::Running {
12215 slot: i,
12216 thread: window_thread,
12217 next: core::ptr::null_mut(),
12218 };
12219 let window = window_lock.map(|l| {
12220 // SAFETY: `rec` outlives the window and is unlinked
12221 // under the lock right after it.
12222 unsafe { s.link_running(&mut rec) };
12223 super::exec_lock::Window::open(l)
12224 });
12225 // phase-463 W7 -- the host profile times this sweep too:
12226 // it is the path every timer of a node that also
12227 // subscribes takes (see above), so missing it measured
12228 // the talker's timer at zero invocations.
12229 //
12230 // The OFF arm is the original `let _ =`, untouched, and
12231 // that is MEASURED rather than tidy: every binding tried
12232 // in its place (`let _p`, `let p; drop(p)`) moved
12233 // `spin_once_capturing` by 49-64 B in the Zephyr image
12234 // (phase-463 W5 I3c). Hence the second `unsafe` block,
12235 // recorded in the unsafe census.
12236 #[cfg(not(feature = "profile-mode"))]
12237 let _ = unsafe { try_process(data_ptr, delta_us, i as u8) };
12238 #[cfg(feature = "profile-mode")]
12239 {
12240 super::profile::dispatch_begin(arena_ptr as usize, i);
12241 let processed = unsafe { try_process(data_ptr, delta_us, i as u8) };
12242 super::profile::dispatch_end(matches!(processed, Ok(true)));
12243 }
12244 // Read before the lock is taken back, so a wait for it
12245 // is not counted as the callback's latency.
12246 let swept_end_us = start_us.and(lat_clock).map(|c| c());
12247 if let Some(w) = window {
12248 drop(w);
12249 // SAFETY: the lock is held again; nothing borrowed
12250 // from the executor before the window is used after.
12251 s = unsafe { &mut *this };
12252 s.unlink_running(&mut rec);
12253 }
12254 if let (Some(end_us), Some(t0)) = (swept_end_us, start_us) {
12255 let elapsed_us = end_us.saturating_sub(t0).min(u32::MAX as u64) as u32;
12256 attribute_latency(mon_table, true, &counts_before, elapsed_us);
12257 let hook = s.reaction_hook.filter(|_| s.violations.counts.armed);
12258 let violations = &mut s.violations;
12259 super::monitor::route_judge(
12260 mon_table,
12261 &counts_before,
12262 end_us,
12263 |row, v, record, react| {
12264 if react {
12265 call_route_reaction(hook, mon_table, row, &v, end_us);
12266 }
12267 if record {
12268 violations.record(v)
12269 }
12270 },
12271 );
12272 }
12273 s.finish_dispatch(i);
12274 }
12275 }
12276
12277 // phase-509 P9b item 2 -- the executor-wide chores below are the
12278 // executor's own worker's. Issue 2020 -- the band's own
12279 // components' ticks are the band's.
12280 if is_band_pass {
12281 // SAFETY: the pass's contract; `s` is not used after it.
12282 unsafe { Self::tick_components(this, Some(level)) };
12283 return SpinOnceResult::new();
12284 }
12285
12286 // Parameter services live outside the arena and must be processed
12287 // regardless of trigger state, otherwise ROS 2 param queries time out.
12288 #[cfg(feature = "param-store")]
12289 {
12290 #[allow(unused_mut)] // only the services add to it
12291 let mut handled = 0usize;
12292 #[cfg(feature = "param-services")]
12293 if let Some(params) = &mut s.params {
12294 {
12295 let crate::param_store::ParamState {
12296 server,
12297 services,
12298 buffers,
12299 staged,
12300 ..
12301 } = &mut **params;
12302 // phase-426 W3 — one set per node; every set is pumped,
12303 // because `ros2 param list` addresses each node's own
12304 // six and a set nobody polls times out.
12305 //
12306 // Issue 1270 -- all of them through ONE buffer pair.
12307 // Issue 1271 -- a request that got no reply is logged
12308 // inside, once per service; there is no error to drop.
12309 if let Some(buffers) = buffers {
12310 for set in services.iter_mut() {
12311 handled += set.process(server, staged, buffers);
12312 }
12313 }
12314 }
12315 }
12316 // phase-425 W3b — a `ros2 param set … use_sim_time true` arrives
12317 // through this path.
12318 s.note_param_services_ran(handled);
12319 }
12320
12321 // Same treatment for lifecycle services — `ros2 lifecycle get`
12322 // must succeed even when no callbacks fired this tick.
12323 //
12324 // Issue 1587 — `process` also drains the state machine's
12325 // announcement queue onto `~/transition_event`, so a transition
12326 // triggered from application code between two spins (autostart is
12327 // the common one) reaches a watching supervisor here.
12328 // SAFETY: see the matching invariant on the later call site.
12329 #[cfg(feature = "lifecycle-services")]
12330 if let Some(lc) = &mut s.lifecycle {
12331 let _ = unsafe { lc.process() };
12332 }
12333
12334 // phase-474 I7 -- an idle spin is the natural slot for the queued
12335 // reports.
12336 s.flush_violation_reports();
12337
12338 return SpinOnceResult::new();
12339 }
12340
12341 // Phase 2.5: LET pre-sample (only when LogicalExecutionTime)
12342 //
12343 // Sample all subscription data into entry buffers BEFORE dispatching
12344 // any callbacks. This ensures all callbacks in this cycle see a
12345 // consistent snapshot of data from the same point in time.
12346 // Services are NOT pre-sampled (request-reply is sequential).
12347 if matches!(s.semantics, ExecutorSemantics::LogicalExecutionTime) {
12348 for (i, meta) in s.entries.iter().enumerate() {
12349 let Some(meta) = meta else { continue };
12350 if matches!(meta.kind, EntryKind::Subscription)
12351 && band_mask & (1u64 << i) != 0
12352 && !Self::slot_held(s.slot_tags, i)
12353 {
12354 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
12355 unsafe { (meta.pre_sample)(data_ptr) };
12356 }
12357 }
12358 }
12359
12360 // Phase 3: Dispatch (Phase 110.C — bucketed by SC.priority).
12361 //
12362 // Two ready-set families, each split across `Priority::COUNT`
12363 // buckets (Critical / Normal / BestEffort). Per-entry SC
12364 // `class` selects FIFO bitmap vs EDF heap; SC `priority`
12365 // selects the bucket within. Drain order:
12366 // for each bucket in priority order (Critical first):
12367 // drain EDF heap (deadline-priority), then FIFO bitmap
12368 // (registration-order)
12369 // Default workloads — every entry on the auto-default Fifo SC
12370 // (Normal priority) — populate only `fifo[Normal]`, so
12371 // dispatch order is bit-identical to 110.B.b for those.
12372 const NB: usize = super::sched_context::Priority::COUNT;
12373 let mut result = SpinOnceResult::new();
12374 // phase-509 P2 (RFC-0108 D2) -- the FIFO half is ordered by dispatch
12375 // level: a bucket's reserved level, or `1 + rank` inside `Normal`, the
12376 // rank being the one the entry's node was seeded with. Equal levels
12377 // keep registration order, so an image with one rank is unchanged.
12378 let mut fifo: super::ready_set::RankedFifoSet<{ MAX_CALLBACK_SLOTS }> =
12379 super::ready_set::RankedFifoSet::new();
12380 let mut edf: super::ready_set::BucketedEdfSet<NB, { MAX_CALLBACK_SLOTS }> =
12381 super::ready_set::BucketedEdfSet::new();
12382 let active_mask = bits | always_mask;
12383
12384 // Phase 110.E — refill any Sporadic SC budgets at period
12385 // boundaries before deciding what to dispatch this cycle.
12386 // Refill is polled (not ISR-driven) — coarse but correct
12387 // upper-bound bandwidth limiter.
12388 // phase-359 W4 — was `#[cfg(feature = "std")]` with NO no_std arm, so
12389 // polled Sporadic budgets never refilled on embedded: they exhausted
12390 // once and stayed exhausted. That was a consequence of "no clock on
12391 // no_std", which is false whenever a `clock_us` hook is injected. Now it
12392 // runs on either flavour when a clock exists, and is skipped when none
12393 // does — identical to today's behaviour in that case.
12394 if let Some(now_us) = now_us_this_spin {
12395 let now_ms = now_us / 1000;
12396 // issue 0736 — REFILL only. This used to also charge the cycle's
12397 // `delta_us` as the per-SC consumption estimate ("worst-case
12398 // attribution", pending a higher-precision clock hook). That hook
12399 // landed, and the attribution was not merely coarse: `delta_us` is
12400 // the wall-clock gap between spins, not CPU the callbacks spent, so
12401 // wherever the gap exceeds the budget the SC is exhausted on every
12402 // spin regardless of what it ran. Consumption is charged from
12403 // measured callback runtime in `consume_dispatch_runtime_us`.
12404 for slot in s.sporadic_states.iter_mut().flatten() {
12405 let _ = slot.refill(now_ms);
12406 }
12407 }
12408
12409 for i in 0..s.entries.len() {
12410 if active_mask & (1u64 << i) == 0 {
12411 continue;
12412 }
12413 let sc_idx = s.sched_context_bindings[i].0 as usize;
12414 let sc_class_priority_deadline = s
12415 .sched_contexts
12416 .get(sc_idx)
12417 .and_then(|s| s.as_ref())
12418 .map(|sc| {
12419 (
12420 sc.class,
12421 sc.priority.index(),
12422 sc.deadline_us.get().map(|nz| nz.get()).unwrap_or(u32::MAX),
12423 )
12424 });
12425 let (sc_class, bucket, deadline_us) = sc_class_priority_deadline.unwrap_or((
12426 super::sched_context::SchedClass::Fifo,
12427 super::sched_context::Priority::Normal.index(),
12428 u32::MAX,
12429 ));
12430 // Phase 110.E — Sporadic SC dispatch is suppressed when
12431 // its budget is exhausted. Atomic path (110.E.b PlatformTimer
12432 // refill) takes precedence when registered; polled path
12433 // (cycle-level delta_us attribution) handles the unregistered
12434 // case. Either way, exhausted budget skips dispatch.
12435 if matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
12436 #[cfg(feature = "alloc")]
12437 let atomic_has_budget = s
12438 .sporadic_atomic_states
12439 .get(sc_idx)
12440 .and_then(|s| s.as_ref())
12441 .map(|(state, _)| state.has_budget());
12442 #[cfg(not(feature = "alloc"))]
12443 let atomic_has_budget: Option<bool> = None;
12444 let has_budget = match atomic_has_budget {
12445 Some(b) => b,
12446 None => s
12447 .sporadic_states
12448 .get(sc_idx)
12449 .and_then(|s| s.as_ref())
12450 .map(|s| s.budget_remaining_us > 0)
12451 .unwrap_or(true),
12452 };
12453 if !has_budget {
12454 // issue 0736 — keep the TIMER'S CLOCK honest even while the
12455 // budget gates its dispatch.
12456 //
12457 // A timer's `elapsed_us` only advances inside
12458 // `timer_try_process`, which is reached only when the entry
12459 // is DISPATCHED, and each dispatch is handed just THIS
12460 // cycle's `delta_us`. So every cycle skipped here was
12461 // dropped from the timer's sense of time: it did not fire,
12462 // it did not learn that its period had passed, and — because
12463 // the overrun counter is driven by that same `elapsed_us` —
12464 // it did not count the activation it missed.
12465 //
12466 // That is why the throttle was invisible. Measured on
12467 // nuttx-arm/rust: ~238 activations lost against ~40
12468 // reported, so five in six missed firings were unaccounted
12469 // ANYWHERE. The tier ran at a quarter of its declared rate
12470 // and the only honest signal was the delivery count itself.
12471 //
12472 // Advancing the clock here does not dispatch anything and
12473 // does not defeat the budget. It makes the entry's own
12474 // accounting independent of whether the executor CHOSE to
12475 // run it, which is the property every "did this meet its
12476 // declaration?" question needs, and it lets the existing
12477 // `Skip` policy count the backlog and the existing
12478 // `timer-overrun-runtime` rule report it.
12479 if let Some(meta) = s.entries[i].as_ref()
12480 && matches!(meta.kind, EntryKind::Timer)
12481 {
12482 // SAFETY: a Timer entry's arena slot holds a
12483 // `TimerEntry<F>`, whose leading layout IS
12484 // `TimerHeader` — the same cast the overrun reporter
12485 // one screen up already relies on.
12486 let header =
12487 unsafe { &mut *(arena_ptr.add(meta.offset) as *mut TimerHeader) };
12488 // Issue 1321 — on the TIMER'S clock, not the
12489 // executor's. This line read
12490 // `header.elapsed_us += delta_us`, which is wall
12491 // microseconds: for a `Ros` timer that advanced a
12492 // simulated-time counter by wall time, so a paused
12493 // `/clock` no longer paused the timer — the one
12494 // property a ROS-time timer has. Unbounded, unlike
12495 // 1321's park bound, because it can fire a callback
12496 // that no clock step earned.
12497 super::arena::timer_advance_without_dispatch(header, delta_us);
12498 }
12499 // issue 0736 — a budget skip used to be a bare `continue`,
12500 // which is the silent-drop shape 0737 gated one layer out:
12501 // the entry is simply not dispatched and nothing anywhere
12502 // says so, so a permanently starved tier is
12503 // indistinguishable from an idle one. It took a hand-run
12504 // 45 s image and a per-executor probe to learn that this
12505 // line was skipping 96 % of one tier's spins.
12506 // The rolling window catches a budget that THROTTLES; the
12507 // streak below catches one that starves outright. issue
12508 // 0736 needed both: the measured case skipped ~3 spins in
12509 // 4 without ever reaching a streak, so the tier ran at a
12510 // quarter of its declared rate in silence.
12511 if let Some((skips, total)) = s
12512 .sporadic_states
12513 .get_mut(sc_idx)
12514 .and_then(|st| st.as_mut())
12515 .and_then(|st| st.take_budget_window())
12516 {
12517 nros_log::log_warn!(
12518 nros_log::get_logger("nros"),
12519 "sporadic budget throttled sched context {}: {} of the last {} dispatch opportunities were skipped for want of budget. The declared budget_us/period_us cannot sustain this tier's callbacks on this target.",
12520 sc_idx,
12521 skips,
12522 total
12523 );
12524 }
12525 if let Some(streak) = s
12526 .sporadic_states
12527 .get_mut(sc_idx)
12528 .and_then(|st| st.as_mut())
12529 .and_then(|st| st.note_budget_skip())
12530 {
12531 nros_log::log_warn!(
12532 nros_log::get_logger("nros"),
12533 "sporadic budget exhausted for {} consecutive spins (sched context {}): its callbacks are not being dispatched. Either the callback runtime exceeds the declared budget_us per period_us, or the declaration is unsatisfiable on this target.",
12534 streak,
12535 sc_idx
12536 );
12537 }
12538 continue;
12539 }
12540 // Phase 110.E.b follow-up — per-callback runtime
12541 // accounting (replaces this cycle-level attribution)
12542 // is applied at dispatch time below via
12543 // `consume_dispatch_runtime_us`. We only update the
12544 // polled-path `SporadicState` (no_std fallback) here
12545 // because the atomic path now records actual
12546 // wall-clock per-callback runtime. The
12547 // `delta_us` over-attribution that previously hit the
12548 // atomic state was a worst-case bandwidth limiter;
12549 // per-callback measurement is strictly tighter.
12550 #[cfg(not(feature = "alloc"))]
12551 {
12552 let _ = sc_idx; // polled-state path lives in
12553 // `sporadic_states`; this branch is a no-op when
12554 // the atomic path is enabled.
12555 }
12556 }
12557 // Phase 110.G — TT window gate, orthogonal to class.
12558 // Skips dispatch when the SC has a TT window AND the
12559 // current monotonic time is outside it. Both gates apply
12560 // independently — a Sporadic SC with a TT window must
12561 // pass both.
12562 if s.major_frame_us > 0 {
12563 let sc_opt = s.sched_contexts.get(sc_idx).and_then(|s| s.as_ref());
12564 if let Some(sc) = sc_opt {
12565 let off = sc.tt_window_offset_us.get().map(|nz| nz.get()).unwrap_or(0);
12566 let dur = sc
12567 .tt_window_duration_us
12568 .get()
12569 .map(|nz| nz.get())
12570 .unwrap_or(0);
12571 if dur > 0 {
12572 // Compute current phase within the major
12573 // frame using the accumulated `delta_us` clock
12574 // (std-only precise; no_std uses `delta_us`
12575 // approximation from spin cadence).
12576 // phase-359 W4 — the shared read. The no_std arm used
12577 // to be `now_us = delta_us`, i.e. a per-spin INTERVAL
12578 // used as an absolute phase clock, which the comment
12579 // above called an approximation. It is now the real
12580 // clock when one is injected, and falls back to the old
12581 // approximation only when none is.
12582 let now_us = now_us_this_spin.unwrap_or(delta_us);
12583 let phase = (now_us % s.major_frame_us as u64) as u32;
12584 let in_window = if off + dur <= s.major_frame_us {
12585 phase >= off && phase < off + dur
12586 } else {
12587 // Window wraps the major frame boundary.
12588 let end = (off as u64 + dur as u64) % s.major_frame_us as u64;
12589 phase >= off || (phase as u64) < end
12590 };
12591 if !in_window {
12592 continue;
12593 }
12594 }
12595 }
12596 }
12597 let is_edf = matches!(sc_class, super::sched_context::SchedClass::Edf);
12598 let job = super::types::ActiveJob {
12599 sort_key: if is_edf { deadline_us } else { i as u32 },
12600 desc_idx: i as super::types::DescIdx,
12601 };
12602 if is_edf {
12603 let _ = edf.insert_into(bucket, job);
12604 } else {
12605 let rank = s.slot_tags.get(i).map_or(0, |t| t.rank);
12606 let level = super::sched_context::Priority::from_index(bucket).dispatch_level(rank);
12607 // phase-509 P6 / P9b item 2 -- the scan saw only this pass's
12608 // band, so nothing it queued is another band's.
12609 debug_assert!(
12610 s.worker.registering_level != super::scheduler::NO_OWNING_LEVEL
12611 || band.contains(level)
12612 );
12613 let _ = fifo.insert_at(level, job);
12614 }
12615 }
12616 // phase-509 P3 -- every slot this pass has already settled: queued
12617 // above, or held back by a gate (budget, window, worker). A slot in
12618 // it never joins the pass again, so each entry runs at most once per
12619 // pass (a timer's clock advances by one `delta_us` per pass, and a
12620 // callback that readies itself cannot hold the pass forever).
12621 let mut settled: u64 = active_mask;
12622 // phase-509 P3 -- the LET semantics dispatch the pass's snapshot,
12623 // sampled before any callback ran; nothing joins it mid-pass.
12624 // A trigger narrower than "any" (`One`, `AllOf`, a predicate) chose
12625 // this pass's work from the scan; it stays the scan's.
12626 let repick = !matches!(s.semantics, ExecutorSemantics::LogicalExecutionTime)
12627 && matches!(s.trigger, Trigger::Any | Trigger::Always);
12628
12629 // SAFETY: each `desc_idx` we pop was set above only when the
12630 // corresponding `entries[i]` slot was `Some`; no Executor
12631 // mutation happens between that scan and this dispatch.
12632 let mut dispatch_one = |meta: &CallbackMeta,
12633 desc_idx: usize,
12634 arena_ptr: *mut u8,
12635 delta_us: u64,
12636 result: &mut SpinOnceResult| {
12637 // Phase 141.B.2 — capture T1 at subscription dispatch
12638 // entry. Probe pairs it with the most recent T0 from
12639 // `nros_rmw_runtime_wake_cb` (std + alloc variants)
12640 // and pushes T1 - T0 onto the ring buffer 141.C
12641 // drains. No-op when the probe feature is off or
12642 // no cycle reader is installed. Other entry kinds
12643 // (Service / Timer / GuardCondition) skip the probe
12644 // because the 141 acceptance is specifically
12645 // wake-to-subscription-dispatch latency.
12646 #[cfg(feature = "wake-latency-probe")]
12647 if matches!(meta.kind, EntryKind::Subscription) {
12648 super::wake_probe::on_dispatch();
12649 }
12650 let data_ptr = unsafe { arena_ptr.add(meta.offset) };
12651 // phase-8 — `desc_idx` is the entry slot index, threaded through so
12652 // the leaf hooks in `arena.rs` can name the callback they bracket.
12653 // `MAX_CALLBACK_SLOTS` is 64 (enforced by the `u64` ready-set
12654 // bitmask), so the `as u8` cannot truncate a live slot.
12655 // phase-463 W7 — time the dispatch and attribute its publishes
12656 // (host `profile-mode` only). ONE call site for both builds, so no
12657 // second `unsafe` block; the binding is the only change an RTOS
12658 // image sees, and its loadable bytes measured identical (phase-463
12659 // W5 I3c, Zephyr cpp/talker).
12660 #[cfg(feature = "profile-mode")]
12661 super::profile::dispatch_begin(arena_ptr as usize, desc_idx);
12662 let processed = unsafe { (meta.try_process)(data_ptr, delta_us, desc_idx as u8) };
12663 #[cfg(feature = "profile-mode")]
12664 super::profile::dispatch_end(matches!(processed, Ok(true)));
12665 match processed {
12666 Ok(true) => match meta.kind {
12667 EntryKind::Subscription => result.subscriptions_processed += 1,
12668 EntryKind::Service
12669 | EntryKind::ServiceClient
12670 | EntryKind::ActionServer
12671 | EntryKind::ActionClient => result.services_handled += 1,
12672 EntryKind::Timer => result.timers_fired += 1,
12673 EntryKind::GuardCondition => {}
12674 },
12675 Ok(false) => {}
12676 Err(e) => {
12677 // phase-427 — the first one is kept, the rest are counted.
12678 // `stop_on_first_error` is what reads it; when nobody
12679 // asked, the `Option` is a local the optimiser drops.
12680 if first_error.is_none() {
12681 *first_error = Some(e);
12682 }
12683 match meta.kind {
12684 EntryKind::Subscription => result.subscription_errors += 1,
12685 EntryKind::Service
12686 | EntryKind::ServiceClient
12687 | EntryKind::ActionServer
12688 | EntryKind::ActionClient => result.service_errors += 1,
12689 EntryKind::Timer | EntryKind::GuardCondition => {}
12690 }
12691 }
12692 }
12693 };
12694
12695 // Phase 110.E.b follow-up — per-callback runtime accounting
12696 // for Sporadic SCs. Wall-clock-measure each dispatch and
12697 // consume the elapsed microseconds from the bound SC's
12698 // atomic budget. This replaces the cycle-level over-
12699 // attribution that previously charged the FULL `delta_us`
12700 // against every Sporadic SC regardless of which entries
12701 // actually fired — accurate per-callback measurement is the
12702 // shape the design doc's per-callback runtime acceptance
12703 // calls out.
12704 //
12705 // phase-359 W10 — this was `feature = "std"`-gated, because it "needs a
12706 // `core::time::Instant`-equivalent monotonic clock ... until a
12707 // board-side monotonic-microsecond accessor lands". That accessor
12708 // landed: `now_us()` reads the platform monotonic counter on every
12709 // flavour. So per-callback sporadic accounting is no longer something
12710 // only a hosted build gets, and the no_std fallback to polled
12711 // `SporadicState` cycle deltas is no longer the only option on target.
12712 let consume_dispatch_runtime_us =
12713 |desc_idx: usize,
12714 elapsed_us: u32,
12715 sched_context_bindings: &[super::sched_context::SchedContextId],
12716 sched_contexts: &[Option<super::sched_context::SchedContext>],
12717 sporadic_states: &mut [Option<super::sched_context::SporadicState>],
12718 alive_slots: &mut [super::monitor::AliveSlot],
12719 #[cfg(feature = "alloc")] sporadic_atomic_states: &[Option<(
12720 portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
12721 OpaqueTimerHandle,
12722 )>]| {
12723 let sc_idx = sched_context_bindings[desc_idx].0 as usize;
12724 // Alive supervision counts EVERY dispatch, before the class
12725 // gate below: a Fifo or time-triggered context can fall
12726 // silent exactly as a sporadic one can, and the rule that
12727 // notices must not be scoped to the class that happens to
12728 // carry a budget.
12729 if let Some(slot) = alive_slots.get_mut(sc_idx) {
12730 slot.dispatches = slot.dispatches.wrapping_add(1);
12731 }
12732 let sc_class = sched_contexts
12733 .get(sc_idx)
12734 .and_then(|s| s.as_ref())
12735 .map(|sc| sc.class)
12736 .unwrap_or(super::sched_context::SchedClass::Fifo);
12737 if !matches!(sc_class, super::sched_context::SchedClass::Sporadic) {
12738 return;
12739 }
12740 #[cfg(feature = "alloc")]
12741 if let Some((state, _)) =
12742 sporadic_atomic_states.get(sc_idx).and_then(|s| s.as_ref())
12743 {
12744 state.consume(elapsed_us);
12745 // Unconditional, unlike the overrun record below: the
12746 // budget is sized FROM this number, so it has to exist
12747 // before the budget is right rather than only after it
12748 // is wrong.
12749 state.record_exec(elapsed_us);
12750 // Phase 110.E.b — overrun detection. Cooperative
12751 // single-thread can't preempt a runaway callback,
12752 // so post-dispatch wall-clock comparison delivers
12753 // the same observable signal as the design's
12754 // oneshot-IRQ-and-cancel pattern, without needing
12755 // a separate timer per SC. `budget_capacity_us` is
12756 // the per-period budget the SC was sized against;
12757 // any callback exceeding that has run past its
12758 // bandwidth allotment.
12759 if elapsed_us > state.budget_capacity_us {
12760 state.record_overrun(elapsed_us - state.budget_capacity_us);
12761 }
12762 }
12763 // issue 0736 — the POLLED state is charged unconditionally,
12764 // and it is the one that matters: `has_budget` prefers the
12765 // atomic state only when a refill timer was registered, which
12766 // no board or entry does. Before this, the alloc arm charged a
12767 // state nothing populated and the no_std arm discarded the
12768 // measurement outright (`let _ = (sc_idx, elapsed_us)`), so no
12769 // shipped image recorded per-callback runtime anywhere — while
12770 // the comment above claimed it did.
12771 if let Some(state) = sporadic_states.get_mut(sc_idx).and_then(|s| s.as_mut()) {
12772 state.consume(elapsed_us);
12773 }
12774 };
12775
12776 // W3b.5 — post-dispatch contract checks. `lat_active` gates the
12777 // per-dispatch publish-count snapshot (attribution of dispatch
12778 // elapsed time to monitored publishers whose counter advanced);
12779 // `dl_active` gates elapsed measurement for deadline actions. The third
12780 // term used to be `cfg!(feature = "std")` — "std measures anyway for
12781 // the sporadic path" — which made a COMPILE-TIME flavour stand in for
12782 // "is there a clock". It is now the runtime question it always was.
12783 let mon_table = s.monitor_table;
12784 let lat_active = mon_table.iter().any(|m| m.max_latency_ms > 0);
12785 let dl_active = s.sched_contexts.iter().flatten().any(|sc| {
12786 sc.deadline_us.is_some()
12787 && !matches!(
12788 sc.deadline_action,
12789 super::sched_context::DeadlineAction::Ignore
12790 )
12791 });
12792 let mon_clock = s.clock_us_fn;
12793 // phase10-B3 -- the route rule needs the clock and the publish-count
12794 // snapshot, and nothing else; without a clock it judges nothing.
12795 let route_on = mon_clock.is_some() && super::monitor::route_active(mon_table);
12796 // phase-503 -- a late supervised route reacts at completion too; not
12797 // before the monitors are armed (phase-474 I2), as after the park.
12798 let route_hook = s.reaction_hook.filter(|_| s.violations.counts.armed);
12799 let counts_on = lat_active || route_on;
12800 // phase-359 W4 — one predicate, no cfg block. `cfg!` is an expression,
12801 // so the flavour difference stays a value instead of a branch: std
12802 // measures unconditionally because the sporadic runtime accounting
12803 // below (itself std-only) consumes the result, which is exactly what
12804 // the comment above described and what the four-arm sites encoded.
12805 let measure_us = counts_on || dl_active || mon_clock.is_some();
12806 // phase-359 W4 — ONE hoisted µs reader for the per-dispatch latency
12807 // measurement below. Hoisted because `now_us()` takes `&mut self` and
12808 // the dispatch loop already holds borrows; the std arm copies the epoch
12809 // (a `Copy` `Instant`) so reading inside the loop touches no `self`.
12810 // This is the last cfg pair in the timing path: the two call sites it
12811 // serves each had FOUR arms (start + elapsed, per flavour).
12812 let read_us = move || mon_clock.map(|c| c()).unwrap_or(0);
12813 // Deferred deadline-miss violations (the loop body holds an
12814 // immutable borrow of `s.entries`, so the ring is fed after).
12815 let mut deadline_misses: heapless::Vec<
12816 super::monitor::Violation,
12817 { super::monitor::MAX_VIOLATIONS },
12818 > = heapless::Vec::new();
12819 // SCs whose remaining callbacks this cycle are skipped
12820 // (`DeadlineAction::Skip`) — bitmask over SC slots.
12821 let mut skipped_scs: u64 = 0;
12822
12823 // For each priority bucket (Critical → Normal → BestEffort),
12824 // drain EDF first then FIFO so an EDF callback in this bucket
12825 // beats a FIFO peer at the same priority, but no lower-priority
12826 // entry runs while a higher-priority bucket has work pending.
12827 // Strict static priority across buckets; non-preemptive within
12828 // an in-flight callback (see Phase 110.D).
12829 // phase-509 P3 (RFC-0108 D2) -- one pick at a time. Each pick is the
12830 // most urgent queued job (bucket by bucket: EDF heap, then the ranked
12831 // FIFO's levels), and after each callback the ready set is merged
12832 // with what became ready while it ran, so an urgent callback made
12833 // ready by a less urgent one runs before the rest of the pass. Was a
12834 // drain of the scan's snapshot, bucket by bucket.
12835 loop {
12836 let mut next = None;
12837 for bucket in 0..NB {
12838 if let Some(job) = edf.pop_from(bucket) {
12839 next = Some(job);
12840 break;
12841 }
12842 let (level_lo, level_hi) =
12843 super::sched_context::Priority::from_index(bucket).level_range();
12844 if let Some(job) = fifo.pop_in(level_lo, level_hi) {
12845 next = Some(job);
12846 break;
12847 }
12848 }
12849 let Some(job) = next else { break };
12850 let i = job.desc_idx as usize;
12851 let sc_idx = s.sched_context_bindings[i].0 as usize;
12852 if sc_idx < 64 && skipped_scs & (1u64 << sc_idx) != 0 {
12853 continue; // W3b.5 DeadlineAction::Skip containment
12854 }
12855 if let Some(meta) = s.entries[i]
12856 && !Self::slot_held(s.slot_tags, i)
12857 {
12858 let counts_before = snapshot_pub_counts(mon_table, counts_on);
12859 // Measured only when a consumer wants it, matching the
12860 // no_std arm this replaces — the std arm used to measure
12861 // unconditionally.
12862 let start_us = measure_us.then(read_us);
12863 let took_before = result.subscriptions_processed;
12864 Self::begin_dispatch(s.slot_tags, i);
12865 // phase-509 P9b item 2 (RFC-0108 "As built in P9b") -- the
12866 // callback window. With a lock that can tell threads apart,
12867 // a shared or band pass gives it for the callback, so a more
12868 // urgent worker (or an FFI caller) reaches the executor
12869 // while this callback runs. What crosses the window is a
12870 // copy of the entry's meta and its pinned arena address; the
12871 // running mark (`begin_dispatch`) keeps every other pass off
12872 // the entry and defers its release, and `rec` names this
12873 // thread as the one running it. `s` is derived again after.
12874 let mut rec = super::exec_lock::Running {
12875 slot: i,
12876 thread: window_thread,
12877 next: core::ptr::null_mut(),
12878 };
12879 let window = window_lock.map(|l| {
12880 // SAFETY: `rec` outlives the window and is unlinked under
12881 // the lock right after it.
12882 unsafe { s.link_running(&mut rec) };
12883 super::exec_lock::Window::open(l)
12884 });
12885 dispatch_one(&meta, i, arena_ptr, delta_us, &mut result);
12886 let end_us = start_us.map(|_| read_us());
12887 if let Some(w) = window {
12888 drop(w);
12889 // SAFETY: the lock is held again; nothing borrowed from
12890 // the executor before the window is used after.
12891 s = unsafe { &mut *this };
12892 s.unlink_running(&mut rec);
12893 }
12894 let elapsed_us: Option<u32> = start_us
12895 .zip(end_us)
12896 .map(|(t0, t1)| t1.saturating_sub(t0))
12897 .map(|d| d.min(u32::MAX as u64) as u32);
12898 // phase10-B3 -- a subscription dispatch takes its sample
12899 // first, so the dispatch start is the take.
12900 if route_on && let (Some(t0), Some(t1)) = (start_us, end_us) {
12901 if result.subscriptions_processed != took_before {
12902 super::monitor::route_take(mon_table, i, t0);
12903 }
12904 super::monitor::route_judge(
12905 mon_table,
12906 &counts_before,
12907 t1,
12908 |row, v, record, react| {
12909 if react {
12910 call_route_reaction(route_hook, mon_table, row, &v, t1);
12911 }
12912 if record {
12913 let _ = deadline_misses.push(v);
12914 }
12915 },
12916 );
12917 }
12918 if let Some(elapsed_us) = elapsed_us {
12919 consume_dispatch_runtime_us(
12920 i,
12921 elapsed_us,
12922 &s.sched_context_bindings[..],
12923 &s.sched_contexts[..],
12924 &mut s.sporadic_states[..],
12925 &mut s.alive_slots[..],
12926 #[cfg(feature = "alloc")]
12927 &s.sporadic_atomic_states[..],
12928 );
12929 }
12930 if let Some(elapsed_us) = elapsed_us {
12931 attribute_latency(mon_table, lat_active, &counts_before, elapsed_us);
12932 check_deadline_miss(
12933 s.sched_contexts.get(sc_idx).and_then(|s| s.as_ref()),
12934 sc_idx,
12935 elapsed_us,
12936 &mut deadline_misses,
12937 &mut skipped_scs,
12938 s.fault_fn,
12939 s.reaction_hook,
12940 end_us.unwrap_or(0),
12941 );
12942 }
12943 s.finish_dispatch(i);
12944 }
12945 if repick {
12946 s.merge_mid_pass(&mut fifo, &mut edf, &mut settled, arena_ptr, band_mask);
12947 }
12948 }
12949
12950 // W3b.5 — feed deferred deadline misses into the violation ring.
12951 for v in deadline_misses {
12952 s.violations.record(v);
12953 }
12954 // phase-509 P9b item 2 -- the rules and the executor-wide chores
12955 // below are the executor's own worker's. Issue 2020 -- the band's
12956 // own components' ticks are the band's.
12957 if is_band_pass {
12958 // SAFETY: the pass's contract; `s` is not used after it.
12959 unsafe { Self::tick_components(this, Some(level)) };
12960 return result;
12961 }
12962
12963 // Issue #505 — same ring, same cycle. This runs AFTER dispatch,
12964 // unlike the windowed rate/age/latency rules at the top of the
12965 // spin: the activations it reports were dropped by the dispatch
12966 // that just happened, and a tier that is stalling should not have
12967 // to wait for the next spin to say so.
12968 s.check_timer_overruns();
12969 s.check_release_jitter_rule();
12970 s.check_stack_headroom_rule();
12971 s.check_alive_supervision();
12972 // phase-474 I7 -- the queued /diagnostics reports, after this spin's
12973 // dispatches and rules, when the next timer leaves room for them.
12974 s.flush_violation_reports();
12975 // phase-474 I1 -- the opt-in drain-and-report hook, after every rule of
12976 // this spin has had its say.
12977 s.violations.drain_and_report();
12978
12979 // Process parameter services (outside the arena)
12980 #[cfg(feature = "param-store")]
12981 #[allow(unused_mut)] // only the services add to it
12982 let mut handled = 0usize;
12983 #[cfg(feature = "param-services")]
12984 if let Some(params) = &mut s.params {
12985 {
12986 let crate::param_store::ParamState {
12987 server,
12988 services,
12989 buffers,
12990 staged,
12991 ..
12992 } = &mut **params;
12993 // phase-426 W3 -- one set per node; see the sibling site above,
12994 // including why nothing here can discard an error (1270/1271).
12995 if let Some(buffers) = buffers {
12996 for set in services.iter_mut() {
12997 let n = set.process(server, staged, buffers);
12998 result.services_handled += n;
12999 handled += n;
13000 }
13001 }
13002 }
13003 }
13004 #[cfg(feature = "param-store")]
13005 s.note_param_services_ran(handled);
13006
13007 // Process lifecycle services (outside the arena).
13008 //
13009 // SAFETY: `change_state` dispatches a user-supplied C callback through a
13010 // raw function pointer stored in `LifecyclePollingNodeCtx`. The caller
13011 // of `register_lifecycle_services` guarantees the callback/context pair
13012 // stays live for as long as the executor (see that method's docs).
13013 #[cfg(feature = "lifecycle-services")]
13014 if let Some(lc) = &mut s.lifecycle {
13015 if let Ok(n) = unsafe { lc.process() } {
13016 result.services_handled += n;
13017 }
13018 }
13019
13020 // Phase 258 (Track 2, 2a) — executor-owned component tick pass.
13021 // Mirrors `ExecutorNodeRuntime::run_ticks`: after the transport +
13022 // callbacks have been pumped, drive each enrolled component's `tick`
13023 // (service-client/action poll, etc.). `exec_ctx` hands the component
13024 // the whole executor as a raw `*mut Executor` so its tick can
13025 // reborrow it (the same disjoint-field raw-ptr pattern run_ticks
13026 // uses). Index-iterate over `Copy` slots so no borrow of
13027 // `s.component_slots` is held while `tick` runs (which aliases
13028 // `self` through `exec_ctx`).
13029 //
13030 // Issue 2020 -- only the components this pass's level owns.
13031 // SAFETY: the pass's contract; `s` is not used after the call.
13032 unsafe { Self::tick_components(this, None) };
13033
13034 result
13035 }
13036
13037 /// Phase 258 (Track 2, 2a) -- the component tick pass at the tail of a
13038 /// pass: each enrolled component's `tick` (service-client polls,
13039 /// action-server completion and feedback), with the whole executor as
13040 /// `exec_ctx`. Issue 2020 -- only the components the pass's level owns
13041 /// ([`Self::pass_ticks_owner`]): `band_level` is a band pass's level,
13042 /// `None` for the executor's own worker.
13043 ///
13044 /// The lock stays held across every tick, as it always was: a component
13045 /// is ticked by one level's thread only, and never by two at once.
13046 ///
13047 /// # Safety
13048 /// As [`Self::pass`], called with the lock held (when installed) and no
13049 /// reference to the executor live in the caller.
13050 unsafe fn tick_components(this: *mut Self, band_level: Option<u8>) {
13051 let exec_ctx = this as *mut core::ffi::c_void;
13052 let mut i = 0;
13053 loop {
13054 // SAFETY: re-derived after each tick, which aliases the executor
13055 // through `exec_ctx`; the slot is copied out, so no borrow of
13056 // `component_slots` is outstanding during the call.
13057 let s: &Self = unsafe { &*this };
13058 let Some(slot) = s.component_slots.get(i).copied() else {
13059 break;
13060 };
13061 i += 1;
13062 let owner = (slot.level != super::scheduler::NO_OWNING_LEVEL).then_some(slot.level);
13063 if !s.pass_ticks_owner(band_level, owner) {
13064 continue;
13065 }
13066 // SAFETY: `slot.state` is the leaked cell the matching `tick`
13067 // expects (enrolled via `enroll_component`); `exec_ctx` is a live
13068 // `*mut Executor`.
13069 unsafe { (slot.tick)(slot.state, exec_ctx) };
13070 }
13071 }
13072
13073 /// The executor's own worker's prelude to a pass: everything
13074 /// `spin_once` did before its readiness scan. Returns the pass's clock
13075 /// read and the elapsed time its timers are credited.
13076 /// # Safety
13077 /// As [`Self::pass`]; `window_lock` is the pass's (`None` for an owned
13078 /// pass), and with it the calling thread holds the lock exactly once.
13079 unsafe fn owned_prelude(
13080 this: *mut Self,
13081 window_lock: Option<super::exec_lock::ExecLock>,
13082 timeout: core::time::Duration,
13083 ) -> (Option<u64>, u64) {
13084 // SAFETY: the caller's contract; re-derived after every window.
13085 #[allow(unused_mut)]
13086 let mut s: &mut Self = unsafe { &mut *this };
13087 // phase-509 P7 (RFC-0108 D8.2) -- this level is alive: it reached a
13088 // spin. A level stuck in a callback stops checking in, which is what
13089 // the image's watchdog feed (`take_every_level_alive`) waits on.
13090 let now = s.now_us();
13091 super::level_liveness::LEVELS.check_in(s.worker.level, now);
13092 // phase-436 W2 (issue 1193) — the budget is carried in MICROSECONDS.
13093 // This was `as_millis()`, which truncates: `from_micros(500)` became
13094 // `0`, and a zero timeout selects the non-blocking path — so a
13095 // sub-millisecond `spin()` was a 100 % CPU loop with no warning. The
13096 // conversion to whatever the platform primitive accepts happens once,
13097 // at the park, and rounds UP. The budget itself is `grid_budget_us`
13098 // below (phase-474 I11).
13099
13100 // phase-425 W3b — bring the `/clock` subscription in line with
13101 // `use_sim_time`. One bool comparison in the settled case; the work only
13102 // happens on a transition, or while a request is waiting for its first
13103 // node to exist.
13104 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
13105 {
13106 #[cfg(feature = "param-store")]
13107 s.settle_builder_reserved_parameters();
13108 s.reconcile_ros_time_source();
13109 }
13110
13111 // phase-426 W3 — attach the six parameter services to any node that
13112 // does not have them yet. Same reason as the reconcile above: the
13113 // request routinely arrives before there is a node to publish under
13114 // (`nros::main!` emits `apply_param_services` ahead of its per-node
13115 // `register` calls, by design). One length comparison once settled.
13116 //
13117 // Issue 1271 -- a failure is LOGGED, once, rather than discarded. On
13118 // Cyclone this fails on every spin with `Transport(Unsupported)`
13119 // (issue 1268), and with the result dropped "this node has no
13120 // parameter services" was a fact nothing on the image stated.
13121 #[cfg(feature = "param-services")]
13122 if s.parameter_services_pending()
13123 && let Err(e) = s.reconcile_parameter_services()
13124 {
13125 s.report_parameter_service_reconcile_failure(e);
13126 }
13127
13128 // Release jitter, measured HERE rather than in `spin_period`, because
13129 // this is the function every driver goes through.
13130 //
13131 // The first version instrumented `spin_period` only, and that was the
13132 // same mistake issue 0736 records one layer down: a measurement placed
13133 // on a path no shipped image takes. `nros-cpp`'s tier trampoline paces
13134 // itself with a `spin_once` loop at `spin_period_us` (see its
13135 // `run_components` docs, step 4), so `spin_period` is not what the C++
13136 // entry -- and therefore not what the Zephyr lane -- actually calls.
13137 // The field recorded zero forever while claiming to measure cadence.
13138 //
13139 // `timeout` is the caller's intended pacing quantum: `spin_period`
13140 // passes its period, and the generated entries pass 10 ms. phase-474
13141 // I11 -- with no declared cadence it is the period of an absolute
13142 // release grid, and the park budget is the time to the next grid
13143 // point (see `record_release_jitter`); the release is judged after the
13144 // park, against the grid (`record_grid_release`). A zero timeout means
13145 // "poll, no cadence claimed", so it is neither judged nor gridded.
13146 let grid_budget_us = s.record_release_jitter(timeout);
13147
13148 // issue 0900 — one-shot advisory when the arena is far larger than what
13149 // registered. First spin rather than "end of registration", because
13150 // there is no such point: an app may register lazily.
13151 super::arena::maybe_report_arena_headroom(s.arena_used, s.arena.len());
13152
13153 // phase-412 -- the same moment, recorded where a board with no log sink
13154 // can still be asked. Reaching this stage is the positive result the
13155 // advisory above cannot deliver on the island: registration completed.
13156 crate::boot_report::checkpoint(crate::boot_report::Stage::FirstSpin);
13157
13158 // Phase 110.0 — the backend's next internal-event deadline (lease
13159 // keepalive, heartbeat, ACK-NACK timeout, ...). Default backend impl
13160 // returns `None`, so this contributes nothing unless the active
13161 // backend opts in.
13162 //
13163 // phase-436 W6 — a CANDIDATE now, not a pre-cap. Folding it into the
13164 // budget here meant a park the session won was attributed to
13165 // `CallerBudget`, so the one number that explains a wake named the
13166 // wrong source.
13167 let session_us = s
13168 .session
13169 .next_deadline_ms()
13170 .map(|next| (next as u64).saturating_mul(1000));
13171
13172 // phase-436 W1 (issue 1192) — and by the next TIMER deadline, which is
13173 // the one class of deadline the executor itself owns. Before this the
13174 // park was bounded by the caller's budget narrowed only by the
13175 // BACKEND's next event, so ~~`spin_default`~~'s 50 ms slept straight past
13176 // a registered 10 ms timer.
13177 //
13178 // Attribution is recorded here rather than derived later: this is the
13179 // only point where the losing candidates are still in hand.
13180 let (park_bound_us, park_source) =
13181 s.next_wake_bound_attributed_us(grid_budget_us, session_us);
13182 s.worker.last_park_bound_us = park_bound_us;
13183 s.worker.last_park_source = park_source;
13184 // phase-436 W2 (issue 1193) — round UP to what the primitive can
13185 // express. The old `as_millis()` truncated, so a sub-millisecond
13186 // request became a zero park: the non-blocking path, a busy loop.
13187 let park_achieved_us = s.round_park_up_us(park_bound_us);
13188 s.worker.last_park_achieved_us = park_achieved_us;
13189
13190 // phase-436 W6 — a port that installed a park primitive does its
13191 // waiting HERE, on the bound every source agreed on, and the transport
13192 // is then drained non-blockingly.
13193 //
13194 // This is the path bare metal needs: a `ParkUntilFn` can arm a
13195 // timer compare before `wfi`, which `sleep(duration)` cannot express —
13196 // and `nros-baremetal-common` notes that `wfi` with no pending
13197 // interrupt deadlocks, so arming first is not optional there.
13198 //
13199 // Additive by construction: with no primitive installed this is a
13200 // no-op and every existing port keeps the behaviour it has.
13201 // phase-436 — park only when something can END the park early. A
13202 // park ends on the bound or on the wake object; with a poll-only
13203 // backend (XRCE), or a session whose wake callback is gone, nothing
13204 // signals that object, so the park would be a blind sleep and data
13205 // arriving mid-park would wait out the whole bound — the Phase
13206 // 127.C.4 stall (a reliable stream whose ACK lands in a session nobody
13207 // drives). Those executors wait on the wake object below, on the short
13208 // entry budget (`entry_spin_budget`).
13209 let park_can_be_woken = s.park_can_be_woken();
13210
13211 // phase-521 W3 (RFC-0113 D3) -- a session whose readiness is a
13212 // WAITABLE handle (a socket on POSIX / NuttX) is parked on: the port
13213 // multiplexes the handles with this executor's own wake object, so
13214 // data arriving ends the park and a guard trigger still does too. The
13215 // tier then drains its sessions non-blockingly below, instead of
13216 // blocking inside the transport's `recv` (and slicing that wait for
13217 // a guard condition, phase-436 D5). A port that cannot wait on handles
13218 // answers `None` and the wait below is the one it always had.
13219 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
13220 let handle_parked = if park_achieved_us > 0 {
13221 let mut handles = [0i32; MAX_WAITABLE_HANDLES];
13222 let n = s.waitable_handles(&mut handles);
13223 match s.node_wake.as_ref() {
13224 Some(wake) if n > 0 => {
13225 let wake: *const super::node_wake::NodeWake = &**wake;
13226 let _window = window_lock.map(super::exec_lock::Window::open);
13227 // SAFETY: the wake object is the executor's (`Arc`, set at
13228 // open and never replaced), so it outlives the window.
13229 unsafe { &*wake }
13230 .park_handles_us(&handles[..n], park_achieved_us)
13231 .is_some()
13232 }
13233 _ => false,
13234 }
13235 } else {
13236 false
13237 };
13238 #[cfg(not(all(feature = "alloc", feature = "rmw-cffi")))]
13239 let handle_parked = false;
13240 // SAFETY: the lock is held again (the window, if any, has closed).
13241 s = unsafe { &mut *this };
13242
13243 // phase-509 P9b item 3 -- a shared pass parks with the lock released
13244 // (a window), so the band workers run while the executor's own
13245 // worker waits. Nothing borrowed from the executor crosses it.
13246 let mut yielded = false;
13247 let parked = handle_parked
13248 || match s.worker.park_primitive {
13249 Some((park, ctx)) if park_achieved_us > 0 && park_can_be_woken => {
13250 let _window = window_lock.map(super::exec_lock::Window::open);
13251 // SAFETY: `park` and `ctx` were supplied together by the port
13252 // through `set_park_primitive` and outlive the executor by its
13253 // contract.
13254 //
13255 // phase-436 — a negative answer is "cannot park": the ABI's
13256 // own contract, and what a Rust-implemented port's trait
13257 // default returns at once. Counting that as a park would
13258 // drop the drain below to non-blocking and spin hot, so the
13259 // spin falls back to the wait it would have had uninstalled.
13260 let rc = unsafe { park(ctx, park_achieved_us) };
13261 yielded = rc == PARK_YIELDED;
13262 rc >= 0
13263 }
13264 _ => false,
13265 };
13266 // SAFETY: the lock is held again (the window, if any, has closed).
13267 s = unsafe { &mut *this };
13268 // phase-521 W8 (c) -- a YIELDED slot did not wait: its framework runs
13269 // this tier again at the bound or on an arrival, so the pass returns.
13270 // Not while a wake is already pending, though: then the work is here
13271 // now, and the answer counts as a park that ended at once.
13272 #[cfg(feature = "alloc")]
13273 let pending = s.wake_flag.load(portable_atomic::Ordering::SeqCst);
13274 #[cfg(not(feature = "alloc"))]
13275 let pending = false;
13276 s.worker.park_yielded = yielded && !pending;
13277
13278 // Having already waited, the transport drain must not block again —
13279 // otherwise the park and the drive would sum, and the cadence would be
13280 // up to twice what was asked. Same trap as passing a period to
13281 // `spin_once` instead of declaring it (#648).
13282 let timeout_ms = if parked {
13283 0
13284 } else {
13285 (park_achieved_us / 1000).min(i32::MAX as u64) as i32
13286 };
13287
13288 // Wall-clock-accurate timer accumulation. Measure real time
13289 // since the previous `spin_once` exited (or, on the first call,
13290 // since `drive_io` started). Two failure modes the requested
13291 // `timeout_ms` doesn't capture:
13292 // 1. `drive_io` returns early — e.g. zenoh-pico's condvar wakes
13293 // on data arrival, well under 1 ms.
13294 // 2. The caller spends time outside `spin_once` (explicit sleep,
13295 // ROS-2 cooperative scheduling, etc.) and that time should
13296 // still count toward timers.
13297 // Crediting the requested timeout to timers in either case ticks
13298 // them faster than wall-clock — observed as a 30 Hz control loop
13299 // overshooting to >200 Hz under sustained traffic. Carry the
13300 // sub-ms remainder across calls so precision is preserved.
13301 // phase-359 W4 — the spin-ENTRY read, one spelling. Kept distinct from
13302 // the post-IO read below because the first spin's delta is measured from
13303 // entry; collapsing them would make that delta 0 on the first call only.
13304 let spin_start_us = s.now_us();
13305
13306 // phase-474 I2 -- arm the monitors when the policy says start-up is over,
13307 // before any rule of this spin can judge.
13308 s.update_monitor_arming(spin_start_us);
13309 // phase-462 W4 -- and switch to the requested mode's tables, before
13310 // any rule of this spin judges by them.
13311 s.update_mode(spin_start_us);
13312
13313 // RFC-0052 W3b.4 — contract monitors tick once per spin (window
13314 // logic inside; single branch when the baked table is empty).
13315 s.run_contract_monitors();
13316
13317 // Phase 104.C.6 — shared executor wake. Swap-and-clear the
13318 // wake flag; if it was set before this `spin_once` entered,
13319 // skip the blocking wait on the primary session and poll
13320 // every session non-blockingly. Lets a wake signal from any
13321 // thread (or, post-104.C.6.b, any backend's vtable hook)
13322 // pre-empt whichever session the executor would otherwise
13323 // sleep on. Cost on the no-wake path is one atomic swap.
13324 // phase-359 W10 — ONE swap, at `alloc` scope.
13325 //
13326 // This was `#[cfg(feature = "std")]` while the alloc wait arm did its
13327 // own swap further down. Merging the two arms made that two swaps of
13328 // one flag per spin, the first consuming what the second tested, so the
13329 // arm would have read `was_woken == false` forever. Deleting the outer
13330 // one instead broke a different thing: `wake_flag` is `alloc`-gated and
13331 // "spin_once consumes the wake flag" is an invariant of every alloc
13332 // build, but the merged arm only runs with `rmw-cffi` — so a
13333 // `std`-without-`rmw-cffi` build stopped clearing it, which
13334 // `test_wake_cleared_each_spin` catches. It belongs here, at the scope
13335 // of the field it reads.
13336 #[cfg(feature = "alloc")]
13337 #[allow(unused_variables)]
13338 let was_woken = s.wake_flag.swap(false, portable_atomic::Ordering::SeqCst);
13339
13340 // Phase 124.B.4 — condvar-blocked wait.
13341 //
13342 // RT contract:
13343 // * cv.wait_timeout_while: bounded by `timeout_ms`.
13344 // Predicate is O(1) — one atomic swap + Instant::now.
13345 // No allocation. PI-mutex consideration: wake_mu held
13346 // only during predicate check (microseconds);
13347 // contended worst-case = notify_all execution time
13348 // (~10s of µs).
13349 // * Backend's `set_wake_callback`-installed cb is called
13350 // on async data arrival from its transport-notify path
13351 // (worker thread, ISR-safe variant via 124.B.7). The
13352 // runtime cb writes wake_flag + signals wake_cv,
13353 // unblocking this loop sub-poll-period.
13354 // * Poll-only backends (XRCE, bare-metal) leave the slot
13355 // NULL; the cv wait still fires on its deadline, then
13356 // drive_io(0) drains whatever the backend's internal
13357 // poll has buffered. Equivalent to their pre-124
13358 // behaviour minus the blocking wait inside drive_io.
13359 //
13360 // Lost-wakeup safe: SeqCst flag write happens-before
13361 // notify, and the waiter checks the flag under wake_mu in
13362 // the predicate. If wake fires between drain and cv.wait
13363 // entry, the predicate sees flag=true on first eval and
13364 // exits immediately.
13365 // Phase 130.4 — only sleep in the wake wait when a backend actually
13366 // installed `set_wake_callback`. Poll-only backends (XRCE) leave the
13367 // vtable slot NULL → `has_async_wake == false` →
13368 // drive_io for the caller's full timeout instead of sleeping in a
13369 // never-signaled wait that starves reliable retransmission (Phase
13370 // 127.C.4 root cause: the server's `send_response` flushes 100 ms once,
13371 // then a blind `wait_ms(100)` sleeps with zero session activity, so the
13372 // agent's ACK arrives into a stalled session and reliable redelivery
13373 // never fires).
13374 //
13375 // Phase 248 (C2) — which primitive is a RUNTIME choice from the
13376 // platform vtable's wake probe, not a compile-time per-RTOS `cfg`:
13377 // `node_wake.is_some()` means `nros_platform_wake_*` is linked and
13378 // `nros_rmw_runtime_wake_cb` signals it on transport arrival.
13379 // phase-359 W10 — ONE arm. This was a `std` arm and an `alloc` arm with
13380 // the same body: block on the platform wake primitive when a backend
13381 // installed the callback and nothing has woken us yet, else drive the
13382 // transport for the caller's full timeout. They stopped differing when
13383 // the condvar fallback was deleted — the std arm's `else` branch had
13384 // already become "drive for the full timeout", which is what the alloc
13385 // arm always did — so keeping two was keeping a fork that agreed.
13386 // phase-417 W4.e — the FIRST arm is what the swap-and-clear above says
13387 // it is for, and it was missing: a wake that landed BETWEEN two spins
13388 // set the flag, and this expression then took the `else` and drove the
13389 // transport for the caller's FULL timeout, so the pending work waited
13390 // out the very budget the wake exists to cut short. Measured with a
13391 // guard condition triggered from another thread just before a 400 ms
13392 // spin: 400 ms to dispatch, against 50 ms for the same trigger issued
13393 // while the spin was already parked in `wait_ms`. Being woken means
13394 // there IS work, so the right timeout is 0 — poll every session
13395 // non-blockingly and go dispatch it, which is what the comment at the
13396 // swap has claimed since Phase 104.C.6.
13397 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
13398 let primary_drive_timeout_ms = {
13399 if was_woken {
13400 // Issue 1385 — drain the wake PRIMITIVE too.
13401 //
13402 // `nros_rmw_runtime_wake_cb` sets both halves of one signal:
13403 // the flag, and the platform primitive. The swap above
13404 // consumed the flag and this arm returns without entering the
13405 // wait, so a primitive left posted makes the NEXT spin's
13406 // `wait_ms` return instantly on a signal already acted on —
13407 // that spin does not wait at all, and whatever it should have
13408 // waited for is dispatched a whole spin late.
13409 //
13410 // `wait_ms(0)` is the non-blocking take: every port's wait is a
13411 // deadline (`sem_timedwait`, `k_sem_take`, `xSemaphoreTake`),
13412 // so zero means "take it if it is there", never "wait
13413 // forever".
13414 //
13415 // Measured, not reasoned: with the C executor installing a
13416 // callback (the other half of 1385), an arrival taken by this
13417 // arm left the spin after it returning in 0 ms where it owed
13418 // 60 ms of waiting — which is what `node_guard_condition.c`'s
13419 // mid-spin case caught, and what `executor_backend_wake.c`'s
13420 // second lower bound now pins. Latent until then only because
13421 // no C spin had ever entered the wait arm.
13422 if let Some(wake) = s.node_wake.as_ref() {
13423 let _ = wake.wait_ms(0);
13424 }
13425 0
13426 } else if let Some(wake) = s.node_wake.as_ref() {
13427 // phase-521 W6 (RFC-0113 D2) — the wait is on the wake object
13428 // whatever the session's form. A session that states no
13429 // readiness form is refused at open (D5), so the transport's
13430 // own `recv` is never the wait any more. A `Notify` session
13431 // whose callback is live posts this object on an arrival; a
13432 // `Waitable` session was parked on above. What reaches here
13433 // unwoken by its data (a wake fan-out with no free member, more
13434 // waitable sessions than one park multiplexes) is the case
13435 // `entry_spin_budget` keeps on the short budget, so the wait
13436 // stays bounded by it and the drain below picks the data up.
13437 // phase-436 — after a park this wait has a zero timeout and
13438 // the park has already waited on this same object, so it is a
13439 // second syscall that can only time out. Measured on a native
13440 // tier: one extra futex wait per iteration, doubling the idle
13441 // context switches. A signal that lands between the park's
13442 // return and here stays posted and ends the next park at once,
13443 // which costs one spin and loses nothing.
13444 if !parked {
13445 // phase-509 P9b item 3 -- the wait is a window too: the
13446 // wake object is the executor's (`Arc`, set at open and
13447 // never replaced), reached through a raw pointer.
13448 let wake: *const super::node_wake::NodeWake = &**wake;
13449 let _window = window_lock.map(super::exec_lock::Window::open);
13450 // SAFETY: the object outlives the executor's open window.
13451 let _ = unsafe { &*wake }.wait_ms(timeout_ms as u32);
13452 }
13453 // SAFETY: the lock is held again.
13454 s = unsafe { &mut *this };
13455 // Drain any flag the cb set while we were waiting.
13456 let _ = s.wake_flag.swap(false, portable_atomic::Ordering::SeqCst);
13457 0
13458 } else {
13459 // No wake object linked (a port that supplies no
13460 // `nros_platform_wake_*`): the backend's drive is the only
13461 // wait there is.
13462 timeout_ms
13463 }
13464 };
13465
13466 // std builds without rmw-cffi (mock-session tests, future alternative
13467 // backends) keep the original "drive_io is non-blocking" assumption.
13468 // `std` implies `alloc`, so this is the only std configuration the arm
13469 // above does not cover.
13470 #[cfg(all(feature = "alloc", not(feature = "rmw-cffi")))]
13471 let primary_drive_timeout_ms = 0;
13472
13473 // no_std without (alloc + rmw-cffi) keeps the legacy
13474 // full-timeout drive_io call.
13475 #[cfg(all(
13476 not(feature = "alloc"),
13477 not(all(feature = "alloc", feature = "rmw-cffi"))
13478 ))]
13479 let primary_drive_timeout_ms = timeout_ms;
13480
13481 // issue 0324 — the primary session's I/O result is TRACKED, not
13482 // discarded. See `consecutive_io_failures` for why this is a counter
13483 // rather than an early return.
13484 match s.drive_primary(primary_drive_timeout_ms) {
13485 Ok(()) => s.consecutive_io_failures = 0,
13486 Err(_) => s.consecutive_io_failures = s.consecutive_io_failures.saturating_add(1),
13487 }
13488 for extra in s.extra_sessions.iter_mut() {
13489 // Best-effort BY DESIGN: extra sessions are bridge / multi-domain
13490 // attachments, and one of them failing must not stall the primary
13491 // spin. The old code expressed this the same way it expressed the
13492 // primary's discarded error — identically — so the intent was
13493 // invisible. It is now the only `let _ =` of the two.
13494 let _ = extra.drive_io(0);
13495 }
13496
13497 // phase-359 W4 — ONE clock read per spin, shared by the delta below and
13498 // every consumer further down. Replaces the std/no_std delta pair AND
13499 // two ad-hoc `static EPOCH: OnceLock<Instant>` blocks that each kept
13500 // their own std-only epoch.
13501 let now_us_this_spin = s.now_us();
13502 // phase-474 I11 -- the release instant is the end of the park, so it
13503 // is judged on this read, before any dispatch of this spin.
13504 if let Some(now) = now_us_this_spin {
13505 s.record_grid_release(now);
13506 }
13507
13508 // Same rule on both flavours: measure elapsed when a clock exists, else
13509 // credit the REQUESTED timeout. That fallback was previously reachable
13510 // only on no_std; on std `Instant` always answered. It stays unreachable
13511 // on std for the same reason — `now_us()` is infallible there — so this
13512 // is one expression, not a behaviour change.
13513 let delta_us = match now_us_this_spin {
13514 Some(now) => {
13515 let prev = s
13516 .last_spin_end_us
13517 .unwrap_or_else(|| spin_start_us.unwrap_or(now));
13518 s.last_spin_end_us = Some(now);
13519 now.saturating_sub(prev)
13520 }
13521 None => (timeout_ms as u64).saturating_mul(1000),
13522 };
13523
13524 // phase-430 W1 — the silence watch is credited the SAME `delta_us` the
13525 // wall timers below are, which is the whole of its unit argument: the
13526 // diagnostic arrives after as much time as this image's own wall timers
13527 // believe has passed, on whatever clock it actually has.
13528 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
13529 s.note_sim_time_silence(delta_us);
13530
13531 if !s.spin_quantization_checked && timeout_ms > 0 {
13532 s.spin_quantization_checked = true;
13533 // The TIER's declared cadence, not the capped park bound. Since
13534 // phase-436 W1 the two differ whenever a timer is due sooner than
13535 // the budget, and auditing the cap would compare the timer period
13536 // against itself — an exact multiple every time, so issue 0515's
13537 // warning would go silent on precisely the images that have a
13538 // timer to quantize. `spin_nominal_us` is the declared cadence
13539 // (`set_spin_nominal_us`, else the caller's timeout), recorded at
13540 // the top of this spin before any capping.
13541 s.spin_quantization_us = s.spin_nominal_us;
13542 s.audit_spin_quantization(s.spin_quantization_us);
13543 }
13544 (now_us_this_spin, delta_us)
13545 }
13546
13547 /// Drive I/O and dispatch callbacks forever — the body of an RTOS task.
13548 ///
13549 /// EXTENSION (RFC-0089). Not a rename of upstream's `spin`: the `!` is the
13550 /// point. A bare-metal entry has no shutdown source to return TO, and
13551 /// declaring that lets the compiler drop the epilogue and lets the caller
13552 /// write it as the last expression of a `-> !` task body with no
13553 /// `unreachable!()`. [`spin`](Self::spin) is the verb that can end, and it
13554 /// is upstream's.
13555 ///
13556 /// phase-427 — was `spin(Duration) -> !`, which squatted on rclrs's name
13557 /// with a different contract (a ported `executor.spin(SpinOptions)` line
13558 /// had to be renamed by hand, the one edit on the tutorial's happy path
13559 /// that no real difference forced). ~~`spin_default()`~~ went with it: it named
13560 /// a 50 ms poll interval and had zero call sites, and the interval is now
13561 /// [`SpinOptions::poll_interval`], defaulting to
13562 /// [`DEFAULT_SPIN_POLL_INTERVAL`](super::types::DEFAULT_SPIN_POLL_INTERVAL).
13563 ///
13564 /// **Envelope.** Only `poll_interval` and `stop_on_first_error` can mean
13565 /// anything here; `timeout`, `only_next` and `max_callbacks` are ENDING
13566 /// conditions and this verb does not end. Passing one is a caller error and
13567 /// says so at `LOG_ERR` on entry rather than being quietly dropped — a
13568 /// discarded ending condition is precisely the "compiles and differs" case
13569 /// RFC-0089 exists to make loud, and a `-> !` signature has no other
13570 /// channel. `stop_on_first_error` likewise cannot RETURN the error; it
13571 /// logs it and keeps spinning, because a task body has nowhere to put it.
13572 ///
13573 /// ```ignore
13574 /// let mut executor = Executor::open(&config)?;
13575 /// executor.register_subscription::<Int32, _>("/topic", |msg| { /* ... */ })?;
13576 /// executor.spin_forever(SpinOptions::default()); // never returns
13577 /// ```
13578 pub fn spin_forever(&mut self, opts: SpinOptions) -> ! {
13579 if opts.timeout.is_some() || opts.only_next || opts.max_callbacks.is_some() {
13580 nros_log::log_error!(
13581 nros_log::get_logger("nros"),
13582 "spin_forever: an ending condition (timeout / only_next / max_callbacks) \
13583 was supplied to a verb that never returns and is being IGNORED \
13584 -- use `Executor::spin` for a loop that can end"
13585 );
13586 }
13587 let poll = opts.poll_interval;
13588 let report_errors = opts.stop_on_first_error;
13589 loop {
13590 let mut first_error = None;
13591 self.spin_once_capturing(poll, &mut first_error);
13592 if report_errors && let Some(e) = first_error {
13593 nros_log::log_error!(
13594 nros_log::get_logger("nros"),
13595 "spin_forever: a callback failed ({:?}); `stop_on_first_error` cannot \
13596 end a `-> !` loop, so this is a report, not a stop",
13597 e
13598 );
13599 }
13600 }
13601 }
13602
13603 /// Execute the work that is ready NOW and return — rclcpp's
13604 /// `Executor::spin_some(max_duration)`.
13605 ///
13606 /// ADOPT-BOUNDED (RFC-0089). Upstream's drain verb: collect what is ready,
13607 /// execute it, come back. It never waits for new work — that is
13608 /// [`spin_once`](Self::spin_once), which takes a budget and blocks on the
13609 /// transport until it expires.
13610 ///
13611 /// **Envelope, two ways it is weaker than rclcpp's:**
13612 ///
13613 /// * rclcpp collects the ready set ONCE and executes it. One
13614 /// `spin_once(0)` here is already that whole set (RFC-0002 §3), so this
13615 /// would be a single call — except a callback may enqueue work for
13616 /// another entity, which rclcpp's `spin_all` re-collects and its
13617 /// `spin_some` leaves for the next call. Ours re-drains until a pass does
13618 /// NO work, which is `spin_all`'s answer, because the alternative is
13619 /// work sitting in the arena until someone spins again. Bounded by
13620 /// `max`, so a callback that feeds itself cannot wedge the caller.
13621 /// * `max` is checked BETWEEN passes, never inside one, so it is a floor on
13622 /// when this returns and not a ceiling: a single long callback overruns
13623 /// it. There is no preemption here to make it a ceiling.
13624 ///
13625 /// `max` of [`Duration::ZERO`](core::time::Duration::ZERO) means no limit —
13626 /// rclcpp's own default — and is the spelling a build with no clock must
13627 /// use. A non-zero `max` on a clockless build is an ERROR rather than an
13628 /// unbounded drain (issue 0709, the same rule as `spin`'s `timeout`).
13629 ///
13630 /// The `Result` is about that PRECONDITION and nothing else. A failed
13631 /// callback is counted in each pass's `SpinOnceResult` and does not end the
13632 /// drain, matching `spin`'s default (`SpinOptions::stop_on_first_error` is
13633 /// `false`); rclcpp's `spin_some` returns `void` and reports callback
13634 /// failures nowhere at all.
13635 ///
13636 /// # Example
13637 ///
13638 /// ```ignore
13639 /// // Drain the ready set, then get on with the caller's own work.
13640 /// executor.spin_some(core::time::Duration::ZERO)?;
13641 /// ```
13642 pub fn spin_some(&mut self, max: core::time::Duration) -> Result<(), NodeError> {
13643 let bounded = !max.is_zero();
13644 let start_us = self.now_us();
13645 if bounded && start_us.is_none() {
13646 nros_log::log_error!(
13647 nros_log::get_logger("nros"),
13648 "spin_some: a max duration was requested but this build has no clock \
13649 - install one with `ExecutorConfig::clock_us`, or pass \
13650 `Duration::ZERO` for rclcpp's unbounded default (issue 0709)"
13651 );
13652 return Err(NodeError::NotInitialized);
13653 }
13654 let max_us = max.as_micros().min(u64::MAX as u128) as u64;
13655
13656 // No `enter_spin_loop` here, deliberately: `is_spinning` answers "a
13657 // blocking loop is running that a peer's `cancel()` has to reach", and
13658 // this verb returns on its own. Raising the flag for the length of a
13659 // drain would make a `cancel()` from another task look acted-upon when
13660 // nothing was waiting for it.
13661 loop {
13662 let result = self.spin_once(core::time::Duration::ZERO);
13663 if !result.any_work() {
13664 break;
13665 }
13666 if bounded
13667 && let (Some(start), Some(now)) = (start_us, self.now_us())
13668 && now.saturating_sub(start) >= max_us
13669 {
13670 break;
13671 }
13672 }
13673 Ok(())
13674 }
13675
13676 // ========================================================================
13677 // spin_one_period (no_std)
13678 // ========================================================================
13679
13680 /// Process one iteration and return remaining sleep time.
13681 ///
13682 /// This is `no_std` compatible — the caller is responsible for the actual
13683 /// delay using platform-specific sleep.
13684 ///
13685 /// # Arguments
13686 /// * `period_ms` - Target period in milliseconds
13687 /// * `elapsed_ms` - Time elapsed since last call (used for timer ticking)
13688 ///
13689 /// # Example
13690 ///
13691 /// ```ignore
13692 /// loop {
13693 /// let r = executor.spin_one_period(10, elapsed_ms);
13694 /// platform_sleep_ms(r.remaining_ms);
13695 /// }
13696 /// ```
13697 pub fn spin_one_period(&mut self, period_ms: u64, elapsed_ms: u64) -> SpinPeriodPollingResult {
13698 let result = self.spin_once(core::time::Duration::from_millis(elapsed_ms));
13699 SpinPeriodPollingResult {
13700 work: result,
13701 remaining_ms: period_ms.saturating_sub(elapsed_ms),
13702 }
13703 }
13704}
13705
13706// ============================================================================
13707// Parameter services (cfg param-services)
13708// ============================================================================
13709
13710#[cfg(feature = "param-services")]
13711impl<'s> Executor<'s> {
13712 /// Register the 6 ROS 2 parameter services for this node.
13713 ///
13714 /// Creates service servers for `get_parameters`, `set_parameters`,
13715 /// `set_parameters_atomically`, `list_parameters`, `describe_parameters`,
13716 /// and `get_parameter_types`.
13717 ///
13718 /// The service names follow the ROS 2 convention: `/{namespace}/{node_name}/{suffix}`.
13719 /// For the default namespace `/`, this becomes `/{node_name}/{suffix}` (e.g.
13720 /// `/sentinel/list_parameters`).
13721 ///
13722 /// Parameter services are stored outside the arena and don't consume
13723 /// callback slots.
13724 ///
13725 /// Issue 2023 — every node served this way also PUBLISHES
13726 /// `/parameter_events` (`rcl_interfaces/msg/ParameterEvent`), rclcpp's and
13727 /// rclrs's default: a start-up event, then one event per declare, set and
13728 /// undeclare. One VOLATILE publisher per executor
13729 /// ([`PARAM_EVENT_PUBLISHERS`](crate::parameter_services::PARAM_EVENT_PUBLISHERS)).
13730 /// A node opts out with
13731 /// [`disable_parameter_events_on`](Self::disable_parameter_events_on)
13732 /// (rclc's `notify_changed_over_dds = false`).
13733 ///
13734 /// # Example
13735 ///
13736 /// ```ignore
13737 /// let config = ExecutorConfig::from_env().node_name("talker");
13738 /// let mut executor = Executor::open(&config)?;
13739 /// executor.register_parameter_services()?;
13740 /// executor.declare_parameter("start_value", ParameterValue::Integer(0));
13741 /// ```
13742 pub fn register_parameter_services(&mut self) -> Result<(), NodeError> {
13743 // Issue 1706 -- six services over a store that does not exist would
13744 // answer every request with "not declared"; say what went wrong
13745 // instead (`report_parameter_store_refused` has named the size).
13746 if !self.ensure_parameter_store() {
13747 return Err(NodeError::Transport(TransportError::BadAlloc));
13748 }
13749 if let Some(params) = &mut self.params {
13750 params.requested = true;
13751 }
13752 // phase-426 W3 — with no node yet, RECORD the request and stop. The
13753 // reconcile's no-node fallback publishes under the executor's own
13754 // identity, and taking it here would pin node 0 to that name before the
13755 // node that IS node 0 has been built — which is the pre-W3 behaviour
13756 // this work item exists to remove. Every generated entry arrives in
13757 // exactly this state (`apply_param_services` runs ahead of the per-node
13758 // `register` calls, by design), so the fallback belongs on the spin
13759 // path, where "no node" has stopped being merely "not yet".
13760 if self.nodes.is_empty() {
13761 return Ok(());
13762 }
13763 self.reconcile_parameter_services()
13764 }
13765
13766 /// phase-426 W3 — is there a node whose six services are not up yet?
13767 ///
13768 /// The cheap half of the reconcile, kept separate so `spin_once` can test
13769 /// it without carrying the reconcile's frame (see below).
13770 #[inline]
13771 pub(crate) fn parameter_services_pending(&self) -> bool {
13772 self.params.as_deref().is_some_and(|p| {
13773 p.requested
13774 && p.services.len() < self.nodes.len().max(1)
13775 // Issue 1268 — a failure that cannot change is not pending
13776 // work. This used to retry six `create_service` calls on EVERY
13777 // spin, for the life of an image that would never serve them,
13778 // and the result was discarded; 1271 made it say so once, which
13779 // left the retrying. An explicit
13780 // `register_parameter_services()` still tries, because that is
13781 // a caller asking rather than the spin guessing.
13782 && p.reconcile_failure.as_ref().is_none_or(|f| !f.permanent)
13783 })
13784 }
13785
13786 /// Issue 1271 -- say, once, that the parameter services could not be
13787 /// created, instead of discarding the result on every spin.
13788 ///
13789 /// The spin keeps retrying (a node registered later, or a transport that
13790 /// comes up late, may still succeed); this only decides whether anyone is
13791 /// told. Cold and out of line: it formats, and `spin_once`'s frame must
13792 /// not carry that.
13793 #[cold]
13794 #[inline(never)]
13795 fn report_parameter_service_reconcile_failure(&mut self, e: NodeError) {
13796 let Some(params) = self.params.as_deref_mut() else {
13797 return;
13798 };
13799 if params.reconcile_failure_reported {
13800 return;
13801 }
13802 params.reconcile_failure_reported = true;
13803 // Issue 1268 — name the node and the service. "could not be created
13804 // ({:?})" named neither, so a reader could not tell a name too long for
13805 // one node from a backend that will serve none of the six.
13806 let (node_fqn, service, permanent) = match params.reconcile_failure.as_ref() {
13807 Some(f) => (f.node_fqn.as_str(), f.service, f.permanent),
13808 None => ("<unknown node>", "<unknown service>", false),
13809 };
13810 nros_log::log_error!(
13811 nros_log::get_logger("nros"),
13812 "parameter services could not be created for node {} at service {}: {:?}. \
13813 `ros2 param` cannot reach {} of this image's {} node(s). {} (issues 1271, 1268).",
13814 node_fqn,
13815 service,
13816 e,
13817 self.nodes.len().max(1) - params.services.len(),
13818 self.nodes.len().max(1),
13819 if permanent {
13820 "Not retrying: this failure cannot change without a rebuild."
13821 } else {
13822 "Retrying on every spin; logged once."
13823 }
13824 );
13825 }
13826
13827 /// phase-426 W3 — publish one set of six under every node that does not
13828 /// have one yet.
13829 ///
13830 /// **Why a reconcile and not a registration.** `nros::main!` emits
13831 /// `apply_param_services` BEFORE its per-node `register` calls, by design:
13832 /// the store has to exist when each component cell captures it. So at the
13833 /// moment the request is made the node table is EMPTY and there is no FQN
13834 /// to publish under — registering there is how the six ended up pinned to
13835 /// the executor's own identity in the first place. The request is recorded
13836 /// and satisfied here, at the head of every spin, exactly as
13837 /// `reconcile_ros_time_source` does for `use_sim_time` and for the same
13838 /// reason.
13839 ///
13840 /// **An executor with no registered node still gets one set**, under its
13841 /// own `node_name`/`namespace` and keyed `NodeId::PRIMARY` — the identity
13842 /// [`declare_parameter`](Self::declare_parameter) writes to. That is the
13843 /// pre-W3 behaviour, preserved for the direct-API images that never call
13844 /// `node_builder` (`param-chatter-talker` is one), and it is keyed the same
13845 /// as the first node record, so a node registered later inherits the set
13846 /// rather than getting a second one under a different name.
13847 ///
13848 /// Cheap in the settled case: one length comparison — but the FRAME is not
13849 /// cheap, so it is `inline(never)` and the spin site tests
13850 /// [`parameter_services_pending`](Self::parameter_services_pending) first.
13851 /// Building one set used to materialise six `EmbeddedServiceServer`s by
13852 /// value (2 x `PARAM_SERVICE_BUFFER_SIZE` each, ~48 KiB at the 4096
13853 /// default) before they were boxed; inlined into `spin_once` that landed
13854 /// on every spin frame whether or not the branch was taken. Issue 1270
13855 /// took the buffers out of the set -- one pair per executor, allocated on
13856 /// the heap directly -- but the set still carries six backend handles and
13857 /// a name, so the frame stays out of `spin_once`. Issue 0756's rule one
13858 /// call up — a parameter-service frame does not fit an embedded thread
13859 /// stack, and `test_open_threaded_spawn_and_halt` died with a bare SIGSEGV
13860 /// the first time this was reachable from the spin path.
13861 #[inline(never)]
13862 pub(crate) fn reconcile_parameter_services(&mut self) -> Result<(), NodeError> {
13863 let Some(params) = self.params.as_deref() else {
13864 return Ok(());
13865 };
13866 if !params.requested {
13867 return Ok(());
13868 }
13869 // The nodes wanted, minus the ones already served. Collected first
13870 // because building a set borrows `self.session` mutably while
13871 // `self.nodes` / `self.params` are still borrowed by the scan.
13872 let want = self.nodes.len().max(1);
13873 if params.services.len() >= want {
13874 return Ok(());
13875 }
13876 let mut todo: heapless::Vec<
13877 (u8, heapless::String<64>, heapless::String<64>),
13878 { crate::param_sizing::MAX_SERVICE_SETS },
13879 > = heapless::Vec::new();
13880 for index in 0..want {
13881 let key = nros_params::NodeKey::new(index as u8);
13882 if params.services.iter().any(|set| set.param_node() == key) {
13883 continue;
13884 }
13885 let (name, namespace) = match self.nodes.get(index) {
13886 Some(record) => (record.name.clone(), record.namespace.clone()),
13887 // No node table entry: the executor's own implicit primary
13888 // identity, which is what `NodeId::PRIMARY` means.
13889 None => (self.node_name.clone(), self.namespace.clone()),
13890 };
13891 if todo.push((index as u8, name, namespace)).is_err() {
13892 return Err(NodeError::NodeTableFull);
13893 }
13894 }
13895 // Issue 1270 -- the ONE buffer pair every set is answered through,
13896 // allocated with the first set so an image that declares parameters
13897 // and serves none pays nothing for it. `params` is `Some` here: the
13898 // scan above returned early otherwise.
13899 // Issue 1812 -- a carved home hands its pair over with the state, so
13900 // this is reached only on the heap fallback.
13901 if let Some(params) = self.params.as_deref_mut()
13902 && params.buffers.is_none()
13903 {
13904 // Issue 1551 — an exhausted heap is an error this fn returns, not
13905 // an abort through libstd's OOM hook.
13906 #[cfg(feature = "alloc")]
13907 {
13908 params.buffers = Some(
13909 crate::parameter_services::ParamServiceBuffers::try_with_capacity(
13910 crate::parameter_services::param_service_buffer_bytes(),
13911 )
13912 .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
13913 );
13914 }
13915 #[cfg(not(feature = "alloc"))]
13916 return Err(NodeError::Transport(TransportError::BadAlloc));
13917 }
13918 for (index, name, namespace) in todo {
13919 let servers = match self.build_parameter_service_set(
13920 nros_params::NodeKey::new(index),
13921 &name,
13922 &namespace,
13923 ) {
13924 Ok(servers) => servers,
13925 Err((e, service)) => {
13926 // Issue 1268 — keep WHAT failed and WHERE, for the one log
13927 // line, and decide once whether retrying could ever help.
13928 // `Unsupported` from the transport is a property of the
13929 // build (the backend has no descriptor for the
13930 // `rcl_interfaces` types), so a later spin asks the same
13931 // question and gets the same answer.
13932 let permanent = matches!(e, NodeError::Transport(TransportError::Unsupported));
13933 if let Some(params) = self.params.as_deref_mut() {
13934 let mut node_fqn = heapless::String::<256>::new();
13935 if let Ok(fqn) = crate::names::fully_qualified_name(&name, &namespace) {
13936 let _ = node_fqn.push_str(fqn.as_str());
13937 }
13938 params.reconcile_failure =
13939 Some(crate::parameter_services::ParamServiceReconcileFailure {
13940 node_fqn,
13941 service,
13942 permanent,
13943 });
13944 }
13945 return Err(e);
13946 }
13947 };
13948 // Issue 1812 -- the set lives INLINE in the (carved) state now, not
13949 // in a box of its own.
13950 // Issue 0745 — PRESERVE an already-initialized store: launch-param
13951 // seeding may have run before any node existed (services can't be
13952 // registered that early). Overwriting here would wipe the seeds.
13953 match &mut self.params {
13954 Some(params) => params
13955 .services
13956 .push(servers)
13957 .map_err(|_| NodeError::NodeTableFull)?,
13958 None => {
13959 // Issue 0756 — see new_param_state: never build a ParamState
13960 // by value, it does not fit an embedded thread stack.
13961 // Issue 1706 -- the store is fallible; the refusal is
13962 // reported once by `ensure_parameter_store`'s spelling.
13963 if self.param_store_refused {
13964 return Err(NodeError::Transport(TransportError::BadAlloc));
13965 }
13966 let Some(mut state) = Self::new_param_state(
13967 core::mem::take(&mut self.param_slots),
13968 core::mem::take(&mut self.param_home),
13969 ) else {
13970 self.param_store_refused = true;
13971 report_parameter_store_refused();
13972 return Err(NodeError::Transport(TransportError::BadAlloc));
13973 };
13974 state.requested = true;
13975 let _ = state.services.push(servers);
13976 self.params = Some(state);
13977 }
13978 }
13979 // phase-430 W2 — "declare `use_sim_time` on EVERY node, as rclcpp
13980 // does", said where the nodes finally exist. This is the only place
13981 // that knows the full set: `ensure_parameter_store` runs before any
13982 // node record does, so it can only seed the implicit PRIMARY, and
13983 // the further nodes an image composes onto one executor arrive
13984 // later. Seeding as each set of six goes up keeps the two in step —
13985 // the parameter is declared on exactly the nodes a
13986 // `ros2 param set <node> use_sim_time true` can reach.
13987 //
13988 // Idempotent by construction: `declare` refuses a name the node
13989 // already holds and the placeholder flag is only raised on
13990 // acceptance, so re-seeding PRIMARY here neither duplicates the
13991 // entry nor re-marks an application's own declaration as ours.
13992 self.seed_use_sim_time_default(super::node_record::NodeId::from_raw(index));
13993 // Issue 2023 — and `/parameter_events`, as rclcpp's and rclrs's
13994 // node does from its constructor. Seeded AFTER `use_sim_time`, so
13995 // the node's start-up event names it, as upstream's does.
13996 self.start_parameter_events_for_served_node(super::node_record::NodeId::from_raw(
13997 index,
13998 ));
13999 }
14000 // Issue 1271 -- every node is served now, so a later failure (a node
14001 // registered after this one) is news again and earns its own line.
14002 if let Some(params) = self.params.as_deref_mut() {
14003 params.reconcile_failure_reported = false;
14004 // Issue 1268 -- and drop the record with it, so a later failure is
14005 // reported against the node it actually happened on.
14006 params.reconcile_failure = None;
14007 }
14008 Ok(())
14009 }
14010
14011 /// phase-426 W3 — create the six service servers for ONE node.
14012 ///
14013 /// Split out of [`register_parameter_services`](Self::register_parameter_services)
14014 /// because there are now N of these per image rather than one, and because
14015 /// the six `create_param_srv` call sites are what
14016 /// `check-infra-queryable-counts` reads to hold `PARAM_SERVICE_QUERYABLES`
14017 /// to the number of queryables a set actually claims.
14018 #[inline(never)]
14019 fn build_parameter_service_set(
14020 &mut self,
14021 node: nros_params::NodeKey,
14022 node_name: &str,
14023 namespace: &str,
14024 ) -> Result<crate::parameter_services::ParameterServiceServers, (NodeError, &'static str)> {
14025 use crate::parameter_services::{
14026 DescribeParameters, GetParameterTypes, GetParameters, ListParameters,
14027 ParamServiceHandle as PSrv, ParameterServiceServers, SetParameters,
14028 SetParametersAtomically,
14029 };
14030 use nros_core::RosService;
14031
14032 // The node FQN, from the one implementation. This was the third
14033 // hand-rolled copy of the same join (`crate::names::fully_qualified_name`
14034 // is the first, `Node::fully_qualified_name` was the second) — and the
14035 // three did not agree: this one absolutised a bare namespace, that one
14036 // did not. A join with three spellings has no answer to "what does the
14037 // tree do with `my_ns`".
14038 let node_fqn = crate::names::fully_qualified_name(node_name, namespace)
14039 .map_err(|()| (NodeError::NameTooLong, "get_parameters"))?;
14040 // The service names below still take the two halves separately.
14041 let ns: &str = namespace;
14042 let nn: &str = node_name;
14043
14044 /// Build a service name like `{node_fqn}/{suffix}` and create the server handle.
14045 ///
14046 /// Issue 1268 — the error carries the SUFFIX, so a failure can name the
14047 /// service a tool would have called instead of "one of six".
14048 fn create_param_srv<Svc>(
14049 session: &mut session::ConcreteSession,
14050 domain_id: u32,
14051 node_fqn: &str,
14052 namespace: &str,
14053 node_name: &str,
14054 suffix: &'static str,
14055 ) -> Result<session::RmwServiceServer, (NodeError, &'static str)>
14056 where
14057 Svc: RosService,
14058 Svc::Request: crate::rmw_type_registry::MessageForRmw,
14059 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
14060 {
14061 // Issue 1268 — REGISTER THE TYPES FIRST, exactly as a typed user
14062 // service does (`register_service_sized`). A backend that keys
14063 // topics on a registered type descriptor — Cyclone does — fails
14064 // `create_service` with `Unsupported` for a type it has never been
14065 // told about, and nothing else in the image registers the
14066 // `rcl_interfaces` service types: the generated typesupport of a
14067 // downstream covers ITS OWN message packages, and the backend bakes
14068 // in only `ParticipantEntitiesInfo` for the graph. So the six
14069 // parameter services failed on every spin while the image's own
14070 // services worked, which is what made the cause look like "no
14071 // service support" for two phases (the issue-0745 comment).
14072 //
14073 // A no-op on backends that need no descriptors (zenoh, xrce), which
14074 // is why zenoh served this same image all along.
14075 let register = |e: NodeError| (e, suffix);
14076 crate::rmw_type_registry::register_type::<Svc::Request>().map_err(register)?;
14077 crate::rmw_type_registry::register_type::<Svc::Reply>().map_err(register)?;
14078 let mut name = heapless::String::<256>::new();
14079 name.push_str(node_fqn)
14080 .map_err(|_| (NodeError::NameTooLong, suffix))?;
14081 name.push_str("/")
14082 .map_err(|_| (NodeError::NameTooLong, suffix))?;
14083 name.push_str(suffix)
14084 .map_err(|_| (NodeError::NameTooLong, suffix))?;
14085 let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
14086 // issue 0824 follow-up — `domain_id` is a PARAMETER, not `self.domain_id`:
14087 // this is a nested `fn`, not a method, so `self` is not in scope and
14088 // the original spelling was E0434. It only fails under feature sets
14089 // that compile this arm, which is why `--all-features` caught it and
14090 // the narrower lanes did not.
14091 .with_domain(domain_id)
14092 .with_namespace(namespace);
14093 if !node_name.is_empty() {
14094 info = info.with_node_name(node_name);
14095 }
14096 // issue 0793 — the parameter services get the PARAMETER profile
14097 // (`rmw_qos_profile_parameters`: KEEP_LAST(1000), reliable,
14098 // volatile), not the generic services one. `QOS_PROFILE_PARAMETERS`
14099 // existed with the right depth and had no caller, so every parameter
14100 // server ran on a depth-10 queue while ROS 2 gives them 1000 — which
14101 // matters exactly when a tool sets many parameters at once, the case
14102 // the deep queue is for.
14103 session
14104 .create_service(&info, QoSProfile::parameter_services_default())
14105 .map_err(|e| (NodeError::Transport(e), suffix))
14106 }
14107
14108 let get_handle = create_param_srv::<GetParameters>(
14109 &mut self.session,
14110 self.domain_id,
14111 &node_fqn,
14112 ns,
14113 nn,
14114 "get_parameters",
14115 )?;
14116 let set_handle = create_param_srv::<SetParameters>(
14117 &mut self.session,
14118 self.domain_id,
14119 &node_fqn,
14120 ns,
14121 nn,
14122 "set_parameters",
14123 )?;
14124 let set_atomic_handle = create_param_srv::<SetParametersAtomically>(
14125 &mut self.session,
14126 self.domain_id,
14127 &node_fqn,
14128 ns,
14129 nn,
14130 "set_parameters_atomically",
14131 )?;
14132 let list_handle = create_param_srv::<ListParameters>(
14133 &mut self.session,
14134 self.domain_id,
14135 &node_fqn,
14136 ns,
14137 nn,
14138 "list_parameters",
14139 )?;
14140 let desc_handle = create_param_srv::<DescribeParameters>(
14141 &mut self.session,
14142 self.domain_id,
14143 &node_fqn,
14144 ns,
14145 nn,
14146 "describe_parameters",
14147 )?;
14148 let types_handle = create_param_srv::<GetParameterTypes>(
14149 &mut self.session,
14150 self.domain_id,
14151 &node_fqn,
14152 ns,
14153 nn,
14154 "get_parameter_types",
14155 )?;
14156
14157 Ok(ParameterServiceServers::new(
14158 // phase-426 W3 — the set carries the node whose parameters it
14159 // serves, so a handler can never read the store without saying
14160 // whose parameters it means.
14161 node,
14162 // Issue 1271 -- carried so a dropped request is reported against
14163 // the service a tool actually called.
14164 node_fqn,
14165 // Issue 1270 -- handles only. The buffers are the executor's one
14166 // shared pair (`ParamState::buffers`), not six per node.
14167 PSrv::<GetParameters>::new(get_handle),
14168 PSrv::<SetParameters>::new(set_handle),
14169 PSrv::<SetParametersAtomically>::new(set_atomic_handle),
14170 PSrv::<ListParameters>::new(list_handle),
14171 PSrv::<DescribeParameters>::new(desc_handle),
14172 PSrv::<GetParameterTypes>::new(types_handle),
14173 ))
14174 }
14175
14176 /// phase-426 W3 — the node FQNs this executor publishes parameter services
14177 /// under, in registration order.
14178 ///
14179 /// The acceptance question for W3 ("does a two-node image expose both
14180 /// FQNs?") answered without a wire: the six services are created from
14181 /// exactly this list, so asserting it asserts what `ros2 param list` will
14182 /// enumerate. The wire assertion is W6.
14183 pub fn parameter_service_node_names(
14184 &self,
14185 ) -> heapless::Vec<crate::names::ResolvedName, { crate::param_sizing::MAX_SERVICE_SETS }> {
14186 let mut out = heapless::Vec::new();
14187 let Some(params) = self.params.as_deref() else {
14188 return out;
14189 };
14190 for set in params.services.iter() {
14191 let index = set.param_node().raw() as usize;
14192 let (name, namespace) = match self.nodes.get(index) {
14193 Some(record) => (record.name.as_str(), record.namespace.as_str()),
14194 None => (self.node_name.as_str(), self.namespace.as_str()),
14195 };
14196 if let Ok(fqn) = crate::names::fully_qualified_name(name, namespace) {
14197 let _ = out.push(fqn);
14198 }
14199 }
14200 out
14201 }
14202
14203 // phase-359 W10 / issue 0080 — ~~`enable_parameter_persistence{,_with}`~~ are
14204 // GONE with the seam they attached. Issue 0080 ruled on-device parameter
14205 // persistence a non-goal in July and listed both by name; nothing in the
14206 // tree ever called them, and the only backend that could be passed
14207 // (~~`FileParamStore`~~) is deleted. Runtime get/set/describe stay — it is the
14208 // PERSISTENCE half that was dropped.
14209}
14210
14211// ============================================================================
14212// Lifecycle services (cfg lifecycle-services)
14213// ============================================================================
14214
14215#[cfg(feature = "lifecycle-services")]
14216impl<'s> Executor<'s> {
14217 /// Register the five REP-2002 lifecycle services on this executor.
14218 ///
14219 /// After this call, `ros2 lifecycle set|get|list|nodes` can drive the
14220 /// stored [`LifecyclePollingNodeCtx`](crate::lifecycle::LifecyclePollingNodeCtx)
14221 /// through the node's lifecycle. The state machine is created fresh
14222 /// (starting in `Unconfigured`); callers register their transition
14223 /// callbacks via [`Executor::lifecycle_state_machine_mut`].
14224 ///
14225 /// # Safety
14226 /// Registered callbacks on the state machine are C FFI function pointers.
14227 /// The caller must keep the callback code and any context it captures
14228 /// valid for as long as the executor processes services.
14229 pub fn register_lifecycle_services(&mut self) -> Result<(), NodeError> {
14230 let ns = self.namespace.clone();
14231 let nn = self.node_name.clone();
14232 self.register_lifecycle_services_named(&ns, &nn)
14233 }
14234
14235 /// [`register_lifecycle_services`](Self::register_lifecycle_services),
14236 /// named after `node` rather than the executor's own identity.
14237 ///
14238 /// phase-488 node 2 (Nav2): a hosted C++ `rclcpp_lifecycle::LifecycleNode`
14239 /// is a node CREATED on the process executor, whose own identity is the
14240 /// default `node`. Registering through the executor served
14241 /// `/node/change_state` beside a phantom `/node`, and `ros2 lifecycle get
14242 /// /velocity_smoother` answered "Node not found". Parameter services were
14243 /// already per node (the reconcile); this is the same for lifecycle.
14244 pub fn register_lifecycle_services_on(
14245 &mut self,
14246 node: super::node_record::NodeId,
14247 ) -> Result<(), NodeError> {
14248 let (ns, nn) = {
14249 let r = self.node(node).ok_or(NodeError::NotInitialized)?;
14250 (r.namespace.clone(), r.name.clone())
14251 };
14252 self.register_lifecycle_services_named(&ns, &nn)
14253 }
14254
14255 fn register_lifecycle_services_named(&mut self, ns: &str, nn: &str) -> Result<(), NodeError> {
14256 use crate::{
14257 lifecycle::LifecyclePollingNodeCtx,
14258 lifecycle_services::{
14259 ChangeState, GetAvailableStates, GetAvailableTransitions, GetState,
14260 LifecycleRuntimeState, LifecycleServiceServers,
14261 },
14262 };
14263 use nros_core::RosService;
14264
14265 // Build the node FQN from namespace + node_name (same convention as
14266 // register_parameter_services).
14267 let mut node_fqn = heapless::String::<256>::new();
14268 if ns.is_empty() || ns == "/" {
14269 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14270 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
14271 } else {
14272 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14273 node_fqn
14274 .push_str(ns.trim_matches('/'))
14275 .map_err(|_| NodeError::NameTooLong)?;
14276 node_fqn.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14277 node_fqn.push_str(nn).map_err(|_| NodeError::NameTooLong)?;
14278 }
14279
14280 fn create_lc_srv<Svc>(
14281 session: &mut session::ConcreteSession,
14282 domain_id: u32,
14283 node_fqn: &str,
14284 namespace: &str,
14285 node_name: &str,
14286 suffix: &str,
14287 ) -> Result<session::RmwServiceServer, NodeError>
14288 where
14289 Svc: RosService,
14290 Svc::Request: crate::rmw_type_registry::MessageForRmw,
14291 Svc::Reply: crate::rmw_type_registry::MessageForRmw,
14292 {
14293 // Issue 1293 — register both halves first, as `create_param_srv`
14294 // does since issue 1268: Cyclone creates a topic only for a type it
14295 // holds a descriptor for. Three of the five request types are
14296 // EMPTY, and an empty schema had no descriptor until codegen gave
14297 // it rosidl's `structure_needs_at_least_one_member` byte. A no-op
14298 // on backends that need no descriptors (zenoh, xrce).
14299 crate::rmw_type_registry::register_type::<Svc::Request>()?;
14300 crate::rmw_type_registry::register_type::<Svc::Reply>()?;
14301 let mut name = heapless::String::<256>::new();
14302 name.push_str(node_fqn)
14303 .map_err(|_| NodeError::NameTooLong)?;
14304 name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14305 name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
14306 let mut info = ServiceInfo::new(&name, Svc::SERVICE_NAME, Svc::SERVICE_HASH)
14307 .with_domain(domain_id)
14308 .with_namespace(namespace);
14309 if !node_name.is_empty() {
14310 info = info.with_node_name(node_name);
14311 }
14312 session
14313 .create_service(&info, QoSProfile::services_default())
14314 .map_err(NodeError::Transport)
14315 }
14316
14317 /// Issue 1587 — the ONE publisher the REP-2002 family claims,
14318 /// `~/transition_event`. Shaped like `create_lc_srv` beside it both
14319 /// because the naming rule is the same (node FQN + suffix) and
14320 /// because `check-infra-queryable-counts` counts the creation sites
14321 /// to hold `LIFECYCLE_SERVICE_PUBLISHERS` to them, exactly as it
14322 /// already holds `LIFECYCLE_SERVICE_QUERYABLES` to the five above.
14323 fn create_lc_pub<Msg: crate::rmw_type_registry::MessageForRmw>(
14324 session: &mut session::ConcreteSession,
14325 domain_id: u32,
14326 node_fqn: &str,
14327 namespace: &str,
14328 node_name: &str,
14329 suffix: &str,
14330 qos: QoSProfile,
14331 ) -> Result<session::RmwPublisher, NodeError> {
14332 // Issue 1293 — same rule as `create_lc_srv`: Cyclone needs the
14333 // `TransitionEvent` descriptor before it can create the topic.
14334 crate::rmw_type_registry::register_type::<Msg>()?;
14335 let mut name = heapless::String::<256>::new();
14336 name.push_str(node_fqn)
14337 .map_err(|_| NodeError::NameTooLong)?;
14338 name.push_str("/").map_err(|_| NodeError::NameTooLong)?;
14339 name.push_str(suffix).map_err(|_| NodeError::NameTooLong)?;
14340 let mut info = TopicInfo::new(
14341 &name,
14342 <Msg as nros_core::RosMessage>::TYPE_NAME,
14343 Msg::TYPE_HASH,
14344 )
14345 .with_domain(domain_id)
14346 .with_namespace(namespace);
14347 if !node_name.is_empty() {
14348 info = info.with_node_name(node_name);
14349 }
14350 session
14351 .create_publisher(&info, qos)
14352 .map_err(NodeError::Transport)
14353 }
14354
14355 let cs_handle = create_lc_srv::<ChangeState>(
14356 &mut self.session,
14357 self.domain_id,
14358 &node_fqn,
14359 ns,
14360 nn,
14361 "change_state",
14362 )?;
14363 let gs_handle = create_lc_srv::<GetState>(
14364 &mut self.session,
14365 self.domain_id,
14366 &node_fqn,
14367 ns,
14368 nn,
14369 "get_state",
14370 )?;
14371 let gas_handle = create_lc_srv::<GetAvailableStates>(
14372 &mut self.session,
14373 self.domain_id,
14374 &node_fqn,
14375 ns,
14376 nn,
14377 "get_available_states",
14378 )?;
14379 let gat_handle = create_lc_srv::<GetAvailableTransitions>(
14380 &mut self.session,
14381 self.domain_id,
14382 &node_fqn,
14383 ns,
14384 nn,
14385 "get_available_transitions",
14386 )?;
14387 let gtg_handle = create_lc_srv::<GetAvailableTransitions>(
14388 &mut self.session,
14389 self.domain_id,
14390 &node_fqn,
14391 ns,
14392 nn,
14393 "get_transition_graph",
14394 )?;
14395
14396 // Issue 1587 — REP-2002's communication interface is the five
14397 // services PLUS this. VOLATILE, so it costs a publisher slot and a
14398 // liveliness token and NO queryable; see
14399 // `LIFECYCLE_SERVICE_PUBLISHERS` for the measurement that settled it.
14400 let te_handle = create_lc_pub::<nros_lifecycle_msgs::msg::TransitionEvent>(
14401 &mut self.session,
14402 self.domain_id,
14403 &node_fqn,
14404 ns,
14405 nn,
14406 crate::lifecycle_services::TRANSITION_EVENT_SUFFIX,
14407 crate::lifecycle_services::transition_event_qos(),
14408 )?;
14409
14410 // Issue 2070 -- built in their allocation, never on this frame.
14411 let servers = LifecycleServiceServers::try_new_boxed([
14412 cs_handle, gs_handle, gas_handle, gat_handle, gtg_handle,
14413 ])
14414 .ok_or(NodeError::Transport(TransportError::BadAlloc))?;
14415
14416 self.lifecycle = Some(alloc::boxed::Box::new(LifecycleRuntimeState {
14417 state_machine: LifecyclePollingNodeCtx::new(),
14418 services: servers,
14419 transition_event: te_handle,
14420 }));
14421
14422 Ok(())
14423 }
14424
14425 /// The `~/transition_event` publisher, for the tests that assert what
14426 /// reached the wire. Not a public capability: the executor announces, and
14427 /// a second publisher on the same topic would be a second source of truth.
14428 /// Its only callers are the mock-session tests, which a `rmw-cffi` build
14429 /// does not compile.
14430 #[cfg(all(test, not(feature = "rmw-cffi")))]
14431 pub(crate) fn lifecycle_transition_event_publisher(&self) -> Option<&session::RmwPublisher> {
14432 self.lifecycle.as_ref().map(|lc| &lc.transition_event)
14433 }
14434
14435 /// Mutable access to the lifecycle state machine, if registered.
14436 ///
14437 /// Used to register transition callbacks before spinning and to read the
14438 /// current state from application code.
14439 pub fn lifecycle_state_machine_mut(
14440 &mut self,
14441 ) -> Option<&mut crate::lifecycle::LifecyclePollingNodeCtx> {
14442 self.lifecycle.as_mut().map(|lc| &mut lc.state_machine)
14443 }
14444
14445 /// Immutable access to the lifecycle state machine, if registered.
14446 pub fn lifecycle_state_machine(&self) -> Option<&crate::lifecycle::LifecyclePollingNodeCtx> {
14447 self.lifecycle.as_ref().map(|lc| &lc.state_machine)
14448 }
14449
14450 /// Register a safe lifecycle node (issue 0335 / phase-317).
14451 ///
14452 /// Binds the five REP-2002 lifecycle services and wires each transition to
14453 /// the [`LifecycleCallbacks`](crate::lifecycle::LifecycleCallbacks) trait
14454 /// impl — the safe Rust counterpart to the C++ `nros::LifecycleNode`. No
14455 /// `unsafe` in user code; alloc-free (monomorphized trampolines).
14456 ///
14457 /// `node` must outlive this executor's lifecycle registration: the context
14458 /// pointer stored here is dereferenced on every transition until the
14459 /// executor is finalized. The executor spins single-threaded, so the
14460 /// `&mut node` reconstituted inside a callback is never aliased.
14461 pub fn register_lifecycle_node<T: crate::lifecycle::LifecycleCallbacks>(
14462 &mut self,
14463 node: &mut T,
14464 ) -> Result<(), NodeError> {
14465 use crate::lifecycle::{LifecycleCallbackSlot as Slot, trampolines};
14466
14467 self.register_lifecycle_services()?;
14468 let sm = self
14469 .lifecycle_state_machine_mut()
14470 .ok_or(NodeError::NotInitialized)?;
14471 sm.set_context(node as *mut T as *mut core::ffi::c_void);
14472 sm.register(Slot::Configure, Some(trampolines::on_configure::<T>));
14473 sm.register(Slot::Activate, Some(trampolines::on_activate::<T>));
14474 sm.register(Slot::Deactivate, Some(trampolines::on_deactivate::<T>));
14475 sm.register(Slot::Cleanup, Some(trampolines::on_cleanup::<T>));
14476 sm.register(Slot::Shutdown, Some(trampolines::on_shutdown::<T>));
14477 sm.register(Slot::Error, Some(trampolines::on_error::<T>));
14478 Ok(())
14479 }
14480}
14481
14482// ============================================================================
14483// phase-446 W6 -- the contract's declared parameters (cfg param-services)
14484// ============================================================================
14485
14486#[cfg(feature = "param-store")]
14487impl<'s> Executor<'s> {
14488 /// Hand the executor the parameters each node's contract declares.
14489 ///
14490 /// The entry calls this before any node registers. It only RECORDS the
14491 /// table: the check runs where an application declares a parameter (the
14492 /// `nros` node runtime), not in [`Self::declare_parameter_on`], because
14493 /// that funnel also carries the launch seed, whose value type is still
14494 /// inferred from text and was already held to the contract by the
14495 /// resolver.
14496 pub fn set_declared_params(&mut self, declared: nros_params::DeclaredParams) {
14497 self.declared_params = declared;
14498 }
14499
14500 /// The table [`Self::set_declared_params`] recorded (empty by default).
14501 pub fn declared_params(&self) -> nros_params::DeclaredParams {
14502 self.declared_params
14503 }
14504}
14505
14506// ============================================================================
14507// Parameter declaration API (cfg param-services)
14508// ============================================================================
14509
14510#[cfg(feature = "param-store")]
14511impl<'s> Executor<'s> {
14512 /// phase-461 W6 — build the parameter STORE and register no service.
14513 ///
14514 /// What an image declaring the `params` axis (and not `param_services`)
14515 /// calls ahead of its nodes: launch seeds and `declare_parameter` land in
14516 /// the store, and `ros2 param` reaches none of it, because no queryable
14517 /// was created. The primary node's `use_sim_time` placeholder is seeded
14518 /// as it is for a served store; nothing can SET it here, which is the
14519 /// axis' stated limit.
14520 ///
14521 /// Idempotent, and a later [`Self::register_parameter_services`] keeps
14522 /// this store and adds the six services on top of it.
14523 pub fn register_parameter_store(&mut self) -> Result<(), NodeError> {
14524 if self.ensure_parameter_store() {
14525 Ok(())
14526 } else {
14527 Err(NodeError::Transport(TransportError::BadAlloc))
14528 }
14529 }
14530
14531 /// Issue 0745 — lazily create the parameter STORE (no services yet).
14532 /// Launch-param seeding runs before any node is constructed; the six
14533 /// service servers attach later in `register_parameter_services`,
14534 /// which preserves this store.
14535 /// `true` when the store exists afterwards. `false` only when the platform
14536 /// heap refused its allocation (issue 1706), which is reported once, here,
14537 /// with the size it asked for.
14538 fn ensure_parameter_store(&mut self) -> bool {
14539 if self.params.is_none() {
14540 if self.param_store_refused {
14541 return false;
14542 }
14543 let Some(state) = Self::new_param_state(
14544 core::mem::take(&mut self.param_slots),
14545 core::mem::take(&mut self.param_home),
14546 ) else {
14547 self.param_store_refused = true;
14548 report_parameter_store_refused();
14549 return false;
14550 };
14551 self.params = Some(state);
14552 // phase-430 W2 — the executor's implicit PRIMARY node exists the
14553 // moment the store does, whether or not a node record has been
14554 // built yet, so it is seeded here. The other node keys are seeded
14555 // in `reconcile_parameter_services`, as each one's set of six goes
14556 // up.
14557 self.seed_use_sim_time_default(super::node_record::NodeId::PRIMARY);
14558 }
14559 true
14560 }
14561
14562 /// phase-430 W2 — declare `use_sim_time = false`, as rclcpp does on EVERY
14563 /// node.
14564 ///
14565 /// Upstream auto-declares the parameter in `rclcpp::Node`'s constructor, so
14566 /// `ros2 param list` shows it and `ros2 param set <node> use_sim_time true`
14567 /// reaches a node whose author never wrote a line about simulated time.
14568 /// Ours honoured the parameter only if the APP had declared it: the store
14569 /// refuses an undeclared set (`allow_undeclared` is false), so the runtime
14570 /// switch worked on exactly the nodes that least needed it.
14571 ///
14572 /// The seed is a PLACEHOLDER, tracked by `ParamState::sim_time_seeded`,
14573 /// because the store refuses a duplicate name: without that flag, seeding
14574 /// here would turn an application's own `declare_parameter("use_sim_time",
14575 /// Bool(true))` into a refusal and the auto-declared default would silently
14576 /// beat the app. `yield_seeded_use_sim_time` steps the placeholder aside
14577 /// for the first real declaration, whatever value or descriptor it carries.
14578 ///
14579 /// PER NODE, both here and in the flag. phase-426 keyed the store and the
14580 /// six services by node, so "every node" is now literally expressible and
14581 /// this is where it is said: `ensure_parameter_store` seeds the executor's
14582 /// implicit PRIMARY, and `reconcile_parameter_services` seeds each further
14583 /// node key as its set of six goes up — which is exactly the set of nodes a
14584 /// `ros2 param set <node> use_sim_time true` can reach.
14585 ///
14586 /// It deliberately does NOT go through `note_reserved_parameter`, so
14587 /// `sim_time_stated` stays false: an auto-declared default is not a
14588 /// STATEMENT by anyone. Recording it as one would make every executor with
14589 /// parameter services write `set_active(false)` onto a PROCESS-GLOBAL gate
14590 /// on its first spin — switching off a simulated clock another executor in
14591 /// the same image installed, which is exactly the collision
14592 /// `sim_time_stated` was added for (issue 1104). That property is per node
14593 /// too: seeding N nodes must still be N statements by nobody.
14594 ///
14595 /// Gated on `sim-time` as well as `param-services`: an image that cannot
14596 /// act on the parameter should not advertise it, because a settable knob
14597 /// that does nothing is worse than an absent one.
14598 fn seed_use_sim_time_default(&mut self, node: super::node_record::NodeId) {
14599 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14600 if let Some(params) = self.params.as_deref_mut()
14601 && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
14602 && params.server.declare(
14603 node.into(),
14604 crate::time_source::USE_SIM_TIME_PARAM,
14605 nros_params::ParameterValue::Bool(false),
14606 )
14607 {
14608 *seeded = true;
14609 }
14610 let _ = node;
14611 }
14612
14613 /// phase-430 W2 — step the seeded `use_sim_time` aside for an application
14614 /// that declares it itself on `node`. Returns whether it did.
14615 ///
14616 /// The app's declaration must WIN — its own default, its own descriptor —
14617 /// and a declaration is refused when the name is already taken, so the
14618 /// placeholder has to leave before the real one arrives. Only ever the
14619 /// placeholder: once a real declaration has landed the flag is false and a
14620 /// second declaration is refused exactly as it was before W2.
14621 ///
14622 /// Only ever THIS node's placeholder, too: an app that names
14623 /// `use_sim_time` on one node of an image has said nothing about the
14624 /// others, and their seeds stay placeholders for whoever declares next.
14625 fn yield_seeded_use_sim_time(&mut self, node: super::node_record::NodeId, name: &str) -> bool {
14626 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
14627 if name == crate::time_source::USE_SIM_TIME_PARAM
14628 && let Some(params) = self.params.as_deref_mut()
14629 && let Some(seeded) = params.sim_time_seeded.get_mut(node.index())
14630 && core::mem::replace(seeded, false)
14631 {
14632 return params.server.remove(node.into(), name);
14633 }
14634 let _ = (node, name);
14635 false
14636 }
14637
14638 /// The HEAP home for the parameter slot table, used only when the
14639 /// executor's backing carved none.
14640 ///
14641 /// phase-382 W2' — `ParameterServer` no longer OWNS its slots; it borrows
14642 /// a caller-placed [`nros_params::ParameterTable`]. phase-382 W3' carves
14643 /// that table out of the caller's executor backing whenever the build
14644 /// KNOWS the image builds a store ([`ExecutorSizing::params`](super::storage::ExecutorSizing::params), from
14645 /// `nros_params::IMPLIED_STORE_SLOTS`); this function is what remains for a
14646 /// store the build could not see — one reached only from application
14647 /// code — and for an executor opened with a sizing that carves none. It
14648 /// makes a single heap allocation, leaked, one per such executor that
14649 /// touches parameters. The leak is what buys the `'static`
14650 /// the borrow needs without making `ParamState` self-referential (a
14651 /// `ParamState` that owned the storage AND a server borrowing it is not
14652 /// expressible), and it is bounded — `ensure_parameter_store` runs this
14653 /// at most once per executor.
14654 ///
14655 /// Issue 0756 — the placement dance is still here because the SIZE is
14656 /// still here; it only moved off `ParameterServer` and onto
14657 /// `ParameterStorage`. That storage is 285,184 bytes at the default
14658 /// `MAX_PARAMETERS=32` and 2,281,472 at 256 (`ParameterValue` is sized by
14659 /// its `StringArray` variant, so every slot costs ~8.5 KiB regardless of
14660 /// what it holds). `Box::new(ParameterStorage::new())` materialises all of
14661 /// that on the caller's stack before copying it into the allocation,
14662 /// because Rust has no placement-new. On the Zephyr lane that silently
14663 /// overran the thread stack: an image built with 256 slots boots to
14664 /// `dds_create_participant` and hangs with no fault and no output, while
14665 /// 32 — which fits the 512 KiB main stack the cyclonedds snippet asks for
14666 /// — runs clean. Initialising through the allocation bounds the largest
14667 /// stack temporary at one slot, so the knob no longer decides whether boot
14668 /// survives.
14669 ///
14670 /// Issue 1706 -- FALLIBLE. This is the one allocation every parameter
14671 /// declaration reaches, in every language and on every RTOS, and it is the
14672 /// largest single request most images make. `Box::new_uninit` sent a
14673 /// failure to the OOM handler: Zephyr printed `HEAP EXHAUSTED` and died in
14674 /// issue 0589's recursion, FreeRTOS heap_4 hung in `MALLOC FAILED`. Every
14675 /// caller already has a refusal to return (`declare_parameter` is a
14676 /// `bool`), so the failure is now one, named by
14677 /// [`report_parameter_store_refused`] with the size it asked for.
14678 ///
14679 /// Issue 1812 -- `alloc` only. Without a heap there is no fallback, and
14680 /// the store is the carved one or none (see `new_param_state`).
14681 #[cfg(feature = "alloc")]
14682 fn leak_parameter_storage() -> Option<nros_params::ParameterTable<'static>> {
14683 #[cfg(all(test, feature = "alloc"))]
14684 if RefuseParameterStore::armed() {
14685 return None;
14686 }
14687 let mut uninit = nros_rmw::fallible::try_box_uninit::<nros_params::ParameterStorage>()?;
14688 // Safety: `try_box_uninit` gives a correctly-sized, correctly-aligned
14689 // allocation for exactly this type, and `init_in_place` writes every
14690 // slot exactly once before `assume_init` observes it.
14691 unsafe {
14692 nros_params::ParameterStorage::init_in_place(uninit.as_mut_ptr());
14693 Some(alloc::boxed::Box::leak(uninit.assume_init()).as_table())
14694 }
14695 }
14696
14697 /// Build the executor's `ParamState`.
14698 ///
14699 /// phase-382 W3' — `carved` are the backing's store slots: non-empty, they
14700 /// ARE the store and the heap is not asked; empty, the store falls back to
14701 /// [`leak_parameter_storage`](Self::leak_parameter_storage).
14702 ///
14703 /// Issue 1812 — and `home` is the services' carved region, which the
14704 /// backing places on the same condition: non-empty, the state, its service
14705 /// sets, the buffer pair and the staging slot are built IN it and nothing
14706 /// here allocates. Otherwise the state is one heap allocation, initialised
14707 /// in place (`ParamState::try_boxed` — it is several KiB now, so never a
14708 /// by-value `Box::new`). Without `alloc` that fallback does not exist and
14709 /// an executor whose backing carved nothing gets `None`, which every caller
14710 /// reports as the named refusal (`report_parameter_store_refused`).
14711 fn new_param_state(
14712 carved: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
14713 home: &'s mut [MaybeUninit<super::storage::ParamHome>],
14714 ) -> Option<crate::param_store::Placed<'s, crate::param_store::ParamState<'s>>> {
14715 let table = if carved.is_empty() {
14716 #[cfg(feature = "alloc")]
14717 {
14718 Self::leak_parameter_storage()?
14719 }
14720 #[cfg(not(feature = "alloc"))]
14721 {
14722 return None;
14723 }
14724 } else {
14725 // Said once per store, so a boot log answers "where did the store
14726 // come from" without a debugger: issue 1706's measurements were
14727 // all heap-peak arithmetic, and this line is the direct answer.
14728 nros_log::log_info!(
14729 nros_log::get_logger("nros"),
14730 "parameter store: {} slots ({} B) + {} ({} B) carved from the executor backing",
14731 carved.len(),
14732 core::mem::size_of_val(carved),
14733 // Issue 1897 -- the home is the store's state, plus the
14734 // services' sets and buffers only where they are compiled.
14735 if cfg!(feature = "param-services") {
14736 "state and services"
14737 } else {
14738 "state"
14739 },
14740 core::mem::size_of_val(home)
14741 );
14742 nros_params::ParameterTable::init_in(carved)
14743 };
14744 let server = nros_params::ParameterServer::new_in(table);
14745 match home.first_mut() {
14746 Some(home) => Some(crate::param_store::ParamHome::init_in(home, server)),
14747 #[cfg(feature = "alloc")]
14748 None => crate::param_store::ParamState::try_boxed(server),
14749 #[cfg(not(feature = "alloc"))]
14750 None => None,
14751 }
14752 }
14753
14754 /// Install `-p` / `--params-file` parameter overrides — phase-482 W5.
14755 ///
14756 /// They are applied when a parameter is DECLARED: the declaration's value
14757 /// is replaced by the override's, typed by it (~~`apply_argv_param_override`~~).
14758 /// So an override reaches a parameter on every road that declares through
14759 /// this executor — C, C++ and Rust — including the launch file's baked
14760 /// values, which the generated entry declares through the same seam.
14761 ///
14762 /// That makes argv WIN over a launch-baked parameter, the opposite of the
14763 /// remap tiers, deliberately: overriding a baked value at run time without
14764 /// a rebuild is what RFC-0015 §9 asks for ("a runtime override has
14765 /// identical effect to a `set_parameter` request after boot"), and upstream
14766 /// agrees — a launch file's parameters reach the node as command-line
14767 /// arguments themselves, where the later one wins.
14768 ///
14769 /// Overrides accumulate in argv order and the LAST one that matches a
14770 /// declaration wins, as rcl's do. A `node:` override matches the node of
14771 /// that NAME; one without matches every node.
14772 ///
14773 /// Fails only when the parameter store could not be allocated (issue 1706),
14774 /// in which case nothing is installed.
14775 #[cfg(feature = "alloc")]
14776 pub fn install_argv_params<'p, I>(&mut self, params: I) -> Result<usize, NodeError>
14777 where
14778 I: IntoIterator<Item = crate::ros_args::ParamArg<'p>>,
14779 {
14780 if !self.ensure_parameter_store() {
14781 return Err(NodeError::Transport(TransportError::BadAlloc));
14782 }
14783 let Some(state) = self.params.as_deref_mut() else {
14784 return Err(NodeError::Transport(TransportError::BadAlloc));
14785 };
14786 let mut n = 0;
14787 for p in params {
14788 state
14789 .argv_overrides
14790 .push(crate::param_store::ArgvParamOverride {
14791 node: p.node.map(alloc::string::String::from),
14792 name: alloc::string::String::from(p.name),
14793 value: alloc::string::String::from(p.value),
14794 });
14795 n += 1;
14796 }
14797 Ok(n)
14798 }
14799
14800 /// The `-p` / `--params-file` overrides that apply to `node`, one per
14801 /// name — issue 2057, for rclcpp's
14802 /// `NodeParametersInterface::get_parameter_overrides()`.
14803 ///
14804 /// The selection is the one a declaration makes
14805 /// (`argv_param_override`): the LAST override of a name that applies
14806 /// wins, and a `node:` override applies only to the node of that name.
14807 /// Each text is typed as a declaration with no default types it (bool,
14808 /// integer, double, then string), because a listing has no declared type
14809 /// to follow. An override no type accepts (a string past the store's
14810 /// bound) is left out. Yields in argv order of the winning override.
14811 #[cfg(all(feature = "param-store", feature = "alloc"))]
14812 pub(crate) fn argv_param_overrides_on(
14813 &self,
14814 node: super::node_record::NodeId,
14815 ) -> impl Iterator<Item = (&str, nros_params::ParameterValue)> + '_ {
14816 let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14817 let list: &[crate::param_store::ArgvParamOverride] = self
14818 .params
14819 .as_deref()
14820 .map(|s| s.argv_overrides.as_slice())
14821 .unwrap_or(&[]);
14822 let applies = move |o: &crate::param_store::ArgvParamOverride| {
14823 o.node.as_deref().is_none_or(|n| n == node_name)
14824 };
14825 list.iter()
14826 .enumerate()
14827 .filter(move |(i, o)| {
14828 applies(o) && !list[i + 1..].iter().any(|l| l.name == o.name && applies(l))
14829 })
14830 .filter_map(|(_, o)| {
14831 typed_param_override(&o.value, &nros_params::ParameterValue::NotSet)
14832 .map(|v| (o.name.as_str(), v))
14833 })
14834 }
14835
14836 /// [`argv_param_overrides_on`](Self::argv_param_overrides_on), one call of
14837 /// `f` per override with the typed value BORROWED (issue 2104). An
14838 /// iterator hands each ~8 KiB value out by value through its `next`
14839 /// frame and then into the caller's; here ONE value is refilled per
14840 /// override (issue 2118), which took `nros_cpp_node_list_param_overrides`
14841 /// from 17.3 KiB to 9.0 KiB on Cortex-M3.
14842 #[cfg(all(feature = "param-store", feature = "alloc"))]
14843 pub(crate) fn for_each_argv_param_override_on(
14844 &self,
14845 node: super::node_record::NodeId,
14846 mut f: impl FnMut(&str, &nros_params::ParameterValue),
14847 ) -> usize {
14848 let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14849 let list: &[crate::param_store::ArgvParamOverride] = self
14850 .params
14851 .as_deref()
14852 .map(|s| s.argv_overrides.as_slice())
14853 .unwrap_or(&[]);
14854 let applies = |o: &crate::param_store::ArgvParamOverride| {
14855 o.node.as_deref().is_none_or(|n| n == node_name)
14856 };
14857 // Issue 2118 -- ONE value for the whole walk, refilled per override.
14858 let mut value = nros_params::ParameterValue::NotSet;
14859 let mut n = 0;
14860 for (i, o) in list.iter().enumerate() {
14861 if !applies(o) || list[i + 1..].iter().any(|l| l.name == o.name && applies(l)) {
14862 continue;
14863 }
14864 if typed_param_override_into(&o.value, &nros_params::ParameterValue::NotSet, &mut value)
14865 {
14866 f(o.name.as_str(), &value);
14867 n += 1;
14868 }
14869 }
14870 n
14871 }
14872
14873 /// Issue 2070 — does an argv override name this parameter? The cheap
14874 /// question [`declare_parameter_on_ref`](Self::declare_parameter_on_ref)
14875 /// asks before it enters the frame that types one.
14876 #[cfg(not(feature = "alloc"))]
14877 fn has_argv_param_override(&self, _node: super::node_record::NodeId, _name: &str) -> bool {
14878 false
14879 }
14880
14881 #[cfg(feature = "alloc")]
14882 fn has_argv_param_override(&self, node: super::node_record::NodeId, name: &str) -> bool {
14883 let Some(state) = self.params.as_deref() else {
14884 return false;
14885 };
14886 if state.argv_overrides.is_empty() {
14887 return false;
14888 }
14889 let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14890 state
14891 .argv_overrides
14892 .iter()
14893 .any(|o| o.name == name && o.node.as_deref().is_none_or(|n| n == node_name))
14894 }
14895
14896 /// The value the last matching argv override gives a declaration of
14897 /// `name` on `node`, typed by `declared` and written into `out`; `false`
14898 /// when no override applies and `declared` stands.
14899 ///
14900 /// An override whose text does not parse as the declared type is NOT
14901 /// applied: it is logged at error severity, naming the parameter, the text
14902 /// and the type, and the declared default stands (RFC-0015 §9.6 — "log +
14903 /// ignore, never panic"). An array-typed declaration takes no override yet.
14904 ///
14905 /// Issue 2104 -- `declared` is borrowed and only the override is
14906 /// produced. Taking a clone of it and handing back a `(value, bool)` held
14907 /// three ~8 KiB values across two frames (25.5 KiB on Cortex-M3). Issue
14908 /// 2118 -- and the override is written into the caller's slot rather than
14909 /// returned, which held two (17 KiB).
14910 #[cfg(not(feature = "alloc"))]
14911 fn argv_param_override(
14912 &self,
14913 _node: super::node_record::NodeId,
14914 _name: &str,
14915 _declared: &nros_params::ParameterValue,
14916 _out: &mut nros_params::ParameterValue,
14917 ) -> bool {
14918 // Issue 1812 -- argv overrides are a hosted-edge input installed
14919 // through `install_argv_params`, which is `alloc`-only.
14920 false
14921 }
14922
14923 #[cfg(feature = "alloc")]
14924 fn argv_param_override(
14925 &self,
14926 node: super::node_record::NodeId,
14927 name: &str,
14928 declared: &nros_params::ParameterValue,
14929 out: &mut nros_params::ParameterValue,
14930 ) -> bool {
14931 let Some(state) = self.params.as_deref() else {
14932 return false;
14933 };
14934 if state.argv_overrides.is_empty() {
14935 return false;
14936 }
14937 let node_name = self.node(node).map(|r| r.name.as_str()).unwrap_or("");
14938 let Some(o) = state
14939 .argv_overrides
14940 .iter()
14941 .rev()
14942 .find(|o| o.name == name && o.node.as_deref().is_none_or(|n| n == node_name))
14943 else {
14944 return false;
14945 };
14946 let typed = typed_param_override_into(&o.value, declared, out);
14947 if !typed {
14948 nros_log::log_error!(
14949 nros_log::get_logger("nros"),
14950 "parameter override `{}:={}` is not a {:?}; the declared default stands",
14951 name,
14952 o.value,
14953 declared.param_type()
14954 );
14955 }
14956 typed
14957 }
14958
14959 /// Declare a parameter with a value on the executor's PRIMARY node.
14960 ///
14961 /// phase-426 W1 — the store is keyed by node, and this is the spelling for
14962 /// the executor's implicit primary node (`NodeId::PRIMARY`), which is the
14963 /// identity `register_parameter_services` publishes and the one every
14964 /// single-node entry means. Name a different node with
14965 /// [`declare_parameter_on`](Self::declare_parameter_on).
14966 ///
14967 /// phase-428 W6 — `#[must_use]`, for the reason `[[nodiscard]]` went on
14968 /// C++'s `Result`: the `else { false }` arm is silently discarded at a
14969 /// call site that drops the value, and `Node::logger` two files over
14970 /// already carries the attribute — so this was inconsistency, not house
14971 /// style. A declare that failed and was ignored is a parameter the program
14972 /// believes it has.
14973 #[must_use]
14974 #[deprecated(
14975 since = "0.6.0",
14976 note = "the node is the parameter receiver (phase-515 R10): `node.declare_parameter::<T>(name).default(value).mandatory()` on the `Node` that `create_node` / `node_mut(id)` returns"
14977 )]
14978 pub fn declare_parameter(&mut self, name: &str, value: nros_params::ParameterValue) -> bool {
14979 self.declare_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
14980 }
14981
14982 /// Declare a parameter with a value on a named node.
14983 ///
14984 /// `#[must_use]` for the reason [`declare_parameter`](Self::declare_parameter)
14985 /// carries it: issue 1257's two dropped results moved to THIS method when
14986 /// they were fixed, so the attribute on the wrapper alone guarded nothing
14987 /// a launch-seed site could still drop.
14988 #[must_use]
14989 pub(crate) fn declare_parameter_on(
14990 &mut self,
14991 node: super::node_record::NodeId,
14992 name: &str,
14993 value: nros_params::ParameterValue,
14994 ) -> bool {
14995 self.declare_parameter_on_ref(node, name, &value)
14996 }
14997
14998 /// [`declare_parameter_on`](Self::declare_parameter_on), borrowing the
14999 /// value — issue 2070, and THE declare path the by-value spelling forwards
15000 /// to. A `ParameterValue` is ~8 KiB at the default capacities, and moving
15001 /// it through each frame of the declare path cost ~57 KiB of stack for one
15002 /// `declare_parameter<double>` (aarch64; ~67 KiB on Cortex-M3). Borrowed,
15003 /// it is copied once, into its slot.
15004 #[must_use]
15005 pub(crate) fn declare_parameter_on_ref(
15006 &mut self,
15007 node: super::node_record::NodeId,
15008 name: &str,
15009 value: &nros_params::ParameterValue,
15010 ) -> bool {
15011 self.ensure_parameter_store();
15012 // The argv override is a hosted-edge input and rare; its frame (which
15013 // holds the typed override) is entered only when one applies, so the
15014 // common path carries no second value.
15015 if self.has_argv_param_override(node, name) {
15016 return self.declare_parameter_overridden(node, name, value);
15017 }
15018 self.declare_parameter_resolved(node, name, value)
15019 }
15020
15021 /// Issue 2070 — the override branch of
15022 /// [`declare_parameter_on_ref`](Self::declare_parameter_on_ref), in its
15023 /// own frame.
15024 #[inline(never)]
15025 fn declare_parameter_overridden(
15026 &mut self,
15027 node: super::node_record::NodeId,
15028 name: &str,
15029 declared: &nros_params::ParameterValue,
15030 ) -> bool {
15031 // Issue 2118 -- the frame's one value, filled in place.
15032 let mut value = nros_params::ParameterValue::NotSet;
15033 if self.argv_param_override(node, name, declared, &mut value) {
15034 self.declare_parameter_resolved(node, name, &value)
15035 } else {
15036 self.declare_parameter_resolved(node, name, declared)
15037 }
15038 }
15039
15040 /// The store write and the reserved-name hook, for a value whose argv
15041 /// override has already been applied.
15042 fn declare_parameter_resolved(
15043 &mut self,
15044 node: super::node_record::NodeId,
15045 name: &str,
15046 value: &nros_params::ParameterValue,
15047 ) -> bool {
15048 // phase-430 W2 — an application declaring `use_sim_time` itself wins
15049 // over the auto-declared default, so the placeholder leaves first.
15050 let yielded = self.yield_seeded_use_sim_time(node, name);
15051 let accepted = match &mut self.params {
15052 Some(params) => params.server.declare_ref(node.into(), name, value),
15053 None => false,
15054 };
15055 if yielded && !accepted {
15056 // The app's own declaration was refused on its own terms (a full
15057 // store, an ill-formed range). Put the rclcpp default back rather
15058 // than leaving the name undeclared — that is a state neither the
15059 // app nor upstream asked for, and it would silently un-do W2 for
15060 // this node.
15061 self.seed_use_sim_time_default(node);
15062 }
15063 // phase-425 W3b — `use_sim_time` is RESERVED, exactly as in ROS 2: its
15064 // value is not a value the app reads, it is the switch that attaches the
15065 // time source. This is the one seam every language funnels through
15066 // (`nros::main!`'s launch bakes via `apply_param_services`, nros-c's
15067 // the rclc parameter server's `declare`, nros-cpp's `params_shim`), so hooking it
15068 // here covers all of them instead of once per entry path.
15069 //
15070 // phase-430 W3 / issue 1202 — the hook runs AFTER the store's verdict
15071 // and only on acceptance. It used to run first and unconditionally, so
15072 // a re-declaration the store REFUSES (the name is already there) still
15073 // flipped the switch: the store kept `use_sim_time = true` while the
15074 // `/clock` source detached, leaving an executor with sim time recorded
15075 // as on and no source attached, which is neither of the two states the
15076 // parameter can name.
15077 if accepted {
15078 self.note_reserved_parameter(name);
15079 }
15080 accepted
15081 }
15082
15083 /// phase-425 W3b — record a reserved parameter's effect. Today that is
15084 /// `use_sim_time` and nothing else: it is the tree's only reserved name,
15085 /// and [`time_source::USE_SIM_TIME_PARAM`](crate::time_source::USE_SIM_TIME_PARAM)
15086 /// is the only such constant.
15087 ///
15088 /// Called only after the store ACCEPTED a declaration, and it reads the
15089 /// value back OUT of the store instead of taking the caller's: what the
15090 /// switch must follow is what the store holds, and the two are the same
15091 /// thing only while every write succeeds (issue 1202). The read is
15092 /// [`refresh_use_sim_time_from_store`](Self::refresh_use_sim_time_from_store),
15093 /// the same one the runtime `ros2 param set` path uses, so the declare path
15094 /// and the wire path cannot disagree about the value.
15095 ///
15096 /// A non-bool `use_sim_time` is IGNORED rather than rejected: parameter
15097 /// declaration has no channel to report a complaint on (it returns "did the
15098 /// store take it"), and refusing the declaration outright would fail a node
15099 /// for a parameter ROS 2 lets it declare. The time source simply does not
15100 /// attach, which is the same outcome as `false`.
15101 ///
15102 /// Not covered: [`Executor::parameter`](Self::parameter)'s
15103 /// [`ParameterBuilder`](nros_params::ParameterBuilder) borrows the server
15104 /// and declares through it, so the executor never sees the write and
15105 /// `use_sim_time` named that way attaches nothing — issue 1203.
15106 /// Issue 1203 — finish a `use_sim_time` that went through
15107 /// [`parameter`](Self::parameter)'s builder.
15108 ///
15109 /// The builder held the store while it declared, so nothing told the
15110 /// executor; this is the spin after, reading the store back. A builder that
15111 /// declared the name gets the reserved-parameter hook a `declare_parameter`
15112 /// would have run. One that was dropped without declaring leaves the node
15113 /// with no `use_sim_time`, because `parameter_on` stepped the placeholder
15114 /// aside for it, so the placeholder goes back.
15115 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15116 fn settle_builder_reserved_parameters(&mut self) {
15117 let Some(params) = self.params.as_deref_mut() else {
15118 return;
15119 };
15120 if !params.sim_time_via_builder.contains(&true) {
15121 return;
15122 }
15123 let pending = core::mem::replace(
15124 &mut params.sim_time_via_builder,
15125 [false; crate::param_sizing::MAX_SERVICE_SETS],
15126 );
15127 let mut declared = false;
15128 for (index, _) in pending.iter().enumerate().filter(|(_, p)| **p) {
15129 let node = super::node_record::NodeId(index as u8);
15130 let present = self.params.as_deref().is_some_and(|p| {
15131 p.server
15132 .get(node.into(), crate::time_source::USE_SIM_TIME_PARAM)
15133 .is_some()
15134 });
15135 if present {
15136 declared = true;
15137 } else {
15138 self.seed_use_sim_time_default(node);
15139 }
15140 }
15141 if declared {
15142 self.note_reserved_parameter(crate::time_source::USE_SIM_TIME_PARAM);
15143 }
15144 }
15145
15146 fn note_reserved_parameter(&mut self, name: &str) {
15147 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15148 if name == crate::time_source::USE_SIM_TIME_PARAM {
15149 self.refresh_use_sim_time_from_store();
15150 }
15151 let _ = name;
15152 }
15153
15154 /// phase-425 W3b — a parameter service handled `n` requests this spin.
15155 ///
15156 /// The hook exists so `use_sim_time` can be re-read after a runtime
15157 /// `ros2 param set`, WITHOUT a per-spin scan of the store. It takes the
15158 /// count unconditionally and ignores it when `sim-time` is off, rather than
15159 /// the call sites carrying the feature test: `handled` was then assigned and
15160 /// never read in the `param-services`-without-`sim-time` combo, which is a
15161 /// `-D warnings` error `check-build` catches and the narrower per-crate
15162 /// clippy runs do not.
15163 #[inline]
15164 fn note_param_services_ran(&mut self, handled: usize) {
15165 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15166 if handled > 0 {
15167 self.refresh_use_sim_time_from_store();
15168 }
15169 let _ = handled;
15170 self.report_truncated_descriptions();
15171 #[cfg(feature = "param-services")]
15172 self.flush_parameter_events();
15173 }
15174
15175 /// phase-446 F2 -- log each parameter whose description did not fit
15176 /// `NROS_MAX_PARAM_DESCRIPTION_LEN`, once, naming the knob.
15177 ///
15178 /// `nros-params` has no logger, so it truncates, records the truncation on
15179 /// the stored descriptor, and hands it over here. Asked on every spin
15180 /// because a declaration can arrive through builders this executor never
15181 /// sees the end of (`parameter::<T>()` returns the store's own builder);
15182 /// while nothing is pending the store answers from one flag, without
15183 /// walking its table.
15184 #[cold]
15185 fn report_truncated_descriptions(&mut self) {
15186 let Some(params) = self.params.as_deref_mut() else {
15187 return;
15188 };
15189 let nodes = &self.nodes;
15190 params.server.take_truncated_descriptions(|key, name| {
15191 let (ns, node) = nodes
15192 .get(key.index())
15193 .map_or(("", "?"), |r| (r.namespace.as_str(), r.name.as_str()));
15194 nros_log::log_warn!(
15195 nros_log::get_logger("nros"),
15196 "parameter `{}` on node {}/{}: its description is longer than \
15197 NROS_MAX_PARAM_DESCRIPTION_LEN ({} bytes), so `ros2 param describe` \
15198 answers it truncated at a character boundary. Raise \
15199 NROS_MAX_PARAM_DESCRIPTION_LEN to keep it whole; 0 stores no \
15200 descriptions. Logged once for this parameter (phase-446 F2).",
15201 name,
15202 ns.trim_end_matches('/'),
15203 node,
15204 nros_params::MAX_PARAM_DESCRIPTION_LEN
15205 );
15206 });
15207 }
15208
15209 /// Declare a parameter with a value and descriptor on the PRIMARY node.
15210 /// Returns `true` if successful.
15211 ///
15212 /// phase-428 W6 remainder — the sibling of [`Self::declare_parameter`],
15213 /// with the identical `else { false }` arm and no attribute. W6 marked the
15214 /// one method its audit named; this is the site three lines below it, which
15215 /// is the shape CLAUDE.md's "fix the CLASS, not the reported site" rule
15216 /// exists for.
15217 #[must_use]
15218 #[deprecated(
15219 since = "0.6.0",
15220 note = "the node is the parameter receiver (phase-515 R10): `node.declare_parameter_with_descriptor(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15221 )]
15222 pub fn declare_parameter_with_descriptor(
15223 &mut self,
15224 name: &str,
15225 value: nros_params::ParameterValue,
15226 descriptor: nros_params::ParameterDescriptor,
15227 ) -> bool {
15228 self.declare_parameter_with_descriptor_on(
15229 super::node_record::NodeId::PRIMARY,
15230 name,
15231 value,
15232 descriptor,
15233 )
15234 }
15235
15236 /// Declare a parameter with a value and descriptor on a named node.
15237 pub(crate) fn declare_parameter_with_descriptor_on(
15238 &mut self,
15239 node: super::node_record::NodeId,
15240 name: &str,
15241 value: nros_params::ParameterValue,
15242 descriptor: nros_params::ParameterDescriptor,
15243 ) -> bool {
15244 self.ensure_parameter_store();
15245 // phase-430 W2 — same rule as the sibling above: the app's own
15246 // declaration, descriptor and all, replaces the auto-declared default.
15247 let yielded = self.yield_seeded_use_sim_time(node, name);
15248 // Issue 2070 — borrowed, as on the sibling path; the override branch
15249 // has its own frame.
15250 let accepted = if self.has_argv_param_override(node, name) {
15251 self.declare_described_overridden(node, name, &value, descriptor)
15252 } else {
15253 match &mut self.params {
15254 Some(params) => params
15255 .server
15256 .try_declare_ref(node.into(), name, &value, Some(descriptor))
15257 .is_ok(),
15258 None => false,
15259 }
15260 };
15261 if yielded && !accepted {
15262 self.seed_use_sim_time_default(node);
15263 }
15264 // phase-430 W3 / issue 1202 — the sibling declare path, which had NO
15265 // reserved hook at all: `declare_parameter_with_descriptor` stored
15266 // `use_sim_time` and attached nothing, so the same declaration meant
15267 // two different things depending on whether the app passed a
15268 // descriptor. Same seam, same verdict ordering, one shared hook.
15269 if accepted {
15270 self.note_reserved_parameter(name);
15271 }
15272 accepted
15273 }
15274
15275 /// Issue 2070 — the override branch of
15276 /// [`declare_parameter_with_descriptor_on`](Self::declare_parameter_with_descriptor_on),
15277 /// in its own frame.
15278 ///
15279 /// phase-482 W5 — an argv override that the DESCRIPTOR refuses (out of
15280 /// range, or read-only with a different value) must not cost the
15281 /// parameter its existence: log it, then declare the code's default.
15282 #[inline(never)]
15283 fn declare_described_overridden(
15284 &mut self,
15285 node: super::node_record::NodeId,
15286 name: &str,
15287 declared: &nros_params::ParameterValue,
15288 descriptor: nros_params::ParameterDescriptor,
15289 ) -> bool {
15290 // Issue 2118 -- the frame's one value, filled in place.
15291 let mut value = nros_params::ParameterValue::NotSet;
15292 let overridden = self.argv_param_override(node, name, declared, &mut value);
15293 let Some(params) = &mut self.params else {
15294 return false;
15295 };
15296 if params
15297 .server
15298 .try_declare_ref(
15299 node.into(),
15300 name,
15301 if overridden { &value } else { declared },
15302 Some(descriptor.clone()),
15303 )
15304 .is_ok()
15305 {
15306 return true;
15307 }
15308 if !overridden {
15309 return false;
15310 }
15311 nros_log::log_error!(
15312 nros_log::get_logger("nros"),
15313 "parameter override for `{}` was refused by its descriptor; the declared \
15314 default stands",
15315 name
15316 );
15317 params
15318 .server
15319 .try_declare_ref(node.into(), name, declared, Some(descriptor))
15320 .is_ok()
15321 }
15322
15323 /// THE writer — phase-426 W2.
15324 ///
15325 /// Set an ALREADY-DECLARED parameter on the executor's primary node, with
15326 /// the same rules a remote `ros2 param set` gets: read-only, type, range
15327 /// (min/max/step), and issue 1151's refusal of a name the node never
15328 /// declared unless that node opted in through
15329 /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on).
15330 ///
15331 /// It routes through [`ParameterServer::apply`](nros_params::ParameterServerOps::apply),
15332 /// which is what makes the C and C++ facades, the by-value oracle, the
15333 /// streaming service path and the atomic pre-check agree — a facade that
15334 /// wrote the slot directly would be a second answer to "may this set
15335 /// happen", which is the class RFC-0019/0020 forbids. An in-image caller
15336 /// that genuinely wants declare-on-write names
15337 /// `params_mut()`/`set_or_declare` and has opted in by naming it.
15338 ///
15339 /// Returns `Err(ParameterError::NotFound)` when the store does not exist
15340 /// yet (no parameter has been declared and no services registered).
15341 #[deprecated(
15342 since = "0.6.0",
15343 note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter(name, value)` on the `Node` that `create_node` / `node_mut(id)` returns"
15344 )]
15345 pub fn set_parameter(
15346 &mut self,
15347 name: &str,
15348 value: nros_params::ParameterValue,
15349 ) -> Result<(), nros_params::ParameterError> {
15350 self.set_parameter_on(super::node_record::NodeId::PRIMARY, name, value)
15351 }
15352
15353 /// [`set_parameter`](Self::set_parameter) on a named node.
15354 pub(crate) fn set_parameter_on(
15355 &mut self,
15356 node: super::node_record::NodeId,
15357 name: &str,
15358 value: nros_params::ParameterValue,
15359 ) -> Result<(), nros_params::ParameterError> {
15360 self.set_parameter_on_ref(node, name, &value)
15361 }
15362
15363 /// [`set_parameter_on`](Self::set_parameter_on), borrowing the value --
15364 /// issue 2104, and the one the by-value spelling forwards to. The value is
15365 /// copied once, into its slot (`ParameterServer::apply_ref`).
15366 pub(crate) fn set_parameter_on_ref(
15367 &mut self,
15368 node: super::node_record::NodeId,
15369 name: &str,
15370 value: &nros_params::ParameterValue,
15371 ) -> Result<(), nros_params::ParameterError> {
15372 // Reserved parameters (`use_sim_time`) take effect on a set exactly as
15373 // they do on a declare — this is the seam every language funnels
15374 // through, so hooking it once here covers all of them.
15375 //
15376 // AFTER the store's verdict and only on `Success`, for the reason issue
15377 // 1202 gives one function up: the hook used to run first and off the
15378 // caller's value, so a write the store REFUSED still moved the switch
15379 // and left sim time recorded as on with no source attached. `apply` has
15380 // more ways to refuse than `declare` does — read-only, type, range,
15381 // undeclared-without-the-node-option — so a writer that hooked first
15382 // would reach that state from four directions instead of one.
15383 let result = match &mut self.params {
15384 Some(params) => params.server.apply_ref(node.into(), name, value),
15385 None => Err(nros_params::ParameterError::NotFound),
15386 };
15387 if result.is_ok() {
15388 self.note_reserved_parameter(name);
15389 }
15390 result
15391 }
15392
15393 /// phase-491 W2 (RFC-0105 D7) — rclc's LOCAL typed set on `node`:
15394 /// type, the on-set hooks with `(old, new)`, the write. Read-only and
15395 /// range are NOT checked, because rclc's owner of a parameter is not bound
15396 /// by them; remote writes keep [`set_parameter_on`](Self::set_parameter_on)'s
15397 /// rules. The reserved-parameter hook runs on success, as on every writer.
15398 pub(crate) fn set_parameter_local_on(
15399 &mut self,
15400 node: super::node_record::NodeId,
15401 name: &str,
15402 value: nros_params::ParameterValue,
15403 ) -> Result<(), nros_params::ParameterError> {
15404 let result = match &mut self.params {
15405 Some(params) => params.server.set_local(node.into(), name, value),
15406 None => Err(nros_params::ParameterError::NotFound),
15407 };
15408 if result.is_ok() {
15409 self.note_reserved_parameter(name);
15410 }
15411 result
15412 }
15413
15414 #[cfg(feature = "param-services")]
15415 /// Issue 2023 — the serving half of `/parameter_events`: a node whose six
15416 /// services just came up publishes its events unless it opted out. A
15417 /// failure is LOGGED and the services stay up: `ros2 param` still works,
15418 /// and a publisher slot the image did not size for is a fact to report,
15419 /// not a reason to take the services down with it.
15420 #[inline(never)]
15421 fn start_parameter_events_for_served_node(&mut self, node: super::node_record::NodeId) {
15422 if self
15423 .params
15424 .as_deref()
15425 .is_some_and(|p| p.events_opt_out.get(node.into()))
15426 {
15427 return;
15428 }
15429 if let Err(e) = self.enable_parameter_events_on(node) {
15430 report_parameter_events_refused(e);
15431 }
15432 }
15433
15434 #[cfg(feature = "param-services")]
15435 /// Issue 2023 — `node` publishes no `/parameter_events` (rclc's
15436 /// `notify_changed_over_dds = false`; rclcpp's
15437 /// `start_parameter_event_publisher(false)`). Stops logging its changes,
15438 /// and keeps [`register_parameter_services`](Self::register_parameter_services)
15439 /// from switching them on. The executor's one publisher stays up if
15440 /// another node uses it.
15441 pub(crate) fn disable_parameter_events_on(&mut self, node: super::node_record::NodeId) {
15442 if !self.ensure_parameter_store() {
15443 return;
15444 }
15445 if let Some(params) = self.params.as_deref_mut() {
15446 params.events_opt_out.set(node.into(), true);
15447 params.server.set_parameter_events(node.into(), false);
15448 }
15449 }
15450
15451 #[cfg(feature = "param-services")]
15452 /// phase-491 W3 (RFC-0105 D8) — publish `node`'s parameter changes on
15453 /// `/parameter_events` (rclc's `notify_changed_over_dds`).
15454 ///
15455 /// Creates the executor's one `/parameter_events` publisher on the first
15456 /// call (see `ParamState::event_publisher`), typed
15457 /// `rcl_interfaces/msg/ParameterEvent`, RELIABLE + VOLATILE as upstream's
15458 /// `parameter_events` profile. Its depth is upstream's 1000 on a hosted
15459 /// build and `NROS_PARAM_EVENTS_DEPTH` (default 1) otherwise, because a
15460 /// reliable DDS writer's history is memory the RMW reserves; depth is not
15461 /// a matching criterion, so an upstream subscriber still matches.
15462 /// Idempotent.
15463 pub(crate) fn enable_parameter_events_on(
15464 &mut self,
15465 node: super::node_record::NodeId,
15466 ) -> Result<(), NodeError> {
15467 type Event = nros_rcl_interfaces::msg::ParameterEvent;
15468 if !self.ensure_parameter_store() {
15469 return Err(NodeError::Transport(TransportError::BadAlloc));
15470 }
15471 let idx = node.index();
15472 if idx >= crate::param_sizing::MAX_SERVICE_SETS {
15473 return Err(NodeError::NodeTableFull);
15474 }
15475 // Issue 2023 — an explicit request overrides an earlier opt-out.
15476 if let Some(params) = self.params.as_deref_mut() {
15477 params.events_opt_out.set(node.into(), false);
15478 }
15479 if self
15480 .params
15481 .as_ref()
15482 .is_some_and(|p| p.event_publisher.is_some())
15483 {
15484 if let Some(params) = self.params.as_mut() {
15485 params.server.set_parameter_events(node.into(), true);
15486 }
15487 return Ok(());
15488 }
15489 // The event is encoded into the services' reply buffer; on the heap
15490 // road that pair is allocated with the first service set, which may
15491 // not exist yet.
15492 #[cfg(feature = "alloc")]
15493 if let Some(params) = self.params.as_mut()
15494 && params.buffers.is_none()
15495 {
15496 params.buffers = Some(
15497 crate::parameter_services::ParamServiceBuffers::try_with_capacity(
15498 crate::parameter_services::param_service_buffer_bytes(),
15499 )
15500 .ok_or(NodeError::Transport(TransportError::BadAlloc))?,
15501 );
15502 }
15503 crate::rmw_type_registry::register_type::<Event>()?;
15504 let qos = QoSProfile::parameter_events_default().depth(nros_params::PARAM_EVENTS_DEPTH);
15505 let handle = create_param_event_pub(self, node, qos)?;
15506 if let Some(params) = self.params.as_mut() {
15507 params.event_publisher = Some(handle);
15508 params.server.set_parameter_events(node.into(), true);
15509 }
15510 Ok(())
15511 }
15512
15513 #[cfg(feature = "param-services")]
15514 /// phase-491 W3 — publish every pending parameter change, one
15515 /// `ParameterEvent` each, oldest first. Runs after each spin's parameter
15516 /// services and after every C or C++ parameter call; nothing allocates.
15517 pub fn flush_parameter_events(&mut self) {
15518 let stamp_ns = self
15519 .epoch_us_fn
15520 .or(self.clock_us_fn)
15521 .map_or(0, |clock| clock().saturating_mul(1000));
15522 let mut lost: u32 = 0;
15523 loop {
15524 let Some(params) = self.params.as_mut() else {
15525 return;
15526 };
15527 // Issue 2023 — a node's start-up event first, then the log.
15528 let Some(event) = params
15529 .server
15530 .pop_initial_parameter_event()
15531 .or_else(|| params.server.pop_parameter_event())
15532 else {
15533 break;
15534 };
15535 let idx = event.node.index();
15536 let mut fqn = heapless::String::<256>::new();
15537 match self.nodes.get(idx) {
15538 Some(r) => {
15539 if let Ok(f) = crate::names::fully_qualified_name(&r.name, &r.namespace) {
15540 let _ = fqn.push_str(f.as_str());
15541 }
15542 }
15543 None => {
15544 if let Ok(f) =
15545 crate::names::fully_qualified_name(&self.node_name, &self.namespace)
15546 {
15547 let _ = fqn.push_str(f.as_str());
15548 }
15549 }
15550 }
15551 let Some(params) = self.params.as_mut() else {
15552 return;
15553 };
15554 let crate::param_store::ParamState {
15555 server,
15556 buffers,
15557 event_publisher,
15558 ..
15559 } = &mut **params;
15560 let (Some(publisher), Some(buffers)) = (event_publisher.as_ref(), buffers.as_mut())
15561 else {
15562 continue;
15563 };
15564 let value = match event.kind {
15565 nros_params::ParameterEventKind::Deleted => None,
15566 _ => server.get(event.node, event.name.as_str()),
15567 };
15568 let encoded = crate::parameter_services::encode_parameter_event(
15569 buffers.reply_mut(),
15570 stamp_ns,
15571 fqn.as_str(),
15572 event.kind,
15573 event.name.as_str(),
15574 value,
15575 );
15576 match encoded {
15577 Some(n) => {
15578 use nros_rmw::Publisher as _;
15579 if publisher.publish_raw(&buffers.reply_mut()[..n]).is_err() {
15580 lost = lost.saturating_add(1);
15581 }
15582 }
15583 None => {
15584 lost = lost.saturating_add(1);
15585 }
15586 }
15587 }
15588 let dropped = self
15589 .params
15590 .as_mut()
15591 .map_or(0, |p| p.server.take_parameter_events_dropped());
15592 let lost = lost.saturating_add(dropped);
15593 if lost > 0 {
15594 Self::report_parameter_events_lost(lost);
15595 }
15596 }
15597
15598 #[cfg(feature = "param-services")]
15599 #[cold]
15600 fn report_parameter_events_lost(lost: u32) {
15601 // Issue 2023 — an event that does not fit is REFUSED whole, never
15602 // truncated into a malformed sample; this line is how anyone hears.
15603 nros_log::log_error!(
15604 nros_log::get_logger("nros"),
15605 "{} /parameter_events message(s) were not published: the change log \
15606 (PARAMETER_EVENT_LOG_LEN) filled between two flushes, an event did not fit the \
15607 parameter-service reply buffer (NROS_PARAM_SERVICE_BUFFER_SIZE) and was refused \
15608 rather than truncated, or the publish failed. The parameters themselves changed \
15609 (phase-491 W3, issue 2023).",
15610 lost
15611 );
15612 }
15613
15614 /// phase-491 W2 — a remote `NOT_SET` on `node` deletes the parameter
15615 /// (rclc) instead of being refused (rclcpp). See
15616 /// [`ParameterServer::set_delete_on_not_set`](nros_params::ParameterServerOps::set_delete_on_not_set).
15617 pub(crate) fn delete_parameters_on_not_set_on(
15618 &mut self,
15619 node: super::node_record::NodeId,
15620 on: bool,
15621 ) {
15622 self.ensure_parameter_store();
15623 if let Some(params) = &mut self.params {
15624 params.server.set_delete_on_not_set(node.into(), on);
15625 }
15626 }
15627
15628 /// May a remote set DECLARE a name this node never declared? Upstream's
15629 /// `allow_undeclared_parameters` node option (issue 1151), per node since
15630 /// phase-426 W1: switching it on for one node leaves its siblings
15631 /// refusing.
15632 pub(crate) fn allow_undeclared_parameters_on(
15633 &mut self,
15634 node: super::node_record::NodeId,
15635 allow: bool,
15636 ) {
15637 self.ensure_parameter_store();
15638 if let Some(params) = &mut self.params {
15639 params.server.set_allow_undeclared(node.into(), allow);
15640 }
15641 }
15642
15643 /// [`allow_undeclared_parameters_on`](Self::allow_undeclared_parameters_on)
15644 /// for the primary node.
15645 #[deprecated(
15646 since = "0.6.0",
15647 note = "the node is the parameter receiver (phase-515 R10): `node.allow_undeclared_parameters(allow)` on the `Node` that `create_node` / `node_mut(id)` returns"
15648 )]
15649 pub fn allow_undeclared_parameters(&mut self, allow: bool) {
15650 self.allow_undeclared_parameters_on(super::node_record::NodeId::PRIMARY, allow);
15651 }
15652
15653 /// Get a parameter value by name from the primary node.
15654 #[deprecated(
15655 since = "0.6.0",
15656 note = "the node is the parameter receiver (phase-515 R10): `node.get_parameter(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15657 )]
15658 pub fn get_parameter(&self, name: &str) -> Option<&nros_params::ParameterValue> {
15659 self.get_parameter_on(super::node_record::NodeId::PRIMARY, name)
15660 }
15661
15662 /// Get a parameter value by name from a named node.
15663 pub(crate) fn get_parameter_on(
15664 &self,
15665 node: super::node_record::NodeId,
15666 name: &str,
15667 ) -> Option<&nros_params::ParameterValue> {
15668 self.params.as_deref()?.server.get(node.into(), name)
15669 }
15670
15671 /// Get an integer parameter value by name from the primary node (convenience).
15672 pub fn get_parameter_integer(&self, name: &str) -> Option<i64> {
15673 self.params
15674 .as_ref()?
15675 .server
15676 .get_integer(super::node_record::NodeId::PRIMARY.into(), name)
15677 }
15678
15679 /// Get a reference to the parameter server (if registered).
15680 pub fn params(&self) -> Option<&nros_params::ParameterServer<'s>> {
15681 self.params.as_deref().map(|p| &p.server)
15682 }
15683
15684 /// Get a mutable reference to the parameter server (if registered).
15685 pub fn params_mut(&mut self) -> Option<&mut nros_params::ParameterServer<'s>> {
15686 self.params.as_deref_mut().map(|p| &mut p.server)
15687 }
15688
15689 /// Create a typed parameter builder (rclrs-compatible API).
15690 ///
15691 /// Returns a [`ParameterBuilder`] for fluent parameter declaration with
15692 /// `.default()`, `.description()`, `.range()`, and terminal methods
15693 /// `.mandatory()`, `.optional()`, or `.read_only()`.
15694 ///
15695 /// Returns [`NodeError::NotInitialized`] if parameter services have
15696 /// not been registered yet — call [`register_parameter_services`]
15697 /// first.
15698 ///
15699 /// # Example
15700 ///
15701 /// ```ignore
15702 /// let max_speed = executor.parameter::<f64>("max_speed")?
15703 /// .default(25.0)
15704 /// .description("Maximum velocity (m/s)")
15705 /// .read_only()?;
15706 /// ```
15707 ///
15708 /// [`ParameterBuilder`]: nros_params::ParameterBuilder
15709 /// [`register_parameter_services`]: Self::register_parameter_services
15710 #[deprecated(
15711 since = "0.6.0",
15712 note = "the node is the parameter receiver (phase-515 R10): `node.declare_parameter::<T>(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15713 )]
15714 pub fn parameter<'a, T: nros_params::ParameterVariant>(
15715 &'a mut self,
15716 name: &'a str,
15717 ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
15718 self.parameter_on(super::node_record::NodeId::PRIMARY, name)
15719 }
15720
15721 /// [`parameter`](Self::parameter) on a named node.
15722 pub(crate) fn parameter_on<'a, T: nros_params::ParameterVariant>(
15723 &'a mut self,
15724 node: super::node_record::NodeId,
15725 name: &'a str,
15726 ) -> Result<nros_params::ParameterBuilder<'a, 's, T>, NodeError> {
15727 // phase-515 R10 — the store comes into being on first use, as it
15728 // does for `declare_parameter_on`: `Node::declare_parameter` is the
15729 // user-facing declaration now, and it must not need a call the old
15730 // one did not. `NotInitialized` remains for a heap that refused it.
15731 self.ensure_parameter_store();
15732 // Issue 1203 — a reserved name the executor acts on. The builder
15733 // declares through its own borrow of the store, so the seeded
15734 // placeholder has to leave first (or the declaration is refused, the
15735 // way a second `declare_parameter` is), and the next spin reads back
15736 // what was declared.
15737 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
15738 if name == crate::time_source::USE_SIM_TIME_PARAM {
15739 self.yield_seeded_use_sim_time(node, name);
15740 if let Some(pending) = self
15741 .params
15742 .as_deref_mut()
15743 .and_then(|p| p.sim_time_via_builder.get_mut(node.index()))
15744 {
15745 *pending = true;
15746 }
15747 }
15748 let server = self
15749 .params
15750 .as_mut()
15751 .map(|p| &mut p.server)
15752 .ok_or(NodeError::NotInitialized)?;
15753 Ok(nros_params::ParameterBuilder::new(
15754 server,
15755 node.into(),
15756 name,
15757 ))
15758 }
15759
15760 // ========================================================================
15761 // phase-417 W4.a — the surface C and C++ forward to
15762 //
15763 // Every one of these is a forwarder onto `nros_params::ParameterServer`,
15764 // the ONE store. They exist on the executor because that is the object the
15765 // FFI holds: a C caller has an `rclc_executor_t*` and an `rcl_node_t*`,
15766 // and a C++ node has its `node_id`. Nothing here keeps state — a wrapper
15767 // that did would be the second store phase-426 removed.
15768 // ========================================================================
15769
15770 /// Attach a description and free-text constraints to a declared parameter
15771 /// on `node` — rclc's `rclc_add_parameter_description`.
15772 pub(crate) fn set_parameter_description_on(
15773 &mut self,
15774 node: super::node_record::NodeId,
15775 name: &str,
15776 description: &str,
15777 additional_constraints: &str,
15778 ) -> bool {
15779 match &mut self.params {
15780 Some(params) => params.server.set_parameter_description(
15781 node.into(),
15782 name,
15783 description,
15784 additional_constraints,
15785 ),
15786 None => false,
15787 }
15788 }
15789
15790 /// [`set_parameter_description_on`](Self::set_parameter_description_on) for
15791 /// the primary node.
15792 #[deprecated(
15793 since = "0.6.0",
15794 note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_description(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15795 )]
15796 pub fn set_parameter_description(
15797 &mut self,
15798 name: &str,
15799 description: &str,
15800 additional_constraints: &str,
15801 ) -> bool {
15802 self.set_parameter_description_on(
15803 super::node_record::NodeId::PRIMARY,
15804 name,
15805 description,
15806 additional_constraints,
15807 )
15808 }
15809
15810 /// Mark a declared parameter read-only — rclc's
15811 /// `rclc_set_parameter_read_only`. Every later write, local or remote, is
15812 /// refused.
15813 pub(crate) fn set_parameter_read_only_on(
15814 &mut self,
15815 node: super::node_record::NodeId,
15816 name: &str,
15817 read_only: bool,
15818 ) -> bool {
15819 match &mut self.params {
15820 Some(params) => params
15821 .server
15822 .set_parameter_read_only(node.into(), name, read_only),
15823 None => false,
15824 }
15825 }
15826
15827 /// [`set_parameter_read_only_on`](Self::set_parameter_read_only_on) for the
15828 /// primary node.
15829 #[deprecated(
15830 since = "0.6.0",
15831 note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_read_only(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15832 )]
15833 pub fn set_parameter_read_only(&mut self, name: &str, read_only: bool) -> bool {
15834 self.set_parameter_read_only_on(super::node_record::NodeId::PRIMARY, name, read_only)
15835 }
15836
15837 /// Attach an integer range — rclc's
15838 /// `rclc_add_parameter_constraint_integer`. Refuses an ill-formed range or
15839 /// one the current value does not satisfy.
15840 pub(crate) fn set_parameter_integer_range_on(
15841 &mut self,
15842 node: super::node_record::NodeId,
15843 name: &str,
15844 min: i64,
15845 max: i64,
15846 step: i64,
15847 ) -> bool {
15848 match &mut self.params {
15849 Some(params) => {
15850 params
15851 .server
15852 .set_parameter_integer_range(node.into(), name, min, max, step)
15853 }
15854 None => false,
15855 }
15856 }
15857
15858 /// [`set_parameter_integer_range_on`](Self::set_parameter_integer_range_on)
15859 /// for the primary node.
15860 #[deprecated(
15861 since = "0.6.0",
15862 note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_integer_range(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15863 )]
15864 pub fn set_parameter_integer_range(
15865 &mut self,
15866 name: &str,
15867 min: i64,
15868 max: i64,
15869 step: i64,
15870 ) -> bool {
15871 self.set_parameter_integer_range_on(
15872 super::node_record::NodeId::PRIMARY,
15873 name,
15874 min,
15875 max,
15876 step,
15877 )
15878 }
15879
15880 /// Attach a floating-point range — rclc's
15881 /// `rclc_add_parameter_constraint_double`.
15882 pub(crate) fn set_parameter_float_range_on(
15883 &mut self,
15884 node: super::node_record::NodeId,
15885 name: &str,
15886 min: f64,
15887 max: f64,
15888 step: f64,
15889 ) -> bool {
15890 match &mut self.params {
15891 Some(params) => {
15892 params
15893 .server
15894 .set_parameter_float_range(node.into(), name, min, max, step)
15895 }
15896 None => false,
15897 }
15898 }
15899
15900 /// [`set_parameter_float_range_on`](Self::set_parameter_float_range_on) for
15901 /// the primary node.
15902 #[deprecated(
15903 since = "0.6.0",
15904 note = "the node is the parameter receiver (phase-515 R10): `node.set_parameter_float_range(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
15905 )]
15906 pub fn set_parameter_float_range(&mut self, name: &str, min: f64, max: f64, step: f64) -> bool {
15907 self.set_parameter_float_range_on(super::node_record::NodeId::PRIMARY, name, min, max, step)
15908 }
15909
15910 /// The descriptor of a declared parameter — rclcpp's
15911 /// `describe_parameter`, rclrs's `ParameterBuilder` readback. `None` when
15912 /// the parameter is not declared on `node`, or was declared without one.
15913 pub(crate) fn describe_parameter_on(
15914 &self,
15915 node: super::node_record::NodeId,
15916 name: &str,
15917 ) -> Option<&nros_params::ParameterDescriptor> {
15918 self.params
15919 .as_ref()?
15920 .server
15921 .get_descriptor(node.into(), name)
15922 }
15923
15924 /// [`describe_parameter_on`](Self::describe_parameter_on) for the primary
15925 /// node.
15926 #[deprecated(
15927 since = "0.6.0",
15928 note = "the node is the parameter receiver (phase-515 R10): `node.describe_parameter(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15929 )]
15930 pub fn describe_parameter(&self, name: &str) -> Option<&nros_params::ParameterDescriptor> {
15931 self.describe_parameter_on(super::node_record::NodeId::PRIMARY, name)
15932 }
15933
15934 /// The declared TYPE of a parameter — rclcpp's `get_parameter_types`, and
15935 /// the local answer to the `~/get_parameter_types` service.
15936 pub(crate) fn get_parameter_type_on(
15937 &self,
15938 node: super::node_record::NodeId,
15939 name: &str,
15940 ) -> Option<nros_params::ParameterType> {
15941 self.params.as_deref()?.server.get_type(node.into(), name)
15942 }
15943
15944 /// [`get_parameter_type_on`](Self::get_parameter_type_on) for the primary
15945 /// node.
15946 #[deprecated(
15947 since = "0.6.0",
15948 note = "the node is the parameter receiver (phase-515 R10): `node.get_parameter_type(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
15949 )]
15950 pub fn get_parameter_type(&self, name: &str) -> Option<nros_params::ParameterType> {
15951 self.get_parameter_type_on(super::node_record::NodeId::PRIMARY, name)
15952 }
15953
15954 /// The names a node has declared, filtered by prefix — rclcpp's
15955 /// `list_parameters`, and the local answer to `~/list_parameters`. An
15956 /// empty prefix lists them all.
15957 pub(crate) fn list_parameters_on<'p>(
15958 &'p self,
15959 node: super::node_record::NodeId,
15960 prefix: &'p str,
15961 ) -> impl Iterator<Item = &'p str> {
15962 self.params
15963 .as_ref()
15964 .into_iter()
15965 .flat_map(move |p| p.server.list_with_prefix(node.into(), prefix))
15966 }
15967
15968 /// [`list_parameters_on`](Self::list_parameters_on) for the primary node.
15969 #[deprecated(
15970 since = "0.6.0",
15971 note = "the node is the parameter receiver (phase-515 R10): `node.list_parameters(prefix)` on the `Node` that `create_node` / `node_mut(id)` returns"
15972 )]
15973 pub fn list_parameters<'p>(&'p self, prefix: &'p str) -> impl Iterator<Item = &'p str> {
15974 self.list_parameters_on(super::node_record::NodeId::PRIMARY, prefix)
15975 }
15976
15977 /// All-or-nothing multi-set on `node` — rclcpp's
15978 /// `set_parameters_atomically`.
15979 ///
15980 /// We SERVED `~/set_parameters_atomically` and offered it in no language,
15981 /// so the only way to reach our own atomic set was over the wire. This is
15982 /// the same call the service handler makes.
15983 ///
15984 /// `items` yields `None` for an element that could not be turned into a
15985 /// value at all; that refuses the batch like any failing check.
15986 pub(crate) fn set_parameters_atomically_on<'x, I>(
15987 &mut self,
15988 node: super::node_record::NodeId,
15989 items: I,
15990 ) -> Result<(), nros_params::ParameterError>
15991 where
15992 I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
15993 {
15994 match &mut self.params {
15995 Some(params) => params.server.apply_atomically(node.into(), items),
15996 None => Err(nros_params::ParameterError::NotFound),
15997 }
15998 }
15999
16000 /// [`set_parameters_atomically_on`](Self::set_parameters_atomically_on),
16001 /// with element `i` FILLED into one scratch value by `fill(i, slot)`
16002 /// (`ParameterServer::apply_atomically_with`, issue 2104). For a caller
16003 /// that would otherwise build each ~8 KiB value on its own stack.
16004 pub(crate) fn set_parameters_atomically_with_on<'x>(
16005 &mut self,
16006 node: super::node_record::NodeId,
16007 count: usize,
16008 fill: impl FnMut(usize, &mut nros_params::ParameterValue) -> Option<&'x str>,
16009 ) -> Result<(), nros_params::ParameterError> {
16010 match &mut self.params {
16011 Some(params) => params
16012 .server
16013 .apply_atomically_with(node.into(), count, fill),
16014 None => Err(nros_params::ParameterError::NotFound),
16015 }
16016 }
16017
16018 /// [`set_parameters_atomically_on`](Self::set_parameters_atomically_on) for
16019 /// the primary node.
16020 #[deprecated(
16021 since = "0.6.0",
16022 note = "the node is the parameter receiver (phase-515 R10): `node.set_parameters_atomically(items)` on the `Node` that `create_node` / `node_mut(id)` returns"
16023 )]
16024 pub fn set_parameters_atomically<'x, I>(
16025 &mut self,
16026 items: I,
16027 ) -> Result<(), nros_params::ParameterError>
16028 where
16029 I: Iterator<Item = Option<(&'x str, nros_params::ParameterValue)>> + Clone,
16030 {
16031 self.set_parameters_atomically_on(super::node_record::NodeId::PRIMARY, items)
16032 }
16033
16034 /// Undeclare a parameter — rclcpp's `undeclare_parameter`, rclc's
16035 /// `rclc_delete_parameter`. The slot is freed for a later declaration.
16036 pub(crate) fn undeclare_parameter_on(
16037 &mut self,
16038 node: super::node_record::NodeId,
16039 name: &str,
16040 ) -> bool {
16041 match &mut self.params {
16042 Some(params) => params.server.remove(node.into(), name),
16043 None => false,
16044 }
16045 }
16046
16047 /// [`undeclare_parameter_on`](Self::undeclare_parameter_on) for the primary
16048 /// node.
16049 #[deprecated(
16050 since = "0.6.0",
16051 note = "the node is the parameter receiver (phase-515 R10): `node.undeclare_parameter(name)` on the `Node` that `create_node` / `node_mut(id)` returns"
16052 )]
16053 pub fn undeclare_parameter(&mut self, name: &str) -> bool {
16054 self.undeclare_parameter_on(super::node_record::NodeId::PRIMARY, name)
16055 }
16056
16057 /// Register an accept/reject hook for writes to `node` — rclcpp's
16058 /// `add_on_set_parameters_callback`.
16059 ///
16060 /// It runs on every write that reaches the store through
16061 /// [`set_parameter_on`](Self::set_parameter_on), which is the seam the six
16062 /// `rcl_interfaces/srv/*` servers and both wrapper languages share, so ONE
16063 /// registration covers a remote `ros2 param set` and a local one alike.
16064 ///
16065 /// `None` when all [`nros_params::MAX_ON_SET_CALLBACKS`] slots are taken.
16066 pub(crate) fn add_on_set_parameters_callback_on(
16067 &mut self,
16068 node: super::node_record::NodeId,
16069 callback: nros_params::OnSetParameterFn,
16070 ctx: nros_params::OnSetContext,
16071 ) -> Option<nros_params::OnSetParameterHandle> {
16072 self.ensure_parameter_store();
16073 self.params
16074 .as_mut()?
16075 .server
16076 .add_on_set_parameters_callback(node.into(), callback, ctx)
16077 }
16078
16079 /// [`add_on_set_parameters_callback_on`](Self::add_on_set_parameters_callback_on)
16080 /// for the primary node.
16081 #[deprecated(
16082 since = "0.6.0",
16083 note = "the node is the parameter receiver (phase-515 R10): `node.add_on_set_parameters_callback(..)` on the `Node` that `create_node` / `node_mut(id)` returns"
16084 )]
16085 pub fn add_on_set_parameters_callback(
16086 &mut self,
16087 callback: nros_params::OnSetParameterFn,
16088 ctx: nros_params::OnSetContext,
16089 ) -> Option<nros_params::OnSetParameterHandle> {
16090 self.add_on_set_parameters_callback_on(super::node_record::NodeId::PRIMARY, callback, ctx)
16091 }
16092
16093 /// Unregister a hook — rclcpp's `remove_on_set_parameters_callback`.
16094 pub fn remove_on_set_parameters_callback(
16095 &mut self,
16096 handle: nros_params::OnSetParameterHandle,
16097 ) -> bool {
16098 match &mut self.params {
16099 Some(params) => params.server.remove_on_set_parameters_callback(handle),
16100 None => false,
16101 }
16102 }
16103
16104 /// rclrs's `Node::use_undeclared_parameters` — a handle that reads and
16105 /// writes parameters this node never declared.
16106 ///
16107 /// The handle writes through `set_or_declare`, not through the wire-facing
16108 /// `apply`: `allow_undeclared_parameters` governs what a REMOTE set may
16109 /// create, and a caller who reached for this type has opted in by naming
16110 /// it. That is rclrs's split too.
16111 pub(crate) fn use_undeclared_parameters_on(
16112 &mut self,
16113 node: super::node_record::NodeId,
16114 ) -> Result<nros_params::Parameters<'_, 's>, NodeError> {
16115 self.ensure_parameter_store(); // phase-515 R10, as `parameter_on`
16116 let server = self
16117 .params
16118 .as_mut()
16119 .map(|p| &mut p.server)
16120 .ok_or(NodeError::NotInitialized)?;
16121 Ok(nros_params::Parameters::new(server, node.into()))
16122 }
16123
16124 /// [`use_undeclared_parameters_on`](Self::use_undeclared_parameters_on) for
16125 /// the primary node.
16126 #[deprecated(
16127 since = "0.6.0",
16128 note = "the node is the parameter receiver (phase-515 R10): `node.use_undeclared_parameters()` on the `Node` that `create_node` / `node_mut(id)` returns"
16129 )]
16130 pub fn use_undeclared_parameters(
16131 &mut self,
16132 ) -> Result<nros_params::Parameters<'_, 's>, NodeError> {
16133 self.use_undeclared_parameters_on(super::node_record::NodeId::PRIMARY)
16134 }
16135}
16136
16137// ============================================================================
16138// Executor lifecycle — cancel / is_spinning (phase-417 W4.c)
16139// ============================================================================
16140
16141// Issue 1177 -- UN-GATED. This block was `#[cfg(feature = "alloc")]` because
16142// `halt_flag` and `spinning` were `alloc` fields, and neither needed to be: the
16143// allocator is required to SHARE the halt flag (`halt_flag()` hands out a
16144// refcounted handle), never to hold it.
16145//
16146// Gating it meant a no_std executor could not be stopped, and worse, that
16147// `nros-c`'s spin loop -- which calls `cancel`, `is_halted`, `is_spinning`,
16148// `enter_spin_loop` and `exit_spin_loop` with no cfg of its own -- did not
16149// COMPILE for an embedded image. The safety island found that; no
16150// merge-gating lane builds this crate without `alloc`.
16151impl<'s> Executor<'s> {
16152 /// Request the executor to stop spinning — `rclcpp::Executor::cancel`.
16153 ///
16154 /// # ADOPT-BOUNDED (RFC-0089)
16155 ///
16156 /// `cancel` sets a flag the spin loop observes at the NEXT POLL BOUNDARY,
16157 /// so it returns BEFORE spinning has actually stopped;
16158 /// [`is_spinning()`](Self::is_spinning) is the observable that tells you
16159 /// when it has. rclcpp makes the same promise in the same shape, but the
16160 /// boundary here is one `spin_once` timeout wide (a backend's `drive_io`
16161 /// blocks up to the poll interval), so "next boundary" is a duration a
16162 /// caller can measure rather than an implementation detail. The wake flag
16163 /// below shortens it; it does not remove it.
16164 ///
16165 /// Nothing here tears down the session: an executor that has been cancelled
16166 /// is still initialised, still owns its entities, and can be spun again.
16167 /// Teardown is a different verb (`fini` / `shutdown`), and collapsing the
16168 /// two costs a full discovery round on every mode change.
16169 ///
16170 /// Safe to call from another thread or a signal handler.
16171 ///
16172 /// Also raises the Phase 104.C.6 wake flag so a `spin_once` already blocked
16173 /// inside a backend's `drive_io` falls through to the cancel check on its
16174 /// next loop iteration instead of waiting out its full `timeout_ms` first.
16175 pub fn cancel(&self) {
16176 self.halt_flag
16177 .store(true, core::sync::atomic::Ordering::SeqCst);
16178 // The wake flag is the `Arc` half and still needs an allocator. A
16179 // core-only image simply waits out the poll interval, which is the
16180 // bounded behaviour the envelope above already promises.
16181 #[cfg(feature = "alloc")]
16182 self.wake_flag
16183 .store(true, core::sync::atomic::Ordering::SeqCst);
16184 // phase-521 W6 (RFC-0113 D2) — and end a PARK, not only a backend
16185 // wait. An entry parks until its next deadline rather than for a
16186 // fixed slice, so a cancel that only raised the flag would wait out
16187 // that whole park. `nros_platform_wake_signal` is the port's ISR- and
16188 // signal-safe post, the one an arrival uses.
16189 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16190 if let Some(wake) = self.node_wake.as_ref() {
16191 wake.signal();
16192 }
16193 }
16194
16195 /// phase-521 W6 (RFC-0113 D2) — wake every worker parked over this
16196 /// executor: its own and every level's band worker, as an arrival nothing
16197 /// attributes does. A level parks until its band's next deadline or an
16198 /// arrival, not for a fixed period, so a runner that stops its levels must
16199 /// end those parks or wait them out. Lock-free; safe from any thread.
16200 /// Raises no halt: a woken worker runs one pass and parks again unless its
16201 /// runner's own stop flag says otherwise.
16202 pub fn wake_all_workers(&self) {
16203 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16204 if let Some(ctx) = self.wake_ctx.as_ref() {
16205 ctx.signal();
16206 }
16207 }
16208
16209 /// Has a [`cancel()`](Self::cancel) been REQUESTED?
16210 ///
16211 /// Not the same question as [`is_spinning()`](Self::is_spinning), and both
16212 /// are kept because both have callers:
16213 ///
16214 /// * `is_halted()` — "someone asked this executor to stop." True the instant
16215 /// `cancel()` returns, and it stays true until the next spin loop clears
16216 /// it on entry. This is the flag a hand-rolled `while !exec.is_halted()`
16217 /// loop polls, which is what a BSP that drives `spin_once` itself writes.
16218 /// * `is_spinning()` — "a spin loop is running right now." Answers the other
16219 /// half of the envelope: whether the cancel has been ACTED on yet.
16220 ///
16221 /// The two are true together for as long as one poll takes, which is exactly
16222 /// the window the ADOPT-BOUNDED envelope on `cancel()` describes. Neither is
16223 /// derivable from the other: an executor that was never spun is neither
16224 /// halted nor spinning, and one cancelled mid-poll is both.
16225 ///
16226 /// The name is the pre-rename spelling and is deliberately NOT deprecated —
16227 /// `cancel`'s rclcpp pair is a `spinning` observable, not an
16228 /// `is_cancel_requested` one, so there is no ROS 2 name to adopt here and a
16229 /// third spelling would buy nothing.
16230 pub fn is_halted(&self) -> bool {
16231 self.halt_flag.load(core::sync::atomic::Ordering::SeqCst)
16232 }
16233
16234 /// Is a spin loop running on this executor right now?
16235 ///
16236 /// rclcpp spells this `Executor::is_spinning()`. True between the entry and
16237 /// the exit of [`spin`](Self::spin),
16238 /// [`spin_period`](Self::spin_period), the `open_threaded` worker loop, and
16239 /// the C / C++ API loops — every construct that owns its own iteration.
16240 ///
16241 /// A bare `spin_once` does NOT set it: one poll is not a spin, and a caller
16242 /// running its own `while` around `spin_once` is the one who knows it is
16243 /// looping. Such a caller can say so with
16244 /// [`enter_spin_loop`](crate::executor::ffi::ExecutorFfi::enter_spin_loop).
16245 ///
16246 /// See [`is_halted()`](Self::is_halted) for why this is a different question
16247 /// from "was cancel requested".
16248 pub fn is_spinning(&self) -> bool {
16249 self.spinning.load(core::sync::atomic::Ordering::SeqCst)
16250 }
16251
16252 /// Declare that the caller is entering a spin loop it drives itself.
16253 ///
16254 /// The seam the C and C++ APIs use so their loops answer
16255 /// [`is_spinning()`](Self::is_spinning) without keeping a flag of their own
16256 /// (RFC-0019: ergonomics may live in the wrapper, state may not). Also
16257 /// CLEARS any pending cancel, matching what `spin` does on entry:
16258 /// a cancel requested before a spin started is not a cancel of THIS spin.
16259 ///
16260 /// Pair it with [`exit_spin_loop`](Self::exit_spin_loop) on EVERY exit path,
16261 /// `break` and early `return` alike — a loop that leaves without it reports
16262 /// `is_spinning() == true` forever. It is a method pair rather than an RAII
16263 /// guard on purpose: a guard would have to borrow the executor for the whole
16264 /// loop, and every caller needs `&mut` inside the body to poll.
16265 ///
16266 /// Takes `&self` (the flags are atomics), so the borrow ends at the call.
16267 pub(crate) fn enter_spin_loop(&self) {
16268 self.halt_flag
16269 .store(false, core::sync::atomic::Ordering::SeqCst);
16270 self.spinning
16271 .store(true, core::sync::atomic::Ordering::SeqCst);
16272 }
16273
16274 /// End the scope opened by [`enter_spin_loop`](Self::enter_spin_loop).
16275 ///
16276 /// Idempotent. Leaves the cancel flag alone: "someone asked me to stop" is
16277 /// the caller's answer to keep or clear, and clearing it here would lose the
16278 /// reason the loop exited.
16279 pub(crate) fn exit_spin_loop(&self) {
16280 self.spinning
16281 .store(false, core::sync::atomic::Ordering::SeqCst);
16282 }
16283}
16284
16285// ============================================================================
16286// std-gated spin and halt methods
16287// ============================================================================
16288
16289// phase-359 W10 — this impl used to be ONE `#[cfg(feature = "std")]` block.
16290// Only the wall-clock spin loops in it need `std` (`Instant`, `thread::sleep`,
16291// `thread::spawn`); `halt` / `is_halted` / `wake` are plain atomic stores on
16292// flags the struct already owns, and `halt_flag` / `wake_handle` only need
16293// `Arc`. Gating the BLOCK rather than the items made a no_std image unable to
16294// stop its own executor — `ExecutorNodeRuntime::spin` carried a matching `std`
16295// gate for no reason but this one.
16296// phase-359 W10 — `alloc`, not `std`. The wall-clock spin loops were the
16297// reason this block was std-gated: `Instant` for the deadline and
16298// `thread::sleep` for the pacing. Both now go through the platform — the
16299// executor's own `now_us()` and `nros_platform_sleep_us` — so a no_std
16300// image can run the same blocking loops a hosted one does instead of
16301// hand-rolling them in its BSP.
16302#[cfg(feature = "alloc")]
16303impl<'s> Executor<'s> {
16304 /// Blocking spin loop with configurable exit conditions — rclrs's
16305 /// `Executor::spin(SpinOptions)`.
16306 ///
16307 /// ADOPT-BOUNDED (RFC-0089). Same name, same argument, same meaning: run
16308 /// until an exit condition in `opts` says stop. Two bounds, both visible in
16309 /// the signature:
16310 ///
16311 /// * **The error channel is one error, not all of them.** rclrs returns
16312 /// `Vec<RclrsError>` and the caller writes `.first_error()?`; there is no
16313 /// allocator here, so ours returns `Result<(), NodeError>` carrying the
16314 /// FIRST failing callback's error and drops the rest (they are still
16315 /// counted in each pass's [`SpinOnceResult`]). Ported line for line, the
16316 /// edit is `.first_error()?` -> `?`, which the compiler points at.
16317 /// * **The loop is a poll loop**, so `opts.poll_interval` is the quantum at
16318 /// which every exit condition is observed — a `cancel()` from another
16319 /// task, the `timeout` expiring, `max_callbacks` being reached. Requesting
16320 /// a `timeout` on a build with no clock is an ERROR, not a silent
16321 /// never-ending loop (issue 0709).
16322 ///
16323 /// phase-427 — this method was ~~`spin_blocking`~~. `spin` was taken by the
16324 /// `-> !` task body, which is now [`spin_forever`](Self::spin_forever).
16325 ///
16326 /// Runs until one of:
16327 /// - [`cancel()`](Self::cancel) is called (from
16328 /// another thread or signal handler)
16329 /// - `opts.timeout` expires (if set)
16330 /// - `opts.max_callbacks` is reached (if set)
16331 /// - `opts.only_next` is true (single iteration)
16332 /// - a callback failed and `opts.stop_on_first_error` is set
16333 ///
16334 /// # Example
16335 ///
16336 /// ```ignore
16337 /// // Spin until cancelled
16338 /// executor.spin(SpinOptions::default())?;
16339 ///
16340 /// // Spin with 5-second timeout
16341 /// executor.spin(SpinOptions::new().timeout(core::time::Duration::from_secs(5)))?;
16342 ///
16343 /// // Single iteration
16344 /// executor.spin(SpinOptions::spin_once())?;
16345 /// ```
16346 pub fn spin(&mut self, opts: SpinOptions) -> Result<(), NodeError> {
16347 // phase-427 — was a private ~~`POLL_INTERVAL`~~ const; it is
16348 // `SpinOptions::poll_interval` now, defaulting to the same 10 ms, so
16349 // `spin` and `spin_forever` read one value instead of two literals.
16350 let poll_interval = opts.poll_interval;
16351
16352 // phase-359 W10 — the executor's own clock. `now_us()` reads the
16353 // PLATFORM's monotonic counter, and nothing else: the platform API is
16354 // the clock, so a build with a port has one and a build without has
16355 // none. There is no `std::time::Instant` third answer any more, which
16356 // is what used to make "what time is it" depend on whether some crate
16357 // in the graph happened to name `std`.
16358 //
16359 // `None` means this build has NO clock at all — and issue 0709 is what
16360 // the previous answer here cost. It read: "a timeout cannot be honoured
16361 // then, and pretending otherwise would exit immediately or never; the
16362 // honest reading is no deadline". It then picked NEVER. A caller that
16363 // asked for 50 ms got an infinite loop, silently, in the one API whose
16364 // whole contract is "returns after N ms" — ten hours of a CI lane
16365 // before anyone read it as stuck rather than slow.
16366 //
16367 // Neither of the two silent readings is honest. An unmet precondition
16368 // is an ERROR (repo rule, fail-loud): the caller supplied a time
16369 // quantity this build cannot measure, and only they can decide what to
16370 // do about it. An UNTIMED `spin` still runs until halt, which
16371 // is a promise this build can keep.
16372 let start_us = self.now_us();
16373 if opts.timeout.is_some() && start_us.is_none() {
16374 nros_log::log_error!(
16375 nros_log::get_logger("nros"),
16376 "spin: a timeout was requested but this build has no clock \
16377 — install one with `ExecutorConfig::clock_us` (issue 0709)"
16378 );
16379 return Err(NodeError::NotInitialized);
16380 }
16381 // `as_micros()` rather than a hand-rolled `ms * 1_000`: the multiply
16382 // was unchecked, and the unit conversion is `Duration`'s job.
16383 let timeout_us = opts.timeout.map(|d| d.as_micros() as u64);
16384 let mut total_callbacks = 0usize;
16385
16386 // phase-417 W4.c — clears the cancel flag AND raises `is_spinning`. The
16387 // clear is the pre-existing behaviour (a cancel requested before this
16388 // spin is not a cancel of this spin); the raise is what lets a peer
16389 // thread see that its `cancel()` has not been acted on yet. Every exit
16390 // below is a `break` to the single tail, which is where the scope ends.
16391 self.enter_spin_loop();
16392
16393 loop {
16394 if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
16395 break;
16396 }
16397
16398 if let (Some(start), Some(limit)) = (start_us, timeout_us)
16399 && self
16400 .now_us()
16401 .is_some_and(|now| now.saturating_sub(start) >= limit)
16402 {
16403 break;
16404 }
16405
16406 let mut callback_error = None;
16407 let result = self.spin_once_capturing(poll_interval, &mut callback_error);
16408 total_callbacks += result.total();
16409
16410 // phase-427 — the first error, and only the first: rclrs's
16411 // `Vec<RclrsError>` needs an allocator we do not have. The scope is
16412 // closed before returning so a caller that reads `is_spinning()`
16413 // after an error sees `false`, exactly as after a clean exit.
16414 if opts.stop_on_first_error
16415 && let Some(e) = callback_error
16416 {
16417 self.exit_spin_loop();
16418 return Err(NodeError::Transport(e));
16419 }
16420
16421 if opts.max_callbacks.is_some_and(|max| total_callbacks >= max) {
16422 break;
16423 }
16424
16425 if opts.only_next {
16426 break;
16427 }
16428 }
16429
16430 self.exit_spin_loop();
16431 Ok(())
16432 }
16433
16434 /// Execute one period with wall-clock overrun detection.
16435 ///
16436 /// Calls [`spin_once()`](Self::spin_once), measures wall-clock time, sleeps
16437 /// for the remainder if under budget.
16438 ///
16439 /// # Example
16440 ///
16441 /// ```ignore
16442 /// let period = core::time::Duration::from_millis(10);
16443 /// let result = executor.spin_one_period_timed(period);
16444 /// if result.overrun {
16445 /// log::warn!("Period overrun: {:?}", result.elapsed);
16446 /// }
16447 /// ```
16448 pub fn spin_one_period_timed(
16449 &mut self,
16450 period: core::time::Duration,
16451 ) -> super::types::SpinPeriodResult {
16452 let start_us = self.now_us();
16453 let result = self.spin_once(period);
16454 // With no clock there is nothing to measure and nothing to sleep off;
16455 // report zero elapsed and no overrun, which is what an unmeasured
16456 // period is.
16457 let elapsed = start_us
16458 .zip(self.now_us())
16459 .map(|(s, e)| core::time::Duration::from_micros(e.saturating_sub(s)))
16460 .unwrap_or_default();
16461 let overrun = elapsed > period;
16462 if !overrun && start_us.is_some() {
16463 platform_sleep(period - elapsed);
16464 }
16465 super::types::SpinPeriodResult {
16466 work: result,
16467 overrun,
16468 elapsed,
16469 }
16470 }
16471
16472 /// Spin at a fixed rate with drift compensation. Blocks until halted.
16473 ///
16474 /// Uses wall-clock time to maintain the target rate. The next invocation
16475 /// time is accumulated (not reset to `now + period`) to prevent cumulative
16476 /// drift.
16477 ///
16478 /// # Example
16479 ///
16480 /// ```ignore
16481 /// // 100Hz control loop — blocks until halt() is called
16482 /// executor.spin_period(core::time::Duration::from_millis(10))?;
16483 /// ```
16484 pub fn spin_period(&mut self, period: core::time::Duration) -> Result<(), NodeError> {
16485 let period_us = period.as_micros().min(u64::MAX as u128) as u64;
16486 // Absolute next-deadline in the executor's own clock. issue 0709 — the
16487 // sibling of `spin`'s guard above: with no clock there is no
16488 // pacing, and the loop would run as fast as `spin_once` returns while
16489 // the caller believes it is running at `period`. A requested period
16490 // this build cannot honour is a configuration error, not a silent
16491 // busy-loop.
16492 let Some(start_us) = self.now_us() else {
16493 nros_log::log_error!(
16494 nros_log::get_logger("nros"),
16495 "spin_period: a period was requested but this build has no clock \
16496 — install one with `ExecutorConfig::clock_us` (issue 0709)"
16497 );
16498 return Err(NodeError::NotInitialized);
16499 };
16500 // phase-417 W4.c — after the clock guard, not before it: a call that
16501 // returns an error never spun, and must not report that it did. (This
16502 // also moved the cancel-flag clear past the guard, which is the same
16503 // correction.)
16504 self.enter_spin_loop();
16505 let mut next_us = Some(start_us + period_us);
16506
16507 loop {
16508 if self.halt_flag.load(core::sync::atomic::Ordering::SeqCst) {
16509 break;
16510 }
16511
16512 self.spin_once(period);
16513
16514 if let Some(next) = next_us {
16515 // Jitter is recorded in `spin_once` above, which this loop
16516 // calls every iteration -- counting it here as well would
16517 // double every wake on this one driver.
16518 if let Some(now) = self.now_us()
16519 && now < next
16520 {
16521 platform_sleep(core::time::Duration::from_micros(next - now));
16522 }
16523 // Accumulate to prevent drift (not = now + period)
16524 next_us = Some(next + period_us);
16525 }
16526 }
16527 self.exit_spin_loop();
16528 Ok(())
16529 }
16530}
16531
16532#[cfg(feature = "alloc")]
16533impl<'s> Executor<'s> {
16534 /// Phase 110.D.b — move this Executor onto a fresh OS thread,
16535 /// apply a per-thread scheduling policy via the caller-supplied
16536 /// `apply_policy` function, and run the spin loop until
16537 /// `ThreadHandle::halt` fires.
16538 ///
16539 /// The function-pointer indirection on `apply_policy` lets the
16540 /// caller pass any platform's `PlatformScheduler::set_current_thread_policy`
16541 /// without forcing `Executor` to be generic over the platform —
16542 /// keeps the existing `Executor` type stable.
16543 ///
16544 /// Multi-executor preemption (the actual hard-RT win) comes from
16545 /// the OS scheduler — call `open_threaded` once per criticality
16546 /// tier, each with its own policy / priority. The kernel handles
16547 /// preemption across executors; within a single executor,
16548 /// dispatch remains non-preemptive (110.A–C bucketed sets).
16549 ///
16550 /// # Safety
16551 ///
16552 /// Moves `self` across thread boundaries. `Executor` contains a
16553 /// raw `*mut session::ConcreteSession` when constructed via
16554 /// `from_session_ptr`; the caller must ensure that pointer's
16555 /// referent stays valid across the lifetime of the spawned thread
16556 /// and that no other thread mutates the session concurrently.
16557 /// `from_session` (Owned) is safer — `ConcreteSession` ownership
16558 /// transfers cleanly into the thread.
16559 #[cfg(feature = "alloc")]
16560 pub unsafe fn open_threaded(
16561 self,
16562 policy: nros_platform_api::SchedPolicy,
16563 apply_policy: fn(
16564 nros_platform_api::SchedPolicy,
16565 ) -> Result<(), nros_platform_api::SchedError>,
16566 spin_period: core::time::Duration,
16567 ) -> ThreadHandle
16568 where
16569 // phase-271 — the spawned thread owns `self` for an unbounded lifetime,
16570 // so its borrowed storage must be `'static` (a leaked/`static` backing —
16571 // the `alloc` convenience constructors or a program-lifetime region).
16572 's: 'static,
16573 {
16574 let halt = self.halt_flag.clone();
16575 // phase-359 W10 — a platform task, not `std::thread`. The two other
16576 // executor-owned workers moved earlier; this is the third and last, and
16577 // it is the one the ABI fits best: `open_threaded` exists to give a
16578 // spin loop an OS SCHEDULING POLICY, which is not something a build
16579 // without an OS was ever going to get from `std`.
16580 //
16581 // The closure becomes a heap context because the entry point crosses C
16582 // as `*mut c_void`. It is reclaimed by the trampoline on exit rather
16583 // than leaked: unlike a tier task, this loop RETURNS — that is what
16584 // `halt` is for.
16585 let ctx = alloc::boxed::Box::into_raw(alloc::boxed::Box::new(ThreadedCtx {
16586 executor: self,
16587 policy,
16588 apply_policy,
16589 spin_period,
16590 }));
16591 // SAFETY: `ctx` stays live until the trampoline reclaims it, which
16592 // happens on the spawned task and only after the loop exits.
16593 let task = unsafe {
16594 nros_platform_api::task::PlatformTask::spawn(
16595 threaded_spin_trampoline,
16596 ctx as *mut core::ffi::c_void,
16597 OPEN_THREADED_STACK_BYTES,
16598 c"nros-exec".as_ptr(),
16599 )
16600 };
16601 let Some(task) = task else {
16602 // The platform refused (or cannot size) a task. Reclaim the
16603 // context and hand back a handle that owns nothing — `halt` and
16604 // `join` stay callable, which keeps the caller's shape identical to
16605 // the success path.
16606 //
16607 // SAFETY: nothing was spawned, so this pointer has no other owner.
16608 drop(unsafe { alloc::boxed::Box::from_raw(ctx) });
16609 return ThreadHandle { task: None, halt };
16610 };
16611 ThreadHandle {
16612 task: Some(task),
16613 halt,
16614 }
16615 }
16616
16617 /// Phase 104.C.6 — wake the executor from another thread / ISR /
16618 /// signal handler.
16619 ///
16620 /// Sets the shared `wake_flag`. The next `spin_once` swap-clears the
16621 /// flag, skips the blocking wait on the primary session, and polls
16622 /// every session non-blockingly so whatever queued the wake is
16623 /// observed in a single iteration. Idempotent — multiple `wake()`
16624 /// calls collapse into one observed wake per `spin_once`.
16625 pub fn wake(&self) {
16626 self.wake_flag
16627 .store(true, core::sync::atomic::Ordering::SeqCst);
16628 }
16629}
16630
16631#[cfg(feature = "alloc")]
16632impl<'s> Executor<'s> {
16633 /// Get a clone of the halt flag for use in signal handlers or other threads.
16634 ///
16635 /// # Example
16636 ///
16637 /// ```ignore
16638 /// let halt = executor.halt_flag();
16639 /// std::thread::spawn(move || {
16640 /// std::thread::sleep(Duration::from_secs(5));
16641 /// halt.store(true, Ordering::SeqCst);
16642 /// });
16643 /// executor.spin(SpinOptions::default())?;
16644 /// ```
16645 pub fn halt_flag(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
16646 self.halt_flag.clone()
16647 }
16648
16649 /// Phase 104.C.6 — clone of the shared wake flag for cross-thread
16650 /// use (signal handlers, foreign threads, future per-backend vtable
16651 /// wake hooks).
16652 ///
16653 /// # Example
16654 ///
16655 /// ```ignore
16656 /// let wake = executor.wake_handle();
16657 /// std::thread::spawn(move || {
16658 /// // ... compute something ...
16659 /// // hand off to executor by setting the flag.
16660 /// wake.store(true, Ordering::SeqCst);
16661 /// });
16662 /// loop { executor.spin_once(Duration::from_millis(100)); }
16663 /// ```
16664 pub fn wake_handle(&self) -> portable_atomic_util::Arc<portable_atomic::AtomicBool> {
16665 self.wake_flag.clone()
16666 }
16667}
16668
16669/// Phase 110.E.b — opaque per-platform timer handle. Stores the
16670/// raw platform handle (a boxed POSIX `timer_t`, FreeRTOS `TimerHandle_t`,
16671/// etc.) plus a destroy thunk so the
16672/// Executor can clean up without being generic over the platform.
16673///
16674/// Caller of `register_sporadic_timer` builds this via
16675/// `OpaqueTimerHandle::new(handle, destroy_fn)` after their
16676/// `PlatformTimer::create_periodic` call returns.
16677#[cfg(feature = "alloc")]
16678pub struct OpaqueTimerHandle {
16679 handle: *mut core::ffi::c_void,
16680 destroy_fn: extern "C" fn(*mut core::ffi::c_void),
16681}
16682
16683#[cfg(feature = "alloc")]
16684unsafe impl Send for OpaqueTimerHandle {}
16685#[cfg(feature = "alloc")]
16686unsafe impl Sync for OpaqueTimerHandle {}
16687
16688#[cfg(feature = "alloc")]
16689impl OpaqueTimerHandle {
16690 /// # Safety
16691 /// `handle` must be a live platform-specific timer handle that
16692 /// `destroy_fn` knows how to drop. Caller surrenders ownership
16693 /// of the underlying handle to the Executor.
16694 pub unsafe fn new(
16695 handle: *mut core::ffi::c_void,
16696 destroy_fn: extern "C" fn(*mut core::ffi::c_void),
16697 ) -> Self {
16698 Self { handle, destroy_fn }
16699 }
16700}
16701
16702#[cfg(feature = "alloc")]
16703impl Drop for OpaqueTimerHandle {
16704 fn drop(&mut self) {
16705 if !self.handle.is_null() {
16706 (self.destroy_fn)(self.handle);
16707 self.handle = core::ptr::null_mut();
16708 }
16709 }
16710}
16711
16712/// Handle returned from [`Executor::open_threaded`]. Holds the
16713/// spawned thread's join handle and a clone of the executor's halt
16714/// flag. Drop runs `halt() + join()` so the thread can't outlive the
16715/// handle.
16716#[cfg(feature = "alloc")]
16717pub struct ThreadHandle {
16718 task: Option<nros_platform_api::task::PlatformTask>,
16719 halt: portable_atomic_util::Arc<portable_atomic::AtomicBool>,
16720}
16721
16722/// Default stack for an `open_threaded` spin loop. `std::thread`'s default was
16723/// 2 MiB; the executor's own storage is caller-carved and lives elsewhere, so
16724/// this carries call frames only — the same reasoning (and size) as the NuttX
16725/// tier spawn, which measured this against a real RTOS default.
16726#[cfg(feature = "alloc")]
16727const OPEN_THREADED_STACK_BYTES: usize = 65536;
16728
16729/// What [`Executor::open_threaded`] hands its task, in place of a closure's
16730/// captures. Owned by the trampoline, which reclaims it when the loop exits.
16731#[cfg(feature = "alloc")]
16732struct ThreadedCtx {
16733 executor: Executor<'static>,
16734 policy: nros_platform_api::SchedPolicy,
16735 apply_policy: fn(nros_platform_api::SchedPolicy) -> Result<(), nros_platform_api::SchedError>,
16736 spin_period: core::time::Duration,
16737}
16738
16739/// The spawned spin loop.
16740///
16741/// # Safety
16742/// `arg` must be the `Box<ThreadedCtx>` raw pointer `open_threaded` created,
16743/// passed exactly once.
16744#[cfg(feature = "alloc")]
16745unsafe extern "C" fn threaded_spin_trampoline(
16746 arg: *mut core::ffi::c_void,
16747) -> *mut core::ffi::c_void {
16748 // SAFETY: the caller's contract — this is the pointer `open_threaded`
16749 // leaked, and the task is the only consumer of it.
16750 let mut ctx = unsafe { alloc::boxed::Box::from_raw(arg as *mut ThreadedCtx) };
16751 // Apply the requested OS scheduling policy to this fresh task. Failure is
16752 // reported but not propagated — a runtime that fails to lift to SCHED_FIFO
16753 // still spins correctly at SCHED_OTHER (just without RT guarantees).
16754 let _ = (ctx.apply_policy)(ctx.policy);
16755 // phase-417 W4.c — this loop owns its own iteration, so it is a spin and
16756 // must say so. NOT `enter_spin_loop()`: that clears the cancel flag, and
16757 // `open_threaded` hands the caller a `ThreadHandle` whose `halt()` may
16758 // already have fired before this task was scheduled. Raising `is_spinning`
16759 // without touching the cancel bit is the honest report here.
16760 ctx.executor
16761 .spinning
16762 .store(true, core::sync::atomic::Ordering::SeqCst);
16763 while !ctx.executor.is_halted() {
16764 ctx.executor.spin_once(ctx.spin_period);
16765 }
16766 ctx.executor.exit_spin_loop();
16767 core::ptr::null_mut()
16768}
16769
16770#[cfg(feature = "alloc")]
16771impl ThreadHandle {
16772 /// Signal the spawned executor thread to stop. The thread exits
16773 /// on its next `spin_once` iteration.
16774 pub fn halt(&self) {
16775 self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
16776 }
16777
16778 /// Wait for the spawned task to exit. After `join`, calling it again is a
16779 /// no-op.
16780 ///
16781 /// phase-359 W10 — was `std::thread::Result<()>`, which carried a panic
16782 /// payload this can no longer produce: a platform task has no unwinding
16783 /// join. Same two outcomes, an error type that does not require `std` —
16784 /// the trade `signal_fd` already made in this campaign. `NotInitialized`
16785 /// means the platform refused to host the task at spawn time — nothing was
16786 /// started, so there is nothing to wait for. (An existing variant rather
16787 /// than a new one: `NodeError` is mapped across the C and C++ FFI, so a new
16788 /// variant is a gate-checked ABI change and this needs no new meaning.)
16789 pub fn join(mut self) -> Result<(), NodeError> {
16790 self.halt();
16791 match self.task.take() {
16792 Some(t) => {
16793 t.join();
16794 Ok(())
16795 }
16796 None => Err(NodeError::NotInitialized),
16797 }
16798 }
16799}
16800
16801#[cfg(feature = "alloc")]
16802impl Drop for ThreadHandle {
16803 fn drop(&mut self) {
16804 self.halt.store(true, core::sync::atomic::Ordering::SeqCst);
16805 if let Some(t) = self.task.take() {
16806 t.join();
16807 }
16808 }
16809}
16810
16811// SAFETY: Phase 110.D.b — `Executor` contains a raw `*mut
16812// session::ConcreteSession` only on the `from_session_ptr` (Borrowed)
16813// path; the `from_session` (Owned) path is plain Send-able. The
16814// `unsafe fn open_threaded` entry point documents the safety
16815// contract for Borrowed sessions; for Owned sessions the Send claim
16816// is unconditional.
16817// phase-359 W10 — `alloc`, not `std`: this assertion exists for
16818// `open_threaded`, which now hands the executor to a PLATFORM task. The
16819// crossing is the same one; only the thing doing the crossing changed.
16820#[cfg(feature = "alloc")]
16821unsafe impl<'s> Send for Executor<'s> {}
16822
16823/// phase-509 P9b item 2 -- take the bits of `mask` out of a ready word and
16824/// leave the rest for the workers of the other bands. One swap for the
16825/// one-level worker's every-slot mask, as before.
16826#[cfg(any(has_rmw, test))]
16827#[allow(dead_code)] // no ready word without `alloc` + `rmw-cffi`
16828#[inline]
16829fn take_band_bits(word: &portable_atomic::AtomicUsize, mask: u64) -> u64 {
16830 if mask == u64::MAX {
16831 word.swap(0, portable_atomic::Ordering::SeqCst) as u64
16832 } else {
16833 (word.fetch_and(!(mask as usize), portable_atomic::Ordering::SeqCst) as u64) & mask
16834 }
16835}
16836
16837/// phase-509 P9b item 2 (RFC-0108 "As built in P9b") -- how a pass runs.
16838#[cfg(any(has_rmw, test))]
16839pub(crate) enum PassMode<'w> {
16840 /// `spin_once(&mut self)`: the executor's own worker; the lock, if one
16841 /// is installed, is held for the whole pass.
16842 Owned,
16843 /// The executor's own worker through a raw pointer
16844 /// ([`Executor::spin_once_shared`]): the lock is released around each
16845 /// user callback when it carries a thread identity.
16846 Shared,
16847 /// A band worker's pass ([`Executor::spin_band_once`]).
16848 Band(&'w mut super::scheduler::BandWorker),
16849}
16850
16851impl<'s> Drop for Executor<'s> {
16852 fn drop(&mut self) {
16853 // Issue 0790 — a teardown that never went through `close()` still gets
16854 // its ordered shutdown hooks, and gets them in the SAME order. The C
16855 // API's `rclc_executor_fini` is exactly that path: it drops the
16856 // executor in place and leaves the session to `rclc_support_fini`, so
16857 // without this the whole facility would be silently inert for every C
16858 // entry. Entities are still live at the top of `drop` — the component
16859 // cells and the arena entries below are what tears them down — so this
16860 // is the last moment a pre-shutdown hook can do what it exists to do.
16861 //
16862 // After a normal `close()` both tables are already empty and these two
16863 // calls are a pair of `None` scans.
16864 self.run_shutdown_hooks(super::types::ShutdownPhase::Pre);
16865 // Phase 258 (Track 2, 2a) — release executor-owned component cells
16866 // first (before the arena entries), so a component's `drop`
16867 // trampoline can still touch its own (cell-owned) state. Each slot
16868 // owns a leaked `Arc<ComponentCell>`; its `drop` reconstitutes +
16869 // drops that Arc exactly once.
16870 for slot in self.component_slots.iter() {
16871 // SAFETY: `slot.state` is the leaked cell enrolled via
16872 // `enroll_component`; `slot.drop` is its matching trampoline, run
16873 // exactly once here (slots are not removed before Drop).
16874 unsafe {
16875 (slot.drop)(slot.state);
16876 }
16877 }
16878 let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
16879 for meta in self.entries.iter().flatten() {
16880 // SAFETY: each entry was written by `ptr::write` in `add_*` and
16881 // has not been dropped yet. `drop_fn` matches the concrete type.
16882 unsafe {
16883 let data_ptr = arena_ptr.add(meta.offset);
16884 (meta.drop_fn)(data_ptr);
16885 }
16886 }
16887 // Issue 0790 — the post-teardown half, after the entities are gone.
16888 // Same "already empty after `close()`" note as the pre pass above.
16889 self.run_shutdown_hooks(super::types::ShutdownPhase::Post);
16890 // Issue 1385 — LAST in the body, and that is the whole point: the
16891 // context the backend holds is `wake_ctx`, a field, so it is still
16892 // alive for every line above and is freed only after `drop` returns.
16893 // Clearing here is therefore the SHORTEST window in which a callback
16894 // can fire into live state, not the longest — entity teardown above
16895 // may still be talking to the backend.
16896 //
16897 // A no-op after `close()` (the latch), and a no-op for an executor
16898 // that never installed. For the C path this is the only clear there
16899 // is: `rclc_executor_fini` runs this `drop_in_place` and THEN
16900 // zero-fills the storage the callback pointed into.
16901 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16902 self.clear_wake_signal();
16903 // phase-467 Row 8 — and the graph-change target, before the box it
16904 // names is freed with this executor.
16905 #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
16906 self.clear_graph_change_signal();
16907 }
16908}
16909
16910/// Phase 274.W1 — borrowed-executor session sharing + active-groups gating.
16911///
16912/// Validates three primitives introduced for RFC-0015 Model 1:
16913/// - `session_handle` / `open_with_session_handle` (Borrowed session store —
16914/// the borrowed executor does not own or close the session on drop).
16915/// - `set_active_groups` + `group_active` (callback-group filter gating).
16916///
16917/// Uses `MockSession`; gated `not(feature = "rmw-cffi")` because the mock
16918/// session exists only there.
16919#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
16920mod p274_w1_tier_executor_tests {
16921 use super::Executor;
16922 use crate::mock::MockSession;
16923
16924 #[test]
16925 fn session_handle_borrowed_executor_shares_session_ptr() {
16926 // Open the primary executor (session owner).
16927 let session = MockSession::new();
16928 let mut primary = Executor::from_session(session);
16929
16930 // Record the primary's session pointer for later comparison.
16931 let primary_session_ptr = primary.session_ptr();
16932
16933 // Get the opaque session handle (into_raw for C FFI; here we keep it raw).
16934 let handle = primary.session_handle();
16935
16936 // Open a second executor over the SAME session (Borrowed — does not own it).
16937 // SAFETY: `primary` (the session owner) outlives `borrowed` in this scope.
16938 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
16939
16940 // Both executors must expose the same session pointer.
16941 assert_eq!(
16942 primary.session_ptr(),
16943 borrowed.session_ptr(),
16944 "borrowed executor must share the primary's session pointer"
16945 );
16946 assert_eq!(
16947 borrowed.session_ptr(),
16948 primary_session_ptr,
16949 "session pointer must be stable across session_handle / open_with_session_handle"
16950 );
16951
16952 // Drop the borrowed executor — the primary's session must remain valid.
16953 drop(borrowed);
16954
16955 // Primary still exposes the same session pointer (session was NOT closed by drop).
16956 assert_eq!(
16957 primary.session_ptr(),
16958 primary_session_ptr,
16959 "primary session pointer must be unchanged after dropping the borrowed executor"
16960 );
16961 }
16962
16963 /// An oversized group name is REFUSED and fails CLOSED.
16964 ///
16965 /// issue 1172 — this used to be `if s.push_str(g).is_ok()`, which dropped
16966 /// the name and left the tier filtering on a set that silently lacked it.
16967 /// Two things are asserted because either alone would let the bug back:
16968 /// the call must REPORT the failure, and the resulting filter must accept
16969 /// NOTHING rather than fall back to the wildcard, which would be
16970 /// fail-open.
16971 #[test]
16972 fn an_oversized_group_name_is_refused_and_fails_closed() {
16973 let session = MockSession::new();
16974 let mut primary = Executor::from_session(session);
16975 let handle = primary.session_handle();
16976 // SAFETY: primary outlives borrowed.
16977 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
16978
16979 // `GroupName` is `heapless::String<32>`.
16980 let too_long = "g".repeat(33);
16981 assert!(
16982 borrowed
16983 .set_active_groups(&[("n", "/", too_long.as_str())])
16984 .is_err(),
16985 "an oversized group name must be reported, not dropped"
16986 );
16987 assert!(
16988 !borrowed.group_active("n", "/", &too_long),
16989 "the name that did not fit must not be active"
16990 );
16991 assert!(
16992 !borrowed.group_active("n", "/", "anything-else"),
16993 "a refused filter must accept NOTHING — falling back to the \
16994 wildcard would run every callback on this tier"
16995 );
16996 }
16997
16998 #[test]
16999 fn set_active_groups_gates_group_active() {
17000 let session = MockSession::new();
17001 let mut primary = Executor::from_session(session);
17002 let handle = primary.session_handle();
17003
17004 // SAFETY: primary outlives borrowed.
17005 let mut borrowed = unsafe { Executor::open_with_session_handle(handle) };
17006
17007 // Before gating: wildcard — every group is accepted.
17008 assert!(
17009 borrowed.group_active("n", "/", "ctrl"),
17010 "default (wildcard) must accept every group"
17011 );
17012 assert!(
17013 borrowed.group_active("n", "/", "telem"),
17014 "default (wildcard) must accept every group"
17015 );
17016
17017 // Gate borrowed to only the "ctrl" group (one-tier filter).
17018 borrowed
17019 .set_active_groups(&[("n", "/", "ctrl")])
17020 .expect("fits");
17021
17022 assert!(
17023 borrowed.group_active("n", "/", "ctrl"),
17024 "\"ctrl\" must be active after set_active_groups([\"ctrl\"])"
17025 );
17026 assert!(
17027 !borrowed.group_active("n", "/", "telem"),
17028 "\"telem\" must NOT be active when only \"ctrl\" is gated"
17029 );
17030 assert!(
17031 !borrowed.group_active("n", "/", "planning"),
17032 "\"planning\" must NOT be active when only \"ctrl\" is gated"
17033 );
17034
17035 // Primary is unaffected (it still uses the wildcard).
17036 assert!(
17037 primary.group_active("n", "/", "telem"),
17038 "primary executor must remain unaffected (still wildcard)"
17039 );
17040
17041 // Clear the filter on borrowed — back to wildcard.
17042 borrowed
17043 .set_active_groups(&[])
17044 .expect("the wildcard always fits");
17045 assert!(
17046 borrowed.group_active("n", "/", "telem"),
17047 "after clearing, borrowed must accept all groups again"
17048 );
17049 }
17050
17051 #[test]
17052 fn session_handle_into_raw_from_raw_round_trip() {
17053 let session = MockSession::new();
17054 let mut primary = Executor::from_session(session);
17055 let session_ptr = primary.session_ptr();
17056
17057 let handle = primary.session_handle();
17058 let raw = handle.into_raw();
17059
17060 // into_raw must return a non-null pointer matching the session address.
17061 assert!(!raw.is_null());
17062 assert_eq!(raw as *mut _, session_ptr);
17063
17064 // from_raw must reconstruct a handle that opens the same borrowed executor.
17065 // SAFETY: primary still owns the session, raw is its address.
17066 let handle2 = unsafe { crate::executor::SessionHandle::from_raw(raw) };
17067 let mut borrowed = unsafe { Executor::open_with_session_handle(handle2) };
17068 assert_eq!(
17069 borrowed.session_ptr(),
17070 session_ptr,
17071 "from_raw reconstructed handle must open executor on the same session"
17072 );
17073 }
17074}
17075
17076/// W3b.5 — snapshot the monitored publishers' counters before a dispatch
17077/// (only when a latency contract exists; otherwise a zeroed array that
17078/// `attribute_latency` never reads).
17079fn snapshot_pub_counts(
17080 table: &'static [super::monitor::MonitorSpec],
17081 active: bool,
17082) -> [u32; super::monitor::MAX_MONITORS] {
17083 let mut counts = [0u32; super::monitor::MAX_MONITORS];
17084 if active {
17085 for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
17086 counts[k] = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
17087 }
17088 }
17089 counts
17090}
17091
17092/// W3b.5 — attribute one dispatch's elapsed time to every monitored
17093/// publisher whose counter advanced during it (an upper bound on the
17094/// node-path take → publish latency: the callback deserialized, ran, and
17095/// published within `elapsed_us`).
17096fn attribute_latency(
17097 table: &'static [super::monitor::MonitorSpec],
17098 active: bool,
17099 counts_before: &[u32; super::monitor::MAX_MONITORS],
17100 elapsed_us: u32,
17101) {
17102 if !active {
17103 return;
17104 }
17105 for (k, spec) in table.iter().take(super::monitor::MAX_MONITORS).enumerate() {
17106 if spec.max_latency_ms == 0 {
17107 continue;
17108 }
17109 let now = spec.cell.count.load(core::sync::atomic::Ordering::Relaxed);
17110 if now != counts_before[k] {
17111 spec.cell
17112 .max_latency_us
17113 .fetch_max(elapsed_us, core::sync::atomic::Ordering::Relaxed);
17114 }
17115 }
17116}
17117
17118/// W3b.5 — enforce the bound SC's deadline after a dispatch. A miss maps
17119/// through [`DeadlineAction`](super::sched_context::DeadlineAction):
17120/// `Warn`/`Skip`/`Fault` all report; `Skip` additionally masks the SC's
17121/// remaining callbacks for this cycle; `Fault` invokes the fault hook
17122/// (panic when none is registered).
17123/// phase-503 -- hand a route deadline verdict of `table[row]` to the reaction
17124/// hook, if one is installed (and the monitors are armed: the caller passes
17125/// `None` otherwise). Shared by the overdue check after the park and the
17126/// completion verdict of a late supervised route (`route_judge`).
17127fn call_route_reaction(
17128 hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
17129 table: &[super::monitor::MonitorSpec],
17130 row: usize,
17131 v: &super::monitor::Violation,
17132 fired_at_us: u64,
17133) {
17134 let Some((f, ctx)) = hook else { return };
17135 let spec = &table[row];
17136 let ev = super::monitor::ReactionEvent {
17137 reaction_id: spec.reaction_id as u32,
17138 row: row as u32,
17139 rule_code: super::monitor::rule_code(v.rule),
17140 measured_ms: v.measured,
17141 declared_ms: v.declared,
17142 mechanism: spec.mechanism,
17143 fired_at_us,
17144 };
17145 // SAFETY: `f` and `ctx` were registered together through
17146 // `set_reaction_hook`, whose caller keeps `ctx` valid for the executor's
17147 // life; the event lives across the call.
17148 unsafe { f(&ev, ctx) };
17149}
17150
17151/// phase-462 W3c -- [`call_route_reaction`] for a detected hazard: the event
17152/// names the hazard's reaction id and its index in the hazard table.
17153fn call_hazard_reaction(
17154 hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
17155 spec: &super::monitor::HazardSpec,
17156 row: usize,
17157 v: &super::monitor::Violation,
17158 fired_at_us: u64,
17159) {
17160 let Some((f, ctx)) = hook else { return };
17161 let ev = super::monitor::ReactionEvent {
17162 reaction_id: spec.reaction_id as u32,
17163 row: row as u32,
17164 rule_code: super::monitor::rule_code(v.rule),
17165 measured_ms: v.measured,
17166 declared_ms: v.declared,
17167 mechanism: super::monitor::MECHANISM_EXECUTOR,
17168 fired_at_us,
17169 };
17170 // SAFETY: as `call_route_reaction` -- `f` and `ctx` were registered
17171 // together through `set_reaction_hook`.
17172 unsafe { f(&ev, ctx) };
17173}
17174
17175// The deadline, the SC slot, the miss sinks and both fault paths, plus the
17176// fire time the reaction hook reports (play_launch phase 96 W4.2).
17177#[allow(clippy::too_many_arguments)]
17178fn check_deadline_miss(
17179 sc: Option<&super::sched_context::SchedContext>,
17180 sc_idx: usize,
17181 elapsed_us: u32,
17182 misses: &mut heapless::Vec<super::monitor::Violation, { super::monitor::MAX_VIOLATIONS }>,
17183 skipped_scs: &mut u64,
17184 fault_fn: Option<fn(&super::monitor::Violation)>,
17185 reaction_hook: Option<(super::monitor::ReactionHookFn, *mut core::ffi::c_void)>,
17186 fired_at_us: u64,
17187) {
17188 use super::sched_context::DeadlineAction;
17189 let Some(sc) = sc else { return };
17190 let Some(deadline_us) = sc.deadline_us.get().map(|nz| nz.get()) else {
17191 return;
17192 };
17193 if matches!(sc.deadline_action, DeadlineAction::Ignore) || elapsed_us <= deadline_us {
17194 return;
17195 }
17196 let v = super::monitor::Violation {
17197 rule: "deadline-miss-runtime",
17198 // Entries carry no name at this altitude; the SC slot stands in.
17199 fqn: "sched-context",
17200 measured: elapsed_us,
17201 declared: deadline_us,
17202 };
17203 match sc.deadline_action {
17204 DeadlineAction::Ignore => {}
17205 DeadlineAction::Warn => {
17206 let _ = misses.push(v);
17207 }
17208 DeadlineAction::Skip => {
17209 if sc_idx < 64 {
17210 *skipped_scs |= 1u64 << sc_idx;
17211 }
17212 let _ = misses.push(v);
17213 }
17214 DeadlineAction::Fault => {
17215 if let Some(f) = fault_fn {
17216 f(&v);
17217 let _ = misses.push(v);
17218 } else if let Some((f, ctx)) = reaction_hook {
17219 // phase-503 — the reaction hook is the fault path a C or C++
17220 // image can install; reaction id 0, the SC slot as the row.
17221 let ev = super::monitor::ReactionEvent {
17222 reaction_id: super::monitor::REACTION_NONE as u32,
17223 row: sc_idx as u32,
17224 rule_code: super::monitor::rule_code(v.rule),
17225 measured_ms: elapsed_us / 1_000,
17226 declared_ms: deadline_us / 1_000,
17227 mechanism: super::monitor::MECHANISM_EXECUTOR,
17228 fired_at_us,
17229 };
17230 // SAFETY: as in `check_route_deadlines`.
17231 unsafe { f(&ev, ctx) };
17232 let _ = misses.push(v);
17233 } else {
17234 panic!(
17235 "nros: deadline fault — dispatch ran {elapsed_us} us past a {deadline_us} us deadline"
17236 );
17237 }
17238 }
17239 }
17240}
17241
17242// Timer-accounting clock default (issue: no_std tiers with no `clock_us`
17243// credited each spin the REQUESTED timeout — spin.rs `delta_us` fallback —
17244// so shared-session wakes made low-rate timers fire early (a 100 ms timer
17245// at ~67 ms on Zephyr native_sim) and tick rounding made fast tiers fire
17246// late. Measured on that native_sim image; mechanism documented at the
17247// fallback site.)
17248//
17249// Every platform port exports `nros_platform_clock_ns` through the same
17250// linkage contract the wake primitives rely on (`nros_platform_export_clock!`
17251// in nros-platform-cffi), so an rmw-cffi no_std build can safely default the
17252// executor clock to it; `ExecutorConfig::clock_us` stays as an override.
17253#[cfg(feature = "rmw-cffi")]
17254unsafe extern "C" {
17255 fn nros_platform_clock_ns() -> u64;
17256}
17257
17258#[cfg(feature = "rmw-cffi")]
17259fn default_platform_clock_us() -> u64 {
17260 // SAFETY: bare query of the platform's monotonic ns counter; the symbol
17261 // is guaranteed by whichever platform port linked the binary (the same
17262 // contract `nros_platform_wake_*` already depends on).
17263 //
17264 // RFC-0073 made the ABI nanoseconds; the executor's own accounting is
17265 // still microseconds (`clock_us_fn`, `delta_us`), so the division lives
17266 // here rather than being pushed into every port.
17267 unsafe { nros_platform_clock_ns() / 1_000 }
17268}
17269
17270/// Sleep, through the platform ABI.
17271///
17272/// phase-359 W10 — the spin loops used `std::thread::sleep`. Every port already
17273/// exports `nros_platform_sleep_us` (the ABI's own pacing primitive, the one an
17274/// RTOS build has always used), so a hosted loop and an embedded one now pace
17275/// the same way. The µs entry point is used rather than `_ms` because
17276/// `spin_one_period_timed` sleeps off a remainder, which rounds badly at
17277/// millisecond granularity on short periods.
17278#[cfg(feature = "alloc")]
17279pub(crate) fn platform_sleep(d: core::time::Duration) {
17280 unsafe extern "C" {
17281 fn nros_platform_sleep_us(us: usize);
17282 }
17283 let us = d.as_micros().min(usize::MAX as u128) as usize;
17284 if us == 0 {
17285 return;
17286 }
17287 // SAFETY: a bare pacing call with no pointer arguments, guaranteed by
17288 // whichever port linked the binary — the same contract the clock and wake
17289 // symbols in this file already rely on.
17290 unsafe { nros_platform_sleep_us(us) }
17291}
17292
17293/// A monotonic µs reader, or `None` when this build has no clock at all.
17294///
17295/// phase-359 W10 — one provider, chosen by what is LINKED rather than by which
17296/// flavour the crate was built in. W4 unified the executor's clock ACCESSOR and
17297/// said the provider was the last piece; this is it.
17298///
17299/// `rmw-cffi` means a platform port is linked, and every port exports
17300/// `nros_platform_clock_ns` under the same contract the wake primitives use —
17301/// so a hosted build reads the same counter an embedded one does, instead of
17302/// `Instant` on one and the platform on the other. `Instant` survives only
17303/// where there is no port to ask: `std` without `rmw-cffi`.
17304///
17305/// phase-359 W10 follow-up — this comment claimed the metadata PROBE was that
17306/// configuration, and **it is not**. The probe's generated manifest deps
17307/// `nros-platform-cffi` with `posix-c-port` (issue 0288 layer 5, so it links),
17308/// and its `nros` resolves with `rmw-cffi` ON — feature-unified from the
17309/// component's board crate. Measured on a real probe tree: `alloc default
17310/// macros rmw-cffi ros-humble std`. It takes the PORT arm.
17311///
17312/// What is real: `nros-rmw-metadata` and `nros-tests` do resolve `nros` with
17313/// `std` and no `rmw-cffi`, and have no port crate in their graphs. But nothing
17314/// in either uses this arm — deleting it and building the workspace
17315/// `--all-targets` is clean, and there is no in-tree caller of
17316/// `nros::time::now` at all. So the arm serves an OUT-OF-TREE contract only: a
17317/// consumer with `std`, no `rmw-cffi`, and its own `Session`.
17318pub(crate) fn default_clock_us_fn() -> Option<fn() -> u64> {
17319 #[cfg(feature = "rmw-cffi")]
17320 {
17321 Some(default_platform_clock_us)
17322 }
17323 #[cfg(not(feature = "rmw-cffi"))]
17324 {
17325 None
17326 }
17327}
17328
17329// =============================================================================
17330// phase-425 W3b — the `/clock` time source's executor half.
17331//
17332// Its OWN impl block, gated on `sim-time`, because the methods were first
17333// written next to `declare_parameter` — which lives in a
17334// `#[cfg(feature = "param-store")]` block, so `reconcile_ros_time_source`
17335// silently disappeared in every combo without parameter services, including the
17336// one `spin_once` calls it from.
17337// =============================================================================
17338#[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17339impl<'s> Executor<'s> {
17340 /// phase-425 W3b — bring the `/clock` subscription in line with the last
17341 /// requested `use_sim_time`.
17342 ///
17343 /// Called at the head of every spin, and cheap when there is nothing to do:
17344 /// the common case is one bool comparison. It is a RECONCILE rather than an
17345 /// action at the request site because the request routinely arrives before
17346 /// there is a node to hang the subscription on — `nros::main!` emits
17347 /// `apply_param_services` before its per-node `register` calls, by design,
17348 /// so the store exists when each cell is created.
17349 ///
17350 /// Turning it off stops SAMPLES from being installed; the subscription
17351 /// itself stays, because the executor has no entity removal and inventing
17352 /// one for this would be a much larger change than the switch is worth. The
17353 /// gate is `time_source::set_active`, which the subscription callback reads.
17354 ///
17355 /// Turning it off also does NOT clear the override: a node that stops
17356 /// listening keeps the last simulated time rather than jumping back to the
17357 /// wall clock, which every ROS-time timer would otherwise absorb as a
17358 /// backwards jump.
17359 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17360 pub(crate) fn reconcile_ros_time_source(&mut self) {
17361 // An executor nobody told about `use_sim_time` has NO opinion, and the
17362 // gate is process-global, so writing its default here is not a no-op --
17363 // it is one executor overruling another. Say nothing until asked.
17364 if !self.sim_time_stated {
17365 return;
17366 }
17367 if self.sim_time_requested == crate::time_source::is_active()
17368 && (!self.sim_time_requested || self.sim_time_source.is_some())
17369 {
17370 return;
17371 }
17372 crate::time_source::set_active(self.sim_time_requested);
17373 if !self.sim_time_requested {
17374 // phase-430 W1 — the source is no longer wanted, so its silence is
17375 // no longer a symptom. Disarming here (rather than only re-arming
17376 // on attach) is what makes a later re-attach start a FRESH
17377 // interval: the watch that was counting is put down, not paused.
17378 self.sim_time_silence.disarm();
17379 return;
17380 }
17381 if self.sim_time_source.is_none() {
17382 // No node yet — stay pending and try again next spin. Not an error:
17383 // it is the ordinary order of a generated entry, which declares
17384 // parameters before it registers components.
17385 if self.nodes.is_empty() {
17386 return;
17387 }
17388 if let Ok(handle) = self.install_ros_time_source(
17389 super::node_record::NodeId::PRIMARY,
17390 crate::time_source::CLOCK_TOPIC,
17391 ) {
17392 self.sim_time_source = Some(handle);
17393 // phase-430 W1 — the interval starts HERE, at attach, and not
17394 // at construction: before the subscription exists there is
17395 // nothing to be silent about, so counting from boot would
17396 // accuse an image that spent the interval bringing a transport
17397 // up.
17398 self.sim_time_silence.arm();
17399 }
17400 } else {
17401 // Re-attached: `use_sim_time` went false and back to true while the
17402 // subscription (which the executor cannot remove) stayed. W1 asks
17403 // for the one-shot to reset in exactly this case.
17404 self.sim_time_silence.arm();
17405 }
17406 }
17407
17408 /// phase-430 W1 — credit one spin's elapsed time to the silence watch and
17409 /// emit the diagnostic if this is the spin that crosses the threshold.
17410 ///
17411 /// `delta_us` is the executor's OWN per-spin elapsed measure — the same one
17412 /// every wall timer on this executor accumulates, including its "no clock,
17413 /// credit the requested timeout" fallback. See
17414 /// [`SILENCE_WARN_US`](crate::time_source::SILENCE_WARN_US) for why that is
17415 /// the unit and not a spin count or an independent wall clock.
17416 ///
17417 /// Scope: the source the EXECUTOR installed for `use_sim_time`. An
17418 /// application that calls
17419 /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
17420 /// directly is not watched, because the line this would print names
17421 /// `use_sim_time`, and that application never set it — the honest
17422 /// diagnostic for the explicit path would be a different sentence, and
17423 /// inventing one nobody asked for is how a warning becomes noise.
17424 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17425 fn note_sim_time_silence(&mut self, delta_us: u64) {
17426 if self.sim_time_silence.tick(
17427 delta_us,
17428 nros_core::clock::Clock::is_ros_time_override_active(),
17429 ) {
17430 crate::time_source::report_silence(crate::time_source::CLOCK_TOPIC);
17431 }
17432 }
17433
17434 /// phase-430 W1 — how many times this executor has reported that
17435 /// `use_sim_time` is on with no `/clock` sample.
17436 ///
17437 /// The count rather than a bool, and an accessor rather than a log grep: a
17438 /// diagnostic whose contract is "exactly once" needs an assertion that can
17439 /// tell one from two, and a log sink is not present on every target that
17440 /// runs the test. It is also the question an application supervising its
17441 /// own configuration would ask.
17442 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17443 pub fn sim_time_silence_warnings(&self) -> u32 {
17444 self.sim_time_silence.reports()
17445 }
17446
17447 /// phase-425 W3b — re-read `use_sim_time` from the parameter store.
17448 ///
17449 /// Called after a parameter service actually handled something, which is
17450 /// what makes a runtime `ros2 param set <node> use_sim_time true` work
17451 /// without a per-spin scan of the store.
17452 ///
17453 /// phase-430 W3 / issue 1202 — the declaration path calls it too, through
17454 /// `note_reserved_parameter`, once the store has accepted the write. It
17455 /// used to record the value directly from the caller's argument, which is
17456 /// the same thing only while the store never refuses. One read for both
17457 /// paths; a second spelling is the drift this function exists to prevent.
17458 /// `pub(crate)` for the same reason: a test that changes the store directly
17459 /// needs the entry point a parameter service uses, not a copy of it.
17460 #[cfg(all(feature = "sim-time", feature = "param-store", any(has_rmw, test)))]
17461 pub(crate) fn refresh_use_sim_time_from_store(&mut self) {
17462 // phase-426 W1 — the store is keyed by NODE, and the simulated clock is
17463 // not. This function is where those two facts meet, so the rule is
17464 // stated here rather than left to whichever call site got there first.
17465 //
17466 // `use_sim_time` is a per-node parameter in ROS 2, and rclcpp means it:
17467 // every `Node` builds its own `TimeSource` over its own `Clock`, so two
17468 // nodes composed into one process CAN be configured differently and
17469 // stamp messages from different clocks at the same instant. This tree
17470 // deliberately does not follow that (phase-425): "the override is
17471 // process-global — one simulated clock per image, the model
17472 // `nros_core::Clock` already documents — so there is nothing to attach
17473 // to and nothing to fan out." That is the right trade for an image with
17474 // a fixed RAM budget, and it makes "whose `use_sim_time`?" a question
17475 // rclcpp never has to answer and this executor now does.
17476 //
17477 // ANY node asking arms the one source. Two reasons, and the second is
17478 // the binding one:
17479 //
17480 // * it is the only rule the CALLERS can implement. `note_param_
17481 // services_ran` fires after a runtime `ros2 param set` and knows
17482 // only that N requests were handled — not which node was written.
17483 // A last-writer-wins rule would need a writer identity that does
17484 // not reach here, so it would silently degrade to "whatever node 0
17485 // says", which is the default-to-primary bug phase-426 W1 exists to
17486 // remove.
17487 // * one node still wanting simulated time is a reason to keep the
17488 // source attached, and detaching is the destructive direction:
17489 // phase-425 already records that turning it off "stops SAMPLES, not
17490 // the subscription", precisely so a mid-run change does not read as
17491 // a clock jump.
17492 //
17493 // The scan is over the executor's own node table, which is the same
17494 // `u8` index space `NodeKey` wraps, so there is one node identity here
17495 // and not two.
17496 let Some(params) = self.params.as_deref() else {
17497 return;
17498 };
17499 let mut stated = false;
17500 let mut enable = false;
17501 // A `NodeId` IS the index into this table (`NodeId::index`), so the
17502 // enumeration is the key space — no second derivation of the number.
17503 //
17504 // `.max(1)` because `NodeId::PRIMARY` is IMPLICIT: the executor's node
17505 // table starts empty and a parameter can be declared on the primary
17506 // node before any `create_node` puts a record in it. Scanning only the
17507 // table would then find nothing and leave `sim_time_stated` false, so a
17508 // `use_sim_time` declared before the first node would never arm the
17509 // source — which `use_sim_time_attaches_and_detaches_the_clock_source`
17510 // catches, since its whole first half runs while "there is still no
17511 // node". Slot 0 is always a legitimate key; at worst the lookup misses.
17512 for idx in 0..self.nodes.len().max(1) {
17513 let key = nros_params::NodeKey::new(idx as u8);
17514 if let Some(v) = params
17515 .server
17516 .get_bool(key, crate::time_source::USE_SIM_TIME_PARAM)
17517 {
17518 stated = true;
17519 enable |= v;
17520 }
17521 }
17522 if stated {
17523 self.sim_time_requested = enable;
17524 self.sim_time_stated = true;
17525 }
17526 }
17527
17528 /// phase-425 W3b — the installed `/clock` subscription, if any.
17529 ///
17530 /// The HANDLE rather than a bool, because "did we subscribe twice" is the
17531 /// question a reconciliation loop has to be able to answer: a second
17532 /// registration takes a new slot, so a stable handle across spins is the
17533 /// evidence that the loop is idempotent.
17534 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17535 pub fn ros_time_source_handle(&self) -> Option<HandleId> {
17536 self.sim_time_source
17537 }
17538
17539 /// phase-425 W3b — whether a `/clock` subscription is currently installed.
17540 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17541 pub fn ros_time_source_installed(&self) -> bool {
17542 self.sim_time_source.is_some()
17543 }
17544
17545 /// phase-425 W3 — subscribe `topic` and install every sample as this
17546 /// image's ROS time. The registration behind
17547 /// [`Node::install_ros_time_source`](super::node::Node::install_ros_time_source)
17548 /// and behind the `use_sim_time` reconciliation, so both spell the QoS and
17549 /// the conversion exactly once.
17550 #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
17551 pub(crate) fn install_ros_time_source(
17552 &mut self,
17553 node_id: super::node_record::NodeId,
17554 topic: &str,
17555 ) -> Result<HandleId, NodeError> {
17556 self.register_subscription_buffered_on::<
17557 nros_rosgraph_msgs::msg::Clock,
17558 _,
17559 { crate::config::DEFAULT_RX_BUF_SIZE },
17560 >(
17561 node_id,
17562 topic,
17563 QoSProfile::clock_default(),
17564 |msg: &nros_rosgraph_msgs::msg::Clock| {
17565 // The `use_sim_time` gate is read HERE rather than by removing
17566 // the subscription, because there is no entity removal. An
17567 // image that never touches `use_sim_time` and installs the
17568 // source explicitly is active by default.
17569 if !crate::time_source::is_active() {
17570 return;
17571 }
17572 if let Some(nanos) =
17573 crate::time_source::override_nanos(msg.clock.sec, msg.clock.nanosec)
17574 {
17575 nros_core::clock::Clock::set_ros_time_override(nanos);
17576 }
17577 },
17578 None, // no group — node default
17579 None, // the configured default RX buffer, unchanged
17580 )
17581 }
17582}
17583
17584// ============================================================================
17585// phase-417 W4.c — cancel / is_spinning
17586// ============================================================================
17587//
17588// Same gate as `executor::tests`, exactly: `ConcreteSession` is `MockSession`
17589// only while no rmw-* feature is unified in (see `executor/mod.rs`), and
17590// `from_session_with` / `halt_flag` are `alloc`. No `std` gate — `cfg(test)`
17591// already means a hosted harness, which is why `lib.rs` brings `std` in under
17592// `test` as well. (It said "exactly" while missing `alloc`, and `std` was not in
17593// scope: 20 errors, hidden by feature unification under `--workspace`.)
17594#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
17595mod cancel_tests {
17596 use super::Executor;
17597 use crate::{executor::types::ExecutorConfig, mock::MockSession};
17598
17599 /// The core has no default clock and says so (issue 0709), so a test that
17600 /// measures elapsed time brings its own — the same seam a board uses.
17601 fn test_clock_us() -> u64 {
17602 use std::{sync::OnceLock, time::Instant};
17603 static EPOCH: OnceLock<Instant> = OnceLock::new();
17604 EPOCH.get_or_init(Instant::now).elapsed().as_micros() as u64
17605 }
17606
17607 fn executor() -> Executor<'static> {
17608 let cfg = ExecutorConfig::default().clock_us(test_clock_us);
17609 Executor::from_session_with(MockSession::new(), &cfg)
17610 }
17611
17612 /// A fresh executor is neither cancelled nor spinning — the two questions
17613 /// are independent, and this is the state where both answers are `false`.
17614 #[test]
17615 fn a_fresh_executor_is_neither_cancelled_nor_spinning() {
17616 let exec = executor();
17617 assert!(!exec.is_halted(), "nothing has asked it to stop");
17618 assert!(!exec.is_spinning(), "no spin loop is running");
17619 }
17620
17621 /// `cancel()` raises the request bit without starting or stopping anything.
17622 #[test]
17623 fn cancel_requests_without_spinning() {
17624 let exec = executor();
17625 exec.cancel();
17626 assert!(exec.is_halted(), "cancel() must be observable immediately");
17627 assert!(
17628 !exec.is_spinning(),
17629 "cancel() must not make a non-spinning executor claim to spin"
17630 );
17631 }
17632
17633 /// The load-bearing one: `cancel()` from a peer thread TERMINATES a running
17634 /// `spin`, `is_spinning()` is true while the loop runs, and false
17635 /// once it has returned.
17636 ///
17637 /// The peer asserts `is_spinning()` from OUTSIDE the spinning thread, which
17638 /// is the whole point of the observable — it is what a signal handler or a
17639 /// supervisor sees.
17640 #[test]
17641 fn cancel_terminates_a_spin_and_is_spinning_tracks_it() {
17642 use std::{
17643 sync::{
17644 Arc,
17645 atomic::{AtomicBool, Ordering},
17646 },
17647 thread,
17648 time::{Duration, Instant},
17649 };
17650
17651 let mut exec = executor();
17652 assert!(!exec.is_spinning());
17653
17654 // `Executor` is not `Sync`, so the peer thread cannot hold a reference
17655 // to it. It observes and drives through the two flag handles instead —
17656 // which is the supported cross-thread surface (`halt_flag()`), and
17657 // keeps this test on the same seam a signal handler uses.
17658 let cancel_bit = exec.halt_flag();
17659 let saw_spinning = Arc::new(AtomicBool::new(false));
17660 let saw_spinning_peer = Arc::clone(&saw_spinning);
17661
17662 let peer = thread::spawn(move || {
17663 // Give the loop time to enter. Bounded, and the assertion below is
17664 // on what was OBSERVED, so a slow host cannot turn this green by
17665 // accident — it fails instead.
17666 thread::sleep(Duration::from_millis(50));
17667 saw_spinning_peer.store(true, Ordering::SeqCst);
17668 cancel_bit.store(true, Ordering::SeqCst);
17669 });
17670
17671 let started = Instant::now();
17672 exec.spin(crate::executor::types::SpinOptions::default())
17673 .expect("an untimed spin runs until cancelled");
17674 let elapsed = started.elapsed();
17675
17676 peer.join().expect("peer thread must not panic");
17677
17678 assert!(
17679 saw_spinning.load(Ordering::SeqCst),
17680 "the peer must have run before spin returned"
17681 );
17682 assert!(
17683 !exec.is_spinning(),
17684 "is_spinning() must be false once the loop has returned"
17685 );
17686 assert!(
17687 exec.is_halted(),
17688 "the cancel request survives the loop it ended"
17689 );
17690 // LOWER bound too: a spin that never waited would prove nothing about
17691 // termination. The peer sleeps 50 ms before cancelling.
17692 assert!(
17693 elapsed >= Duration::from_millis(40),
17694 "spin returned in {elapsed:?} — it cannot have spun"
17695 );
17696 // ...and an UPPER bound, because "terminated" is the claim: without the
17697 // cancel check this loop never returns and the test would hang rather
17698 // than fail. 10 s is far past any scheduling noise.
17699 assert!(
17700 elapsed < Duration::from_secs(10),
17701 "spin took {elapsed:?} to observe the cancel"
17702 );
17703 }
17704
17705 /// `is_spinning()` is true from INSIDE the loop. Read from a timer callback,
17706 /// which is the only place the executor is genuinely mid-spin, and asserted
17707 /// after the loop so a callback that never fired cannot pass silently.
17708 #[test]
17709 fn is_spinning_is_true_inside_the_loop() {
17710 use std::time::Duration;
17711
17712 let mut exec = executor();
17713 // `only_next` runs exactly one iteration, so this is a single poll
17714 // inside a spin scope — bounded, no thread, no clock dependency beyond
17715 // the one the fixture installs.
17716 assert!(!exec.is_spinning());
17717 exec.spin(crate::executor::types::SpinOptions::spin_once())
17718 .expect("a single-iteration spin must succeed");
17719 assert!(
17720 !exec.is_spinning(),
17721 "the scope must close when the loop returns"
17722 );
17723
17724 // The inside-the-loop reading needs a second thread to do it, because
17725 // the spinning thread is blocked in the loop and `Executor` is `!Sync`
17726 // so no `&Executor` may cross. What crosses is a pointer to the one
17727 // atomic, which is the same thing the C and C++ APIs hold: they observe
17728 // `is_spinning` through a raw executor handle, and that is the case this
17729 // flag exists to serve.
17730 struct FlagPtr(*const portable_atomic::AtomicBool);
17731 // SAFETY: the pointee is an `AtomicBool`, which is `Sync`; the only
17732 // operation performed through it is a relaxed-or-stronger load. The
17733 // executor outlives the thread — `peer.join()` below happens before
17734 // `exec` is dropped at the end of the test.
17735 unsafe impl Send for FlagPtr {}
17736
17737 let cancel_bit = exec.halt_flag();
17738 let observed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
17739 let spinning_bit_seen = std::sync::Arc::clone(&observed);
17740 let flag = FlagPtr(&exec.spinning as *const portable_atomic::AtomicBool);
17741 let peer = std::thread::spawn(move || {
17742 let flag = flag;
17743 for _ in 0..500 {
17744 // SAFETY: see the `unsafe impl Send` above.
17745 if unsafe { &*flag.0 }.load(core::sync::atomic::Ordering::SeqCst) {
17746 spinning_bit_seen.store(true, std::sync::atomic::Ordering::SeqCst);
17747 break;
17748 }
17749 std::thread::sleep(Duration::from_millis(2));
17750 }
17751 cancel_bit.store(true, std::sync::atomic::Ordering::SeqCst);
17752 });
17753
17754 exec.spin(crate::executor::types::SpinOptions::default())
17755 .expect("an untimed spin runs until cancelled");
17756 peer.join().expect("peer thread must not panic");
17757
17758 assert!(
17759 observed.load(std::sync::atomic::Ordering::SeqCst),
17760 "is_spinning() must read true while spin is running"
17761 );
17762 }
17763
17764 /// `spin_period` maintains the same scope, and a period this build cannot
17765 /// pace is an error that must NOT leave the executor claiming to spin.
17766 #[test]
17767 fn spin_period_with_no_clock_leaves_is_spinning_false() {
17768 let mut exec = executor();
17769 exec.clock_us_fn = None;
17770 let err = exec
17771 .spin_period(core::time::Duration::from_millis(10))
17772 .expect_err("a period with no clock must fail (issue 0709)");
17773 assert_eq!(err, crate::NodeError::NotInitialized);
17774 assert!(
17775 !exec.is_spinning(),
17776 "a spin that never started must not report that it did"
17777 );
17778 }
17779
17780 /// Entering a spin CLEARS a stale cancel: a cancel requested before this
17781 /// spin is not a cancel of this spin. Documents the ADOPT-BOUNDED envelope's
17782 /// one sharp edge.
17783 #[test]
17784 fn entering_a_spin_clears_a_stale_cancel() {
17785 let exec = executor();
17786 exec.cancel();
17787 assert!(exec.is_halted());
17788 exec.enter_spin_loop();
17789 assert!(
17790 !exec.is_halted(),
17791 "a cancel raised before the loop started must not end it instantly"
17792 );
17793 assert!(exec.is_spinning());
17794 exec.exit_spin_loop();
17795 assert!(!exec.is_spinning());
17796 }
17797}
17798
17799/// phase-509 P9b item 3 (RFC-0108 D7) -- the arrival path wakes only the
17800/// worker whose band holds the slot.
17801#[cfg(all(test, feature = "alloc", feature = "rmw-cffi"))]
17802mod band_wake_routing_tests {
17803 use super::{super::scheduler::BandWaiter, WakeCtx};
17804 use core::ffi::c_void;
17805 use portable_atomic::{AtomicUsize, Ordering};
17806
17807 static WOKEN: [AtomicUsize; 2] = [AtomicUsize::new(0), AtomicUsize::new(0)];
17808
17809 unsafe extern "C" fn wake(ctx: *mut c_void) {
17810 WOKEN[ctx as usize].fetch_add(1, Ordering::SeqCst);
17811 }
17812
17813 unsafe extern "C" fn wait(_ctx: *mut c_void, _us: u64) -> i32 {
17814 0
17815 }
17816
17817 #[test]
17818 fn an_attributed_arrival_wakes_its_band_and_an_unattributed_one_wakes_all() {
17819 // SAFETY: the contexts are plain indices `wake` decodes.
17820 static A: BandWaiter = unsafe { BandWaiter::new(wake, wait, core::ptr::null_mut()) };
17821 static B: BandWaiter =
17822 unsafe { BandWaiter::new(wake, wait, core::ptr::without_provenance_mut(1)) };
17823 A.slots.store(0b0011, Ordering::SeqCst);
17824 B.slots.store(0b0100, Ordering::SeqCst);
17825 let flag = portable_atomic_util::Arc::new(portable_atomic::AtomicBool::new(false));
17826 let ctx = WakeCtx::new(flag.clone(), None);
17827 B.next.store(&A as *const _ as *mut _, Ordering::SeqCst);
17828 ctx.band_waiters
17829 .store(&B as *const _ as *mut _, Ordering::SeqCst);
17830
17831 ctx.mark_ready(2);
17832 assert_eq!(WOKEN[1].load(Ordering::SeqCst), 1, "slot 2 is B's");
17833 assert_eq!(WOKEN[0].load(Ordering::SeqCst), 0);
17834 assert!(
17835 !flag.load(Ordering::SeqCst),
17836 "the executor's own worker sleeps on"
17837 );
17838
17839 ctx.mark_ready(1);
17840 assert_eq!(WOKEN[0].load(Ordering::SeqCst), 1, "slot 1 is A's");
17841
17842 ctx.mark_ready(5);
17843 assert!(
17844 flag.load(Ordering::SeqCst),
17845 "no band holds slot 5: own worker"
17846 );
17847 assert_eq!(
17848 WOKEN[0].load(Ordering::SeqCst) + WOKEN[1].load(Ordering::SeqCst),
17849 2
17850 );
17851
17852 ctx.signal();
17853 assert_eq!(
17854 WOKEN[0].load(Ordering::SeqCst),
17855 2,
17856 "unattributed: every band"
17857 );
17858 assert_eq!(WOKEN[1].load(Ordering::SeqCst), 2);
17859 assert_eq!(ctx.ready.load(Ordering::SeqCst), 0b100110);
17860 }
17861}