Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

QoS, Status Events, and Discovery

This page covers runtime event behavior that application authors need after choosing an RMW backend: liveliness, deadline misses, lost messages, and the places where nano-ros deliberately differs from standard ROS 2 waitset-style event handling.

Transport-level status events let an application observe conditions the underlying middleware detects: a remote node going silent, a publisher missing its rate, a subscriber dropping samples. nano-ros exposes a core subset useful for embedded RTOS deployments — liveliness changes, deadline misses, and message loss.

This page describes the event surface, the dispatch model, and the patterns that motivate having events at all on RTOS targets.

The event kinds

Nine event kinds: the five core kinds below, plus Iron’s matched and incompatible-type pairs (below). Three core kinds on subscribers, two on publishers.

Subscriber events

KindFires whenUse
LivelinessChangedA tracked publisher’s liveliness state changes (publisher started asserting / stopped)Safety-island fail-over: trigger MRM when remote control node goes silent
RequestedDeadlineMissedAn expected sample didn’t arrive within the configured deadlinePeriodic-sensor pattern: 100 Hz topic with 15 ms deadline; fire alarm when late
MessageLostThe backend dropped one or more samples (typically: ring-buffer overflow, slow consumer)Diagnostic + adaptive: log, drop more aggressively, coalesce

Publisher events

KindFires whenUse
LivelinessLostThis publisher missed its own liveliness assertion deadlineSelf-monitoring: detect own task starvation
OfferedDeadlineMissedThis publisher promised X Hz, fell behindSelf-monitoring: detect overrunning compute

Matched and incompatible type (Iron+)

Both pairs have one kind per side.

KindSideFires whenPayload
PublicationMatchedpublishera subscription starts or stops matching itMatchedStatus
SubscriptionMatchedsubscriptiona publisher starts or stops matching itMatchedStatus
PublisherIncompatibleTypepublishera subscription on the same topic name with another type appearsIncompatibleTypeStatus
SubscriptionIncompatibleTypesubscriptiona publisher on the same topic name with another type appearsIncompatibleTypeStatus

MatchedStatus is upstream’s rmw_matched_status_t — total_count, total_count_change, current_count, current_count_change (signed: an endpoint leaving is -1). The typical use is “start publishing once somebody listens”:

#![allow(unused)]
fn main() {
pub_.on_matched(|s| {
    if s.current_count > 0 { LISTENER_PRESENT.store(true, Ordering::Release); }
})?;
// Publisher-side events are observed on the publisher's poll points, so a
// publisher that is not publishing yet gives it one:
while !LISTENER_PRESENT.load(Ordering::Acquire) {
    let _ = pub_.assert_liveliness();
    executor.spin_once(Duration::from_millis(50));
}
}

C: rclc_publisher_set_matched_callback, rclc_publisher_set_incompatible_type_callback, rclc_subscription_set_matched_callback and rclc_subscription_set_incompatible_type_callback (the subscription must be added to an executor first), payloads rclc_matched_status_t / rclc_incompatible_type_status_t. C++: Publisher::on_matched / on_incompatible_type and the same pair on PollSubscription, taking rclcpp’s MatchedInfo / IncompatibleTypeInfo through a function pointer plus user_context. Like the five core C/C++ setters below, they keep the registration in one process-wide 16-slot table shared by C and C++.

Registering callbacks

Same shape as message callbacks. Register at construction time; callback fires from inside spin_once when the backend detects the event.

Rust

#![allow(unused)]
fn main() {
use core::time::Duration;

let mut sub = node.create_polling_subscription::<SensorReading>("/sensor")?;

// Liveliness — fires when the publisher comes / goes
sub.on_liveliness_changed(|status| {
    if status.alive_count == 0 {
        log::warn!("sensor publisher went silent");
        trigger_failover();
    }
})?;

// Deadline — fires when no sample within 15 ms
sub.on_requested_deadline_missed(Duration::from_millis(15), |status| {
    metric_inc(&LATE_SAMPLE_COUNT, status.total_count_change);
})?;

// Message lost — fires when backend drops a sample
sub.on_message_lost(|status| {
    log::warn!("dropped {} samples", status.total_count_change);
})?;
}

C

