Two-Layer API
nano-ros exposes the same communication primitives — subscriptions, publishers, services, service clients, action servers, action clients, timers — through two layers with disjoint verbs. Picking between them is a per-entity deployment choice. The two layers share the same session and compose freely.
| Layer 1 — caller polls | Layer 2 — executor dispatches | |
|---|---|---|
| Verb | Node::create_polling_* (receive side), Node::create_publisher / create_client | Executor::register_*, or rclrs’s Node::create_subscription(topic, callback) / create_service(name, callback) |
| Returns | Owned handle | Handle ID + dispatched closure |
| Receive shape | take() / call() → Promise<T> / try_accept_goal(...) | Closure runs on rx / reply / timer fire |
| Scheduler | Caller (RTIC, embassy, app loop) | Executor::spin_once |
| Typical use | RTIC tasks, single-task FreeRTOS, RT-bounded loops | Callback-shaped apps, multi-handler servers |
Rationale
Embedded callers want both. RTIC owns scheduling and refuses to hand its loop to a library; an embassy task wants to .await a Promise; a FreeRTOS task-per-entity port wants tight take polling. None of those fit the L2 callback model.
But a posix app that handles a dozen topics + a couple of services + an action server wants L2 — write one closure per entity and let spin_once() route events. Forcing it through L1 means writing a manual dispatcher.
The two layers are deliberately separate verb sets so the choice is explicit in source:
node.create_polling_subscription::<M>("/topic")— caller polls. No executor magic.executor.register_subscription::<M, _>("/topic", |m| { ... }), or rclrs’s spellingnode.create_subscription::<M, _>("/topic", |m| { ... })— executor dispatches.
phase-483 W2 gave the polled receive-side constructors their create_polling_*
names: on nros::Node, rclrs’s create_subscription and create_service take
a callback, and a polled form under the same name would compile a ported line
into a different contract.
The C / C++ FFI mirrors this split: rclc_polling_subscription_init + rclc_polling_subscription_take_serialized (L1, the type rclc_polling_subscription_t, inline storage — the caller takes from the entity) vs rclc_subscription_init_default + rclc_executor_add_subscription_* (L2, executor arena, callback supplied at registration).
What goes where
graph LR
EX["Executor::open(&config)"] --> NODE["executor.create_node(name)"]
subgraph "L2 — register_*"
direction LR
EX --> R_T["register_timer"]
EX --> R_S["register_subscription"]
EX --> R_SVC["register_service"]
EX --> R_AS["register_action_server"]
EX --> R_AC["register_action_client"]
end
subgraph "L1 — create_*"
direction LR
NODE --> C_P["create_publisher"]
NODE --> C_S["create_polling_subscription"]
NODE --> C_SS["create_polling_service"]
NODE --> C_SC["create_client"]
NODE --> C_AS["create_action_server"]
NODE --> C_AC["create_action_client"]
end
Both layers are wired up by the same call to Executor::open — the register_* family attaches to the executor’s arena, create_* borrows the executor’s session through a short-lived Node. The handles returned by create_* outlive the Node (owned, not borrowed), so the canonical pattern is to scope the Node to creation time only:
let mut executor = Executor::open(&config)?;
let publisher = {
let mut node = executor.create_node("talker")?;
node.create_publisher::<Int32>("/chatter")?
}; // node drops here; publisher outlives it
Picking the right layer
Use L1 (Node::create_*) when:
- The caller has its own scheduling primitive (RTIC dispatcher, embassy task, FreeRTOS task per entity).
- The flow is fundamentally request-response — service-client
Promise<Reply>and action-clientsend_goal→get_resultwork this way. - The receive logic isn’t simple enough to express as a one-shot closure — e.g. a Fibonacci server that publishes feedback in a loop after accepting a goal.
- You want no executor arena overhead (the L2 path stores the closure + its captures inline in the arena; the arena byte budget is per-build-tunable but non-zero).
Use L2 (Executor::register_*) when:
- The flow is event-driven with a simple handler — log every received message, reply to a service synchronously, fire a 1 Hz publish.
- You want
spin_onceto dispatch all handlers from one site (less state to thread through main). - The application is callback-shaped (matches
rclcpp/rclpymental models).
Service clients and action clients stay on L1 in the current code — they have no typed register_*_client<T> API (only register_*_raw byte-level). The typed user path is Node::create_client::<S> + client.call(&req) → Promise<Reply>. The request-response + Promise::await model is the L1 shape.
C / C++ mirror
Both layers cross the FFI cleanly:
- C, L1 polling —
rclc_polling_subscription_initwrites aRawSubscriptioninline inrclc_polling_subscription_t._opaque, andrclc_polling_subscription_take_serializedreads from the inline buffer. Like C++, C splits the two layers by TYPE (phase-487): a dispatchedrcl_subscription_thas no inline storage at all. Services and clients have the same split:rclc_polling_service_t/rclc_polling_client_twithrclc_polling_service_take_request_raw/rclc_polling_client_take_response_raw, beside the dispatchedrcl_service_t/rcl_client_t. Action servers and clients keep both roads in one type for now (phase-487 W4). - C, L2 callback —
rclc_subscription_init_defaultcreates the entity; the callback is supplied at registration (rclc_executor_add_subscription_with_context— rclc’s six arguments — for the typed path,rclc_executor_add_subscription_rawfor the byte path), which also allocates the executor-arena entry. The arena entry owns the subscriber, the callback and its context; the struct keeps the arena handle and the description it was initialised with. - C++ — typed templates wrap each FFI surface:
rclcpp::PollSubscription<M>+take,rclcpp::PollService<S>+take_requestandrclcpp::PollClient<S>+send_requestfor L1,rclcpp::PollingActionServer<A>for the L1 action path (122.3.d.b), the L2 executor-registered callback model via the existingrclcpp::ActionServer<A>API. The L1/L2 split is a TYPE split in C++ since phase-456: one class serving both owners offered the L1 method set on an L2 object, where the storage it reads is never filled.
For the per-FFI-function spec, see the Doxygen reference. For the example migration tally (32 examples on L2, 16 intentionally L1), see the unify-api-paths roadmap doc.
Event-driven path (122.3.c.6.e)
L1 polling assumes the caller has a spin loop. RTOS / embassy applications want the kernel to wake them when data lands. The Raw*::register_waker(&Waker) Rust API and the matching *_set_wake_callback(state, cb, ctx) C / C++ FFI plug a wake source into the same L1 handle. RMW backends that support waking (currently zenoh-pico for subscribers, service-clients, and service-servers) fire the callback from their rx path; other backends fall back to polling silently.
For the underlying primitive (ServiceTrait::register_waker in nros-rmw), see the trait doc. For the C wake-callback POD shape (rclc_wake_state_t { fn_ptr, ctx }), see the Doxygen reference — same canonical-spec rule as the platform API.