Dispatch Strategy
Phase 216 design rationale. This chapter explains why
DispatchStrategyis shaped the way it is — the trichotomy, the per-Node granularity, the tag-based callback API, the__nros_node_<pkg>_dispatch_strategy()ABI symbol, and the backward-compat contract. For the user-facing tutorials see RTIC Integration and Embassy Integration.
The Inline / Deferred / FromIsr trichotomy
A nano-ros Node declares Node::DISPATCH: DispatchStrategy to tell the
codegen + lint layers how its callbacks need to be delivered:
#![allow(unused)]
fn main() {
// File: packages/core/nros-platform/src/board/dispatch.rs
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum DispatchStrategy {
Inline = 0,
Deferred = 1,
FromIsr = 2,
}
}
The variants:
-
Inline— Callbacks fire from the executor’s spin loop, in the same task that drives transport I/O. Default for every Node (preserves every pre-Phase-216 Node pkg unchanged). Served by every runtime: POSIX, FreeRTOS, NuttX, Zephyr, ThreadX, bare-metal, RTIC (proxied via__nros_dispatchtask when needed), Embassy (likewise — though Inline is unusual under cooperative async; see the matrix below). -
Deferred— Callbacks land in a board-side queue (heapless::spsc::Queueon RTIC,embassy_sync::channel::Channelon Embassy). A framework-owned dispatch task drains the queue and drivesExecutableNode::on_callbackfrom its own task context, decoupling callback latency from network polling. Required for callback-driven Nodes on RTIC + Embassy. -
FromIsr— Callbacks fire directly from an ISR handler. Design slot only — implementation deferred to Phase 216.E.1. Reserved so the lint matrix has a stable discriminant to reject against; current builds error out at thenros checklayer.
Framework × Strategy matrix
The matrix nros check (Phase 216.D.1) enforces:
┌──────────────────────────────────────────────────────┐
│ DispatchStrategy │
│ Inline │ Deferred │ FromIsr │
┌─────────────┼──────────────┼─────────────────┼─────────────────────┤
│ posix │ OK │ OK │ ERR: no ISRs │
│ rtos │ OK │ OK │ ERR: no ISRs │
│ rtic │ WARN: pref. │ OK (canonical) │ FUTURE (216.E.1) │
│ │ Deferred │ │ │
│ embassy │ WARN: pref. │ OK (canonical) │ ERR: no ISR exec │
│ │ Deferred │ │ │
└─────────────┴──────────────┴─────────────────┴─────────────────────┘
- POSIX + RTOS: both Inline and Deferred work. Inline is the default because the executor’s spin loop is the natural place to dispatch callbacks on hosted targets.
- RTIC + Embassy: Deferred is canonical. Inline is permitted (the
framework adapter still serves it) but
nros checkwarns: callback-driven Nodes that run inline tie their handler latency to spin task scheduling, which is rarely what you want under hardware-interrupt or async-cooperative scheduling. FromIsron POSIX/RTOS: rejected — there’s no meaningful “ISR context” for nano-ros to dispatch from on a hosted OS.FromIsron RTIC: future — needs the reentrancy audit + SPSC rework + per-Node#[isr_safe]contract called out in Phase 216.E.1.FromIsron Embassy: rejected — Embassy has no concept of “callback fires from an ISR handler”; ISR-driven work hands off to an async task viaembassy_sync::signal::Signalor similar.
Why per-Node, not per-callback
A Node that wants its /chatter subscription to run inline but its
/heartbeat subscription to run deferred is conceptually expressible —
we could put DispatchStrategy on each create_subscription call.
We don’t, for two reasons:
- The lint matrix collapses. Per-callback strategies multiply the
(framework, strategy)matrix by the number of callbacks per Node. The error surface grows quadratically and the messages get harder to phrase. Per-Node keeps each Node either fully Inline or fully Deferred — one strategy to reason about per pkg. - Implementation simplicity. The dispatch task drains a single
queue and routes each entry by
CallbackIdto a single Node’son_callback. Per-callback would mean tagging each entry with its own strategy at enqueue time and forking the dispatch path. Adds weight for a feature no real user has asked for yet.
If a real user demonstrates a Node that genuinely wants mixed strategies — typically because one subscription handler must hold a high-priority lock while another doesn’t — Phase 216.E.3 is the slot to reconsider. Until then: YAGNI.
Why FromIsr is a design slot, not an impl
The FromIsr discriminant exists in the enum today so that:
- The
[repr(u8)]discriminants are stable.Inline = 0,Deferred = 1,FromIsr = 2are wire-frozen — the__nros_node_<pkg>_dispatch_strategy()ABI symbol returns au8thatnros checkreads without linking the Node crate. AddingFromIsrlater (when 216.E.1 lands) would either renumber existing discriminants (breaking already-compiled Node binaries) or require a new ABI symbol. - The lint matrix has somewhere to point. Without the variant in the
enum, the
nros checkmatrix would have to special-case “user wroteFromIsrbut it doesn’t exist yet” via spelling-comparison rather than enum match. Worse error messages, worse evolvability.
The actual implementation needs three pieces that aren’t there yet:
- Reentrancy audit. Every step of the dispatch path —
signal_callback, queue push, RMW raw-CDR buffer ownership — must be re-entrant against ISR-priority producers. Today’s path assumes thread-context callers. - Lock-free SPSC variant.
heapless::spsc::Queueis single- producer / single-consumer at thread priority. ISR-priority producer + thread-priority consumer needs a stronger ordering contract (memory barrier on the ISR-side push at minimum, possibly a different queue type). - Per-Node
#[isr_safe]proof contract.on_callbackmust not call anything that can block, panic, or allocate. Statically proving that for arbitrary user code is a documentation + attribute exercise we haven’t undertaken.
The substrate Phase 214.J built (atomic_waker for cross-task
notification) is the building block for the SPSC variant; landing
214.J was a precondition for being able to even prototype
FromIsr. The full impl is deferred until a real ISR-driven
driver demands it.
Tag-based callback API rationale
The closure-based registration API:
#![allow(unused)]
fn main() {
// Pre-216.A.4 — still valid for Inline Nodes.
ctx.create_subscription("/chatter", |msg: Int32| {
handle(msg);
});
}
works fine when the callback runs synchronously on the spin task: the
closure captures state by value (or by &mut) and gets called inline
during spin_once. The captured environment lives on the spin
task’s stack frame, no heap, no boxing.
Deferred dispatch breaks that assumption. The callback now fires from
a different task than the one that called register, which means
the closure environment must either:
- Outlive both tasks (require
'staticcapture —move ||everywhere, but state still needs somewhere to live), or - Be stored generically in the runtime, which means erasing the
closure type to
Box<dyn FnMut(...)>(alloc-dependent) or toextern "C" fn(no captures at all).
Both options conflict with the no-alloc + framework-task-routed contract. So Phase 216.A.4 introduces tags:
#![allow(unused)]
fn main() {
// File: packages/core/nros/src/dispatch_tag.rs
pub struct SubscriptionTag(&'static str);
pub struct ServiceTag(&'static str);
pub struct ActionTag(&'static str);
impl From<SubscriptionTag> for CallbackId<'static> { /* ... */ }
impl PartialEq<Callback<'_>> for SubscriptionTag { /* ... */ }
}
The tag carries only the &'static str callback identifier — zero
runtime cost, no captures, FFI-safe. State lives on
ExecutableNode::State; the macro-emitted init() body wires the
tag fields by calling the _static registration variants
(create_subscription_static / create_service_static /
create_action_static) and storing the returned tag onto
Self::State.
Dispatch then matches the tag against the Callback<'_> event:
#![allow(unused)]
fn main() {
fn on_callback(state: &mut Self::State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
if state.sub_chatter == cb {
let msg: Int32 = ctx.downcast().unwrap();
// ...
} else if state.sub_heartbeat == cb {
let beat: Heartbeat = ctx.downcast().unwrap();
// ...
}
}
}
The PartialEq<Callback<'_>> impl on each tag type does the
comparison in &'static str terms — O(ptr_eq) in the common case
when the runtime hands back the same &'static the user registered.
Inline keeps closures. The closure-vs-tag split is enforced by the
macro lint (216.A.6): a Deferred Node using create_subscription (the
closure form) fails to compile with a clear error pointing at the
registration call and suggesting the _static form. An Inline Node
using _static is allowed — but Deferred → closure is rejected.
Zero migration cost for pre-216 Node pkgs (all defaulted to Inline)
and a forced-correct API for the Deferred path.
The __nros_node_<pkg>_dispatch_strategy() ABI symbol
The nros::node!() macro emits, per Node pkg:
#![allow(unused)]
fn main() {
// Emitted by nros::node!(Talker) — Phase 216.A.5.
#[unsafe(no_mangle)]
extern "C" fn __nros_node_talker_pkg_dispatch_strategy() -> u8 {
<Talker as ::nros::Node>::DISPATCH as u8
}
}
Three consumers care about this symbol:
-
nros check(Phase 216.D.1). Statically inspects the Node crate’s.rmetaor links the staticlib + reads the symbol viadlsym/GetProcAddress(host-side check; embedded targets only read it from.rmeta). Compares against the Entry pkg’s boardframeworkmetadata using the matrix above; rejects mismatches atnros checktime, before the user runscargo build. -
The
nros::main!()proc-macro (Phase 216.B.3 / C.3). When expanding the Entry pkg’smain.rsit walks the registered Node list and reads each pkg’s strategy. If any Node is Deferred, the generated#[rtic::app]/#[embassy_executor::main]body includes the__nros_dispatchtask; if all are Inline, the dispatch task is omitted (zero overhead for Inline-only workspaces). -
Future runtime diagnostic tools. A
nros doctorornros topologystyle introspection tool can read the symbols from a linked binary and print the (pkg, strategy) table without having to re-parse the source. Useful for post-mortem on a binary you didn’t build yourself.
The extern "C" + [repr(u8)] ABI is the contract. It must not
break across nano-ros versions — adding a new strategy variant means
adding a new discriminant (FromIsr = 2 was reserved up front
exactly to avoid this), never renumbering an existing one.
The trait surface split (post-214.K.1)
Phase 214.K.1 renamed the board-side dispatch sink from NodeRuntime
to NodeDispatchRuntime (the user-facing sink kept the
NodeRuntime name, which is now in packages/core/nros/src/node.rs).
Phase 216 lands its new methods on NodeDispatchRuntime:
#![allow(unused)]
fn main() {
// File: packages/core/nros-platform/src/board/runtime.rs
pub trait NodeDispatchRuntime {
// ... existing methods unchanged ...
fn signal_callback(&mut self, _cb_id: CallbackId<'_>, _ctx: &mut CallbackCtx<'_>) {
panic!("signal_callback not implemented for Inline runtime");
}
fn dispatch_strategy(&self) -> DispatchStrategy {
DispatchStrategy::Inline
}
}
}
Both methods are defaulted — zero-touch for the existing Inline
impls (ExecutorNodeRuntime in nros, NullNodeRuntime in
nros-platform). A Deferred runtime overrides both:
#![allow(unused)]
fn main() {
// File: packages/boards/nros-board-rtic-stm32f4/src/runtime.rs (sketch)
impl NodeDispatchRuntime for RticRuntime {
fn dispatch_strategy(&self) -> DispatchStrategy {
DispatchStrategy::Deferred
}
fn signal_callback(&mut self, cb_id: CallbackId<'_>, ctx: &mut CallbackCtx<'_>) {
// SAFETY: SPSC producer is single-threaded by RTIC priority assignment.
self.queue_producer.enqueue((cb_id, ctx.snapshot())).unwrap();
// Wake the dispatch task; RTIC will schedule it at its declared priority.
__nros_dispatch::spawn().ok();
}
}
}
signal_callback’s default panic is the right behavior: the Inline
path never calls it (callbacks flow through the existing inline
trampoline). If a Deferred Node ends up on an Inline runtime — for
example because the user manually picked the wrong board — the panic
is louder than silently dropping the callback. The lint at
Phase 216.D.1 prevents this combination from compiling in the first
place; the panic is a belt-and-braces backstop.
Backward compatibility
Two contracts:
- Defaulted associated const.
Node::DISPATCHisconst DISPATCH: DispatchStrategy = DispatchStrategy::Inline;in the trait definition. Edition 2024 supports defaulted associated consts as stable, so every pre-216impl Node for ...block that doesn’t mentionDISPATCHcontinues to compile and is treated asInline. - Closure API preserved on the Inline path. The Inline runtime
keeps the closure-based registration path
(
create_subscription,create_service,create_action). The macro lint only rejects closure use whenDISPATCH = Deferred. Every Phase 212 Node pkg using closures stays valid without changes.
The migration shape for a Phase 212 Node that wants to move to Deferred is:
#![allow(unused)]
fn main() {
// Before — Phase 212 Inline-by-default.
impl Node for Listener {
const NAME: &'static str = "listener";
fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
ctx.create_subscription::<Int32>("/chatter", |msg| {
defmt::info!("Received: {}", msg.data);
})?;
Ok(())
}
}
// After — Phase 216 Deferred.
impl Node for Listener {
const NAME: &'static str = "listener";
const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
let _tag = ctx.create_subscription_static::<Int32>("/chatter")?;
Ok(())
}
}
impl ExecutableNode for Listener {
type State = ListenerState;
fn init() -> Self::State {
ListenerState { sub_chatter: SubscriptionTag::placeholder() }
}
fn on_callback(state: &mut Self::State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
if state.sub_chatter == cb {
let msg: Int32 = ctx.downcast().unwrap();
defmt::info!("Received: {}", msg.data);
}
}
}
}
The migration adds three things: the DISPATCH const, the tag-typed
State field, and the on_callback body. The closure body in the
“before” version becomes the if state.sub_chatter == cb { ... }
branch in the “after” version — same code, hoisted to a method.
See also
- RTIC Integration — user-facing tutorial for the RTIC side of dispatch.
- Embassy Integration — user-facing tutorial for the Embassy side of dispatch + spawn-from-sync.
- Scheduling Models — the real-time scheduling backdrop against which dispatch strategy choices are made.
docs/roadmap/phase-216-baremetal-framework-integration.md— the locked spec.packages/core/nros-platform/src/board/dispatch.rs— theDispatchStrategyenum.packages/core/nros/src/dispatch_tag.rs— the tag types.