static void on_liveliness_changed(
        rcl_subscription_t *sub,
        rclc_liveliness_changed_status_t status,
        void *user_context) {
    if (status.alive_count == 0) {
        trigger_failover();
    }
}

rclc_subscription_set_liveliness_changed_callback(
    sub, on_liveliness_changed, NULL);

Same shape on the four other event kinds.

Every setter returns RCL_RET_OK once the backend holds the registration, and RCL_RET_UNSUPPORTED when the backend does not generate that event (XRCE-DDS and uORB carry none; zenoh-pico has no LivelinessChanged on bare metal). That answer comes from the backend, never from the C layer. The other returns:

  • RCL_RET_NOT_INIT — for the three subscription setters, the subscription has not been added to an executor yet (rclc_executor_add_subscription*). The executor owns the subscriber, so before that there is nothing to register on. Register after the add.
  • RCLC_RET_FULL — the process-wide event-slot table is full. It holds 16 registrations across every C and C++ entity; one publisher with all four of its kinds plus one subscription with all five take nine.

deadline_ms on the two deadline setters reaches the backend: zenoh uses it when the entity’s QoS states no deadline of its own, Cyclone DDS takes the deadline from the QoS profile and ignores the argument. Registering the same kind again replaces the earlier callback, and a callback never runs after rcl_publisher_fini / rcl_subscription_fini (or rclc_executor_remove_subscription), even if the backend fires late.

C++

static void on_liveliness_changed(void* storage,
                                  nros_cpp_liveliness_changed_status_t status,
                                  void* user_context) {
    if (status.alive_count == 0) trigger_failover();
}

// On `rclcpp::PollingSubscription` / `rclcpp::Publisher`.
sub.on_liveliness_changed(on_liveliness_changed, nullptr);
sub.on_requested_deadline_missed(15 /* ms */, on_deadline_missed, nullptr);

C function pointers + user_context only; there are no lambda or std::function overloads. The subscription-side setters exist on PollingSubscription alone. The first callback argument is the entity’s storage, and it follows the object through a C++ move. Same answers as C: ErrorCode::Unsupported where the backend does not generate the event, ErrorCode::Full when the shared 16-slot table is full.

How dispatch works

Events fire from inside drive_io on the executor thread, the same way message callbacks do:

spin_once(timeout)
 └─ session.drive_io(...)
       ├─ backend RX worker detects message → fires message callback
       ├─ backend RX worker detects liveliness change → fires liveliness callback
       ├─ backend timer expires (deadline) → fires deadline callback
       └─ return

Same execution context, same priority, same constraints as message callbacks. The max_callbacks_per_spin knob (see RTOS Cooperation) covers events too — an event callback counts as one against the cap.

This means events get the same scheduling treatment as messages. max_callbacks_per_spin = 1 will fire either one message OR one event per spin_once, whichever the backend has ready first.

Why a callback model, not a waitset

Upstream rmw.h exposes events via a waitset — register an rmw_event_t handle in a waitset, call rmw_wait, then rmw_take_event to pull payload. nano-ros doesn’t have a waitset (see RMW vs upstream Section 4).

Reusing the existing message-callback path avoids introducing the waitset. Trade-off: applications can’t bulk-poll for events. For the core set this isn’t load-bearing — events are rare, callbacks are cheap.

Backend support is uneven

Not every backend can generate every event. Apps must handle Unsupported errors when registering:

BackendLivelinessChanged / LostDeadlineMissedMessageLost
Cyclone DDS✅ Polled dds_get_liveliness_changed_status / _liveliness_lost_status✅ Polled dds_get_{requested,offered}_deadline_missed_status (deadline comes from the QoS profile, not the on_* argument)✅ Polled dds_get_sample_lost_status
XRCE-DDS✗ Not exposed (xrce-dds-client API limitation)✅ Client-side clock check against the last takeable arrival / last publish, fired from drive_io. The Agent’s DDS entity keeps an infinite deadline — uxrQoS_t has no field for one — so a peer matching on deadline still sees infinite✗ Not exposed
zenoh-pico✅ Poll-based via zenoh tokens (alive_count ∈ {0,1})✅ Shim-side clock check (<P as PlatformClock>::clock_ms)✅ Seq-gap detection from RMW attachment
uORB✗ No wire-level liveliness✗ No rate concept✗ Not wired: the C++ backend (nros-rmw-uorb) fills no event slot. The retired Rust uORB backend’s publish-counter MessageLost did not carry over

