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}