Skip to main content

nros_rmw/
event.rs

1//! Status-event surface (Phase 108).
2//!
3//! Tier-1 events that backends optionally surface — liveliness
4//! changes, deadline misses, message loss. Dispatched via
5//! callback-on-entity (registration on `Subscription` / `Publisher`),
6//! not via upstream's waitset-take pattern.
7//!
8//! Events fire from inside [`Session::drive_io`](crate::Session::drive_io)
9//! on the executor thread, the same as message callbacks. (Phase 105 counted
10//! both against a per-spin cap, ~~`max_callbacks_per_spin`~~; the executor has
11//! no such cap now.)
12//!
13//! See `book/src/communicate/qos-status-events.md` for the user-facing
14//! patterns and `book/src/extending/rmw-vs-upstream.md` Section 8 for
15//! the design rationale.
16//!
17//! ### Matched and incompatible-type (issue 1972)
18//!
19//! Iron/Jazzy's `*_MATCHED` and `*_INCOMPATIBLE_TYPE` kinds are appended
20//! after the Tier-1 set ([`EventKind::PublicationMatched`] onward), with
21//! payloads [`MatchedStatus`] and [`IncompatibleTypeStatus`]. Upstream's
22//! QOS_INCOMPATIBLE is still surfaced synchronously at create time as
23//! `TransportError::IncompatibleQos` (`NROS_RMW_RET_INCOMPATIBLE_QOS` at the
24//! C boundary); no runtime event for it.
25
26/// Status-event kinds. `#[non_exhaustive]` so adding a variant later is not
27/// a break for matchers. Discriminants equal the C `rmw_event_type_t`
28/// values, which only ever grow at the end.
29#[non_exhaustive]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[repr(u8)]
32pub enum EventKind {
33    /// Subscription: a tracked publisher's liveliness state changed
34    /// (started / stopped asserting).
35    LivelinessChanged = 0,
36    /// Subscription: an expected sample didn't arrive within the
37    /// configured deadline.
38    RequestedDeadlineMissed = 1,
39    /// Subscription: the backend dropped a sample (overflow / etc.).
40    MessageLost = 2,
41    /// Publisher: this publisher missed its own liveliness assertion.
42    LivelinessLost = 3,
43    /// Publisher: this publisher promised X Hz, fell behind.
44    OfferedDeadlineMissed = 4,
45    /// Publisher: the set of subscriptions matched to it changed. Upstream
46    /// `RMW_EVENT_PUBLICATION_MATCHED` (Iron+), issue 1972.
47    PublicationMatched = 5,
48    /// Subscription: the set of publishers matched to it changed. Upstream
49    /// `RMW_EVENT_SUBSCRIPTION_MATCHED`.
50    SubscriptionMatched = 6,
51    /// Publisher: an endpoint on the same topic was seen with a different
52    /// type. Upstream `RMW_EVENT_PUBLISHER_INCOMPATIBLE_TYPE`.
53    PublisherIncompatibleType = 7,
54    /// Subscription: an endpoint on the same topic was seen with a different
55    /// type. Upstream `RMW_EVENT_SUBSCRIPTION_INCOMPATIBLE_TYPE`.
56    SubscriptionIncompatibleType = 8,
57}
58
59impl EventKind {
60    /// `true` for a kind a PUBLISHER carries, `false` for a subscription's.
61    pub const fn is_publisher_side(self) -> bool {
62        matches!(
63            self,
64            EventKind::LivelinessLost
65                | EventKind::OfferedDeadlineMissed
66                | EventKind::PublicationMatched
67                | EventKind::PublisherIncompatibleType
68        )
69    }
70}
71
72/// Liveliness-status payload. Mirrors DDS
73/// `rmw_liveliness_changed_status_t` shape.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75#[repr(C)]
76pub struct LivelinessChangedStatus {
77    /// Number of currently-alive matched publishers (subscriber side)
78    /// or self-asserting state (publisher side, always 0/1).
79    pub alive_count: u16,
80    /// Number of currently-not-alive matched publishers.
81    pub not_alive_count: u16,
82    /// Change in `alive_count` since the last callback fire.
83    pub alive_count_change: i16,
84    /// Change in `not_alive_count` since the last callback fire.
85    pub not_alive_count_change: i16,
86}
87
88/// Deadline / message-lost payload. Used for
89/// `RequestedDeadlineMissed`, `LivelinessLost`,
90/// `OfferedDeadlineMissed`, `MessageLost` — same shape.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92#[repr(C)]
93pub struct CountStatus {
94    /// Cumulative count over the entity's lifetime.
95    pub total_count: u32,
96    /// Change since the last callback fire.
97    pub total_count_change: u32,
98}
99
100/// Matched payload — upstream `rmw_matched_status_t` (Iron+), issue 1972.
101///
102/// Same four fields in upstream's order. The counts are `u32` where upstream
103/// has `size_t`, like every other count on this surface.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105#[repr(C)]
106pub struct MatchedStatus {
107    /// Cumulative matches over the entity's lifetime.
108    pub total_count: u32,
109    /// Change in `total_count` since the last report.
110    pub total_count_change: u32,
111    /// Endpoints matched right now.
112    pub current_count: u32,
113    /// Change in `current_count` since the last report (negative when an
114    /// endpoint left).
115    pub current_count_change: i32,
116}
117
118/// Incompatible-type payload — upstream `rmw_incompatible_type_status_t`
119/// (Iron+), issue 1972. `u32` where upstream has `int32_t`: both only grow.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
121#[repr(C)]
122pub struct IncompatibleTypeStatus {
123    /// Cumulative incompatible endpoints seen over the entity's lifetime.
124    pub total_count: u32,
125    /// Change since the last report.
126    pub total_count_change: u32,
127}
128
129/// Turns successive SNAPSHOTS of a matched count into [`MatchedStatus`]
130/// reports — issue 1972. `no_std`, no allocation, eight bytes.
131///
132/// A backend that can count its matched peers but has no change counter of
133/// its own (zenoh's graph cache; a DDS backend whose change counters are
134/// reset by other readers of the same status) feeds the current count here
135/// on every poll, and gets a report only when it moved.
136///
137/// **`total_count` counts INCREASES SEEN between polls.** A peer that leaves
138/// and another that joins between two polls nets to no change and is not
139/// counted; upstream's DDS counter would count the join. A snapshot cannot
140/// do better, and saying so is the honest bound.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
142pub struct MatchedCounter {
143    total: u32,
144    current: u32,
145}
146
147impl MatchedCounter {
148    /// A counter that has seen nothing matched yet.
149    pub const fn new() -> Self {
150        Self {
151            total: 0,
152            current: 0,
153        }
154    }
155
156    /// The last `current_count` observed.
157    pub const fn current(&self) -> u32 {
158        self.current
159    }
160
161    /// Record a snapshot of how many endpoints are matched now. `Some` with
162    /// the report when the count moved, `None` when it did not.
163    pub fn observe(&mut self, current_now: u32) -> Option<MatchedStatus> {
164        if current_now == self.current {
165            return None;
166        }
167        let grew = current_now.saturating_sub(self.current);
168        let change = (current_now as i64 - self.current as i64)
169            .clamp(i32::MIN as i64, i32::MAX as i64) as i32;
170        self.total = self.total.saturating_add(grew);
171        self.current = current_now;
172        Some(MatchedStatus {
173            total_count: self.total,
174            total_count_change: grew,
175            current_count: current_now,
176            current_count_change: change,
177        })
178    }
179}
180
181/// Turns successive snapshots of an incompatible-type count into
182/// [`IncompatibleTypeStatus`] reports. Same bound as [`MatchedCounter`]:
183/// only increases between polls are counted.
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
185pub struct IncompatibleTypeCounter {
186    total: u32,
187    current: u32,
188}
189
190impl IncompatibleTypeCounter {
191    /// A counter that has seen nothing yet.
192    pub const fn new() -> Self {
193        Self {
194            total: 0,
195            current: 0,
196        }
197    }
198
199    /// Record how many incompatible endpoints are visible now. `Some` when
200    /// the number GREW (a new incompatible endpoint), `None` otherwise — a
201    /// mismatched endpoint leaving is not an event upstream reports either.
202    pub fn observe(&mut self, current_now: u32) -> Option<IncompatibleTypeStatus> {
203        let grew = current_now.saturating_sub(self.current);
204        self.current = current_now;
205        if grew == 0 {
206            return None;
207        }
208        self.total = self.total.saturating_add(grew);
209        Some(IncompatibleTypeStatus {
210            total_count: self.total,
211            total_count_change: grew,
212        })
213    }
214}
215
216/// Type alias: the deadline-missed shape is identical to
217/// [`CountStatus`].
218pub type DeadlineMissedStatus = CountStatus;
219
220/// Type alias: the message-lost shape is identical to [`CountStatus`].
221pub type MessageLostStatus = CountStatus;
222
223/// Borrow-shaped event payload union. Variant selection mirrors
224/// [`EventKind`].
225#[derive(Debug, Clone, Copy)]
226pub enum EventPayload<'a> {
227    LivelinessChanged(&'a LivelinessChangedStatus),
228    RequestedDeadlineMissed(&'a DeadlineMissedStatus),
229    MessageLost(&'a MessageLostStatus),
230    LivelinessLost(&'a CountStatus),
231    OfferedDeadlineMissed(&'a DeadlineMissedStatus),
232    PublicationMatched(&'a MatchedStatus),
233    SubscriptionMatched(&'a MatchedStatus),
234    PublisherIncompatibleType(&'a IncompatibleTypeStatus),
235    SubscriptionIncompatibleType(&'a IncompatibleTypeStatus),
236}
237
238impl<'a> EventPayload<'a> {
239    /// Returns the [`EventKind`] this payload corresponds to.
240    pub fn kind(&self) -> EventKind {
241        match self {
242            EventPayload::LivelinessChanged(_) => EventKind::LivelinessChanged,
243            EventPayload::RequestedDeadlineMissed(_) => EventKind::RequestedDeadlineMissed,
244            EventPayload::MessageLost(_) => EventKind::MessageLost,
245            EventPayload::LivelinessLost(_) => EventKind::LivelinessLost,
246            EventPayload::OfferedDeadlineMissed(_) => EventKind::OfferedDeadlineMissed,
247            EventPayload::PublicationMatched(_) => EventKind::PublicationMatched,
248            EventPayload::SubscriptionMatched(_) => EventKind::SubscriptionMatched,
249            EventPayload::PublisherIncompatibleType(_) => EventKind::PublisherIncompatibleType,
250            EventPayload::SubscriptionIncompatibleType(_) => {
251                EventKind::SubscriptionIncompatibleType
252            }
253        }
254    }
255}
256
257/// Raw event callback. Identical Rust + C ABI; no_std + alloc-free.
258///
259/// The backend invokes this with the [`EventKind`] selecting which
260/// payload-pointer variant is valid. `user_ctx` is opaque application
261/// state passed at registration.
262///
263/// **Lifetime.** `payload_ptr` is valid for the duration of this call
264/// only. Copy fields out before returning if needed beyond.
265///
266/// **Threading.** Invoked from inside `drive_io` on the executor
267/// thread; do not block.
268///
269/// **Safety contract.** The callback is `unsafe extern "C" fn`:
270/// implementors guarantee `payload_ptr` is non-null and points to a
271/// valid payload of the variant selected by `kind`.
272pub type EventCallback = unsafe extern "C" fn(
273    kind: EventKind,
274    payload_ptr: *const core::ffi::c_void,
275    user_ctx: *mut core::ffi::c_void,
276);
277
278/// Helper: convert a raw `(kind, payload_ptr)` pair into a typed
279/// [`EventPayload`] borrow. Used inside `EventCallback` trampolines.
280///
281/// # Safety
282///
283/// Caller guarantees `payload_ptr` matches the variant indicated by
284/// `kind` and is valid for the borrow lifetime.
285pub unsafe fn payload_from_raw<'a>(
286    kind: EventKind,
287    payload_ptr: *const core::ffi::c_void,
288) -> EventPayload<'a> {
289    match kind {
290        EventKind::LivelinessChanged => unsafe {
291            EventPayload::LivelinessChanged(&*(payload_ptr as *const LivelinessChangedStatus))
292        },
293        EventKind::RequestedDeadlineMissed => unsafe {
294            EventPayload::RequestedDeadlineMissed(&*(payload_ptr as *const CountStatus))
295        },
296        EventKind::MessageLost => unsafe {
297            EventPayload::MessageLost(&*(payload_ptr as *const CountStatus))
298        },
299        EventKind::LivelinessLost => unsafe {
300            EventPayload::LivelinessLost(&*(payload_ptr as *const CountStatus))
301        },
302        EventKind::OfferedDeadlineMissed => unsafe {
303            EventPayload::OfferedDeadlineMissed(&*(payload_ptr as *const CountStatus))
304        },
305        EventKind::PublicationMatched => unsafe {
306            EventPayload::PublicationMatched(&*(payload_ptr as *const MatchedStatus))
307        },
308        EventKind::SubscriptionMatched => unsafe {
309            EventPayload::SubscriptionMatched(&*(payload_ptr as *const MatchedStatus))
310        },
311        EventKind::PublisherIncompatibleType => unsafe {
312            EventPayload::PublisherIncompatibleType(
313                &*(payload_ptr as *const IncompatibleTypeStatus),
314            )
315        },
316        EventKind::SubscriptionIncompatibleType => unsafe {
317            EventPayload::SubscriptionIncompatibleType(
318                &*(payload_ptr as *const IncompatibleTypeStatus),
319            )
320        },
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn matched_counter_reports_only_movement_and_signs_the_change() {
330        let mut c = MatchedCounter::new();
331        assert_eq!(c.observe(0), None, "0 -> 0 is no event");
332        let up = c.observe(1).expect("0 -> 1");
333        assert_eq!(
334            up,
335            MatchedStatus {
336                total_count: 1,
337                total_count_change: 1,
338                current_count: 1,
339                current_count_change: 1,
340            }
341        );
342        assert_eq!(c.observe(1), None, "1 -> 1 is no event");
343        let up2 = c.observe(3).expect("1 -> 3");
344        assert_eq!((up2.total_count, up2.total_count_change), (3, 2));
345        let down = c.observe(0).expect("3 -> 0");
346        assert_eq!(
347            down,
348            MatchedStatus {
349                total_count: 3,
350                total_count_change: 0,
351                current_count: 0,
352                current_count_change: -3,
353            }
354        );
355        assert_eq!(c.current(), 0);
356    }
357
358    #[test]
359    fn incompatible_type_counter_reports_growth_only() {
360        let mut c = IncompatibleTypeCounter::new();
361        assert_eq!(c.observe(0), None);
362        assert_eq!(
363            c.observe(2),
364            Some(IncompatibleTypeStatus {
365                total_count: 2,
366                total_count_change: 2
367            })
368        );
369        assert_eq!(
370            c.observe(1),
371            None,
372            "a mismatched endpoint leaving is not an event"
373        );
374        assert_eq!(
375            c.observe(2),
376            Some(IncompatibleTypeStatus {
377                total_count: 3,
378                total_count_change: 1
379            })
380        );
381    }
382
383    #[test]
384    fn side_split_covers_every_kind() {
385        use EventKind::*;
386        for k in [
387            LivelinessLost,
388            OfferedDeadlineMissed,
389            PublicationMatched,
390            PublisherIncompatibleType,
391        ] {
392            assert!(k.is_publisher_side(), "{k:?}");
393        }
394        for k in [
395            LivelinessChanged,
396            RequestedDeadlineMissed,
397            MessageLost,
398            SubscriptionMatched,
399            SubscriptionIncompatibleType,
400        ] {
401            assert!(!k.is_publisher_side(), "{k:?}");
402        }
403    }
404
405    #[test]
406    fn payload_round_trips_through_the_raw_pointer() {
407        let m = MatchedStatus {
408            total_count: 4,
409            total_count_change: 1,
410            current_count: 2,
411            current_count_change: -1,
412        };
413        let p = unsafe {
414            payload_from_raw(
415                EventKind::SubscriptionMatched,
416                &m as *const _ as *const core::ffi::c_void,
417            )
418        };
419        assert_eq!(p.kind(), EventKind::SubscriptionMatched);
420        match p {
421            EventPayload::SubscriptionMatched(s) => assert_eq!(*s, m),
422            other => panic!("wrong arm: {other:?}"),
423        }
424    }
425}