Matched / incompatible type: Cyclone DDS (its own matched status; builtin-topic type comparison) and zenoh-pico (the graph cache’s liveliness tokens, as rmw_zenoh_cpp does) carry both pairs; XRCE-DDS and uORB answer Unsupported. On zenoh the counts are snapshots of the graph cache, so total_count counts increases seen between polls, and an image built with graph discovery off (ZPICO_GRAPH_DISCOVERY=0) refuses the registration.

✅ = wired. ✗ = not wired (registration answers Unsupported). The slot-level view is generated from the backend sources in the per-RMW feature matrix.

Cyclone DDS carries the whole core set, as zenoh-pico does, but it does it by POLLING rather than by calling you back: the backend fills the ABI’s *_take_event slots instead of *_event_init, and the runtime drains them from the entity’s ordinary data path (has_data / take_serialized on a subscription; publish / assert_liveliness on a publisher). That is invisible from the application API — you still register with on_* and are still called back — but it has one consequence worth knowing: a publisher that stops publishing stops polling, so a publisher-side event is observed on the next publish. Apps should still call Subscriber::supports_event(kind) first or design for graceful fallback.

The subscription side has no such limit. Its statuses are also polled from has_data, and a subscription with an arrival waker is normally asked only after something arrives. Registering any status callback on a subscription changes that: the executor then asks it on every spin, arrival or not. So once the last publisher has gone, the matched count falls, the liveliness count drops, and RequestedDeadlineMissed fires, on the next spin after the backend notices. On zenoh that is within one liveliness query and one graph walk (at most every 50 ms). The interop cell native-qos-event-c-zenoh-bidir measures both falls against stock ROS 2. A subscription with no status callback is still asked only after an arrival, so it costs an idle executor nothing. On Cyclone DDS, whose has_data always answers “maybe”, a subscription with a status callback is dispatched on every spin; when nothing is waiting, the take finds nothing and the callback does not run.

The Subscriber::supports_event(kind) query lets applications check support before registering:

#![allow(unused)]
fn main() {
if sub.supports_event(EventKind::RequestedDeadlineMissed) {
    sub.on_requested_deadline_missed(...)?;
} else {
    // fallback: app-side timeout monitoring
}
}

Apps that need cross-backend portability code defensively. Apps pinned to one backend can call register-and-unwrap.

What’s deliberately skipped

One upstream event type is intentionally absent from the API (*_MATCHED and *_INCOMPATIBLE_TYPE are supported; see above):

*_QOS_INCOMPATIBLE

Fires when publisher and subscriber QoS profiles can’t be reconciled. nano-ros surfaces this synchronously at create_publisher / create_subscription time as NROS_RMW_RET_INCOMPATIBLE_QOS. No runtime event needed; the failure is visible at startup.

Patterns

Drone-bridge fail-over on liveliness loss

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<VehicleAttitude>("/vehicle_attitude")?;
sub.on_liveliness_changed(|status| {
    if status.alive_count == 0 {
        // PX4 commander.cpp went silent — trigger MRM
        request_minimum_risk_manoeuvre();
    } else if status.alive_count_change > 0 {
        // commander came back online; clear MRM if appropriate
        clear_failover();
    }
})?;
}

This pairs with the cross-backend bridge example pattern.

100 Hz sensor with deadline alarm

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<SensorReading>("/imu")?;
sub.on_requested_deadline_missed(
    Duration::from_millis(15),    // expected 100 Hz, allow 15 ms deadline
    |status| {
        if status.total_count_change > 0 {
            log::error!("IMU late: {} missed deadlines", status.total_count_change);
            // Optional: enter degraded-mode controller
        }
    },
)?;
}

Slow-consumer logging

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<Pointcloud>("/lidar")?;
sub.on_message_lost(|status| {
    log::warn!("dropped {} pointcloud frames (total: {})",
               status.total_count_change, status.total_count);
    // Tighten downstream filter, reduce work, coalesce, etc.
})?;
}

See also