hick_compio/service.rs
1//! `Service` handle returned by [`Endpoint::register_service`].
2//!
3//! Holds an [`Rc<EndpointInner>`] + the proto-layer [`ServiceHandle`] + a
4//! handle-owned `ServiceMailbox` the driver fills with [`ServiceUpdate`]s.
5//! The driver task owns the proto `Service` state machine inside
6//! `State.services`; this handle drains the shared mailbox under a brief borrow,
7//! then parks on the shared driver notifier when no update is ready.
8//!
9//! The mailbox is shared `Rc<RefCell<ServiceMailbox>>` between the driver ctx
10//! (which fills it) and this handle (which drains it) — the `!Send`,
11//! single-thread analogue of the multi-threaded reactor's `Arc<Mutex<_>>`
12//! mailbox, mirroring how the compio query path shares driver state via
13//! [`Rc`]/[`RefCell`] and wakes through the same `LocalNotify` driver notifier
14//! (no separate doorbell channel). Because the mailbox is owned by the HANDLE,
15//! not the driver ctx, the terminal retirement update (`Conflict`/`HostConflict`)
16//! keeps its own reserved slot and survives an immediate ctx GC: a still-live
17//! reader observes it even after the driver removed the ctx.
18//!
19//! Dropping a [`Service`] flags the service cancelled and wakes the driver; the
20//! driver's post-pump sweep then begins the endpoint-owned RFC 6762 §10.1
21//! withdrawal (the endpoint holds the route + drives the TTL=0 goodbye resend
22//! schedule, freeing the route on completion).
23//!
24//! [`Endpoint::register_service`]: crate::Endpoint::register_service
25
26use core::cell::RefCell;
27use std::{collections::VecDeque, rc::Rc};
28
29use mdns_proto::{ServiceHandle, ServiceUpdate};
30
31use crate::driver::EndpointInner;
32
33/// Upper bound on undelivered NON-terminal service updates buffered per service.
34///
35/// `Established` is one-time and `Renamed(..)` coalesces to the latest name, so
36/// in normal operation the backlog stays at one or two entries. The cap is a
37/// backstop: an on-link peer can force endless conflict-renames, and a
38/// non-draining caller would otherwise let the buffer grow without bound. Beyond
39/// the cap [`ServiceMailbox::push_update`] drops the oldest pending non-terminal
40/// update. The terminal retirement update (`Conflict`/`HostConflict`) has its own
41/// reserved slot and is never dropped. Matches the reactor driver's
42/// `SERVICE_UPDATE_CAPACITY`.
43pub(crate) const SERVICE_UPDATE_CAPACITY: usize = 16;
44
45/// Bounded, coalescing delivery buffer shared between the driver task (which
46/// fills it) and the [`Service`] handle (which drains it via [`Service::next`]).
47///
48/// This mirrors the reactor driver's `ServiceMailbox`: non-terminal updates are
49/// bounded ([`SERVICE_UPDATE_CAPACITY`]) and coalesced by kind so a flooding peer
50/// cannot grow the queue, while the terminal retirement update keeps a dedicated
51/// slot so it is delivered even under non-terminal backpressure and survives an
52/// immediate ctx GC (the mailbox is owned by the handle, not the driver ctx).
53pub(crate) struct ServiceMailbox {
54 /// NON-terminal updates (`Established` / `Renamed(..)`), coalesced by kind and
55 /// bounded by [`SERVICE_UPDATE_CAPACITY`]. Drained FIFO before the terminal.
56 updates: VecDeque<ServiceUpdate>,
57 /// RESERVED slot for the terminal retirement update (`Conflict` /
58 /// `HostConflict`). Independent of the non-terminal cap and idempotent (first
59 /// terminal wins), so it is always deliverable.
60 terminal: Option<ServiceUpdate>,
61 /// Set once the terminal has been handed to the consumer, so subsequent drains
62 /// report end-of-stream rather than waiting forever.
63 terminal_delivered: bool,
64}
65
66/// Result of draining one update from a [`ServiceMailbox`].
67#[cfg_attr(test, derive(Debug))]
68enum Drained {
69 /// An update is ready for the consumer.
70 Update(ServiceUpdate),
71 /// No more updates will ever arrive (terminal already delivered).
72 Ended,
73 /// Nothing ready right now; the consumer should wait for a wakeup.
74 Empty,
75}
76
77impl ServiceMailbox {
78 fn new() -> Self {
79 Self {
80 updates: VecDeque::new(),
81 terminal: None,
82 terminal_delivered: false,
83 }
84 }
85
86 /// Buffer a NON-terminal update (`Established` / `Renamed(..)`), bounding
87 /// memory while keeping insertion order:
88 ///
89 /// * `Renamed` — drop any prior pending `Renamed` and append the new one, so
90 /// only the LATEST name is kept, at its true (most recent) position (the
91 /// caller only needs the current name).
92 /// * `Established` — drop any prior pending `Established` and append, keeping
93 /// only the LATEST at its most-recent position, never displacing a pending
94 /// `Renamed`.
95 ///
96 /// Keeping only the latest `Established` preserves the post-rename confirmation
97 /// across an `Established -> Renamed -> Established` sequence (RFC 6762 §9
98 /// conflict re-probe): the second `Established` lands AFTER the `Renamed`, so
99 /// the caller learns the new name became advertised, while a duplicate or
100 /// pre-rename `Established` coalesces away. Renamed churn still coalesces to a
101 /// single pending `Renamed`; the [`SERVICE_UPDATE_CAPACITY`] cap is a hard
102 /// backstop that drops the oldest pending update if a future non-terminal kind
103 /// ever fills it.
104 ///
105 /// A terminal update passed here is ROUTED to [`Self::set_terminal`] instead
106 /// (terminals belong in the reserved slot); the driver routes by kind, so this
107 /// is defensive — it guarantees a retirement reason can never be lost in the
108 /// bounded ring.
109 pub(crate) fn push_update(&mut self, upd: ServiceUpdate) {
110 if upd.is_conflict() || upd.is_host_conflict() {
111 // Terminals never go into the non-terminal ring; route to the reserved
112 // slot instead so the caller can never lose a retirement reason.
113 self.set_terminal(upd);
114 return;
115 }
116 if upd.is_renamed() {
117 self.updates.retain(|u| !u.is_renamed());
118 self.bounded_push_back(upd);
119 return;
120 }
121 if upd.is_established() {
122 // Keep only the LATEST `Established`, at its most-recent position: drop any
123 // prior `Established`, then append. A post-rename `Established` (the
124 // `Established -> Renamed -> Established` lifecycle: established, conflict,
125 // auto-rename, re-establish under the new name) therefore lands AFTER the
126 // pending `Renamed`, so a slow reader observes "renamed, then established
127 // under the new name" — not just that a rename happened.
128 // Globally deduping by kind instead kept the EARLIER `Established` at the
129 // front and dropped this post-rename one. Bounds the ring to one `Renamed`
130 // plus one trailing `Established` under conflict-rename churn.
131 self.updates.retain(|u| !u.is_established());
132 self.bounded_push_back(upd);
133 return;
134 }
135 // Any future non-terminal kind: append (bounded by the cap).
136 self.bounded_push_back(upd);
137 }
138
139 /// Append `upd`, evicting the oldest pending non-terminal update first if the
140 /// ring is already at capacity (drop-oldest backstop).
141 fn bounded_push_back(&mut self, upd: ServiceUpdate) {
142 if self.updates.len() >= SERVICE_UPDATE_CAPACITY {
143 self.updates.pop_front();
144 }
145 self.updates.push_back(upd);
146 }
147
148 /// Record the terminal retirement update in its reserved slot (idempotent — the
149 /// first terminal wins, and a terminal is never recorded after one has already
150 /// been delivered).
151 pub(crate) fn set_terminal(&mut self, terminal: ServiceUpdate) {
152 if self.terminal.is_none() && !self.terminal_delivered {
153 self.terminal = Some(terminal);
154 }
155 }
156
157 /// Number of buffered NON-terminal updates (excludes the reserved terminal
158 /// slot). Test-only window into the bound + coalesce behaviour.
159 #[cfg(test)]
160 pub(crate) fn non_terminal_len(&self) -> usize {
161 self.updates.len()
162 }
163
164 /// Whether a terminal retirement update is pending in the reserved slot (and
165 /// not yet delivered). Test-only.
166 #[cfg(test)]
167 pub(crate) fn has_terminal(&self) -> bool {
168 self.terminal.is_some()
169 }
170
171 /// Drain one update (non-terminal first, then the reserved terminal) and report
172 /// it to the caller, or `None` at end-of-stream / when empty. Test-only
173 /// synchronous peek used by the driver-level tests to assert what was delivered
174 /// without awaiting the async [`Service::next`].
175 #[cfg(test)]
176 pub(crate) fn drain_for_test(&mut self) -> Option<ServiceUpdate> {
177 match self.drain() {
178 Drained::Update(upd) => Some(upd),
179 Drained::Ended | Drained::Empty => None,
180 }
181 }
182
183 /// Saturate the NON-terminal ring to [`SERVICE_UPDATE_CAPACITY`] with distinct,
184 /// non-coalescing entries (bypassing `push_update`'s by-kind coalescing).
185 /// Test-only: lets a driver test exercise a FULL non-terminal ring while the
186 /// reserved terminal slot stays independently deliverable.
187 #[cfg(test)]
188 pub(crate) fn fill_non_terminal_to_cap_for_test(&mut self) {
189 use mdns_proto::event::ServiceRenamed;
190 self.updates.clear();
191 for i in 0..SERVICE_UPDATE_CAPACITY {
192 self
193 .updates
194 .push_back(ServiceUpdate::Renamed(ServiceRenamed::new(
195 mdns_proto::Name::try_from_str(&format!("fill-{i}._ipp._tcp.local.")).unwrap(),
196 )));
197 }
198 }
199
200 /// Pull the next update for the consumer: non-terminal updates first (FIFO),
201 /// then the reserved terminal, then end-of-stream.
202 fn drain(&mut self) -> Drained {
203 if let Some(upd) = self.updates.pop_front() {
204 Drained::Update(upd)
205 } else if let Some(terminal) = self.terminal.take() {
206 self.terminal_delivered = true;
207 Drained::Update(terminal)
208 } else if self.terminal_delivered {
209 Drained::Ended
210 } else {
211 Drained::Empty
212 }
213 }
214}
215
216/// A fresh shared mailbox. Both the driver ctx and the [`Service`] handle hold a
217/// clone of the returned [`Rc`]; the wakeup is the shared
218/// [`crate::driver::LocalNotify`] on [`EndpointInner`] (mirroring the compio
219/// query path, which parks on the same notifier — no separate doorbell channel).
220pub(crate) fn new_service_mailbox() -> Rc<RefCell<ServiceMailbox>> {
221 Rc::new(RefCell::new(ServiceMailbox::new()))
222}
223
224/// Handle to a registered service.
225///
226/// Dropping the handle implicitly unregisters the service: it is flagged
227/// cancelled and the driver's post-pump sweep begins the endpoint-owned RFC 6762
228/// §10.1 withdrawal — the endpoint holds the route (reserving the name) while it
229/// multicasts the TTL=0 goodbye a few times, then frees the route.
230pub struct Service {
231 pub(crate) inner: Rc<EndpointInner>,
232 pub(crate) handle: ServiceHandle,
233 /// Handle-owned delivery buffer the driver fills with [`ServiceUpdate`]s. Owned
234 /// by the handle (not the driver ctx), so the reserved terminal survives an
235 /// immediate ctx GC and is still drained here.
236 pub(crate) mailbox: Rc<RefCell<ServiceMailbox>>,
237}
238
239impl Service {
240 /// The underlying proto-layer [`ServiceHandle`] for this registration.
241 #[inline]
242 pub const fn handle(&self) -> ServiceHandle {
243 self.handle
244 }
245
246 /// Wait for the next [`ServiceUpdate`] event, or `None` once the service has
247 /// been retired (terminal delivered) or the driver task exited.
248 ///
249 /// Drains the handle-owned mailbox: non-terminal updates first (FIFO), then the
250 /// reserved terminal, then end-of-stream. Mirrors [`crate::Query::next`]'s
251 /// wakeup discipline — a brief synchronous borrow, then a park on the shared
252 /// driver notifier when nothing is ready.
253 pub async fn next(&self) -> Option<ServiceUpdate> {
254 loop {
255 // Brief synchronous borrow — drain one update, else fall through to park.
256 // The borrow is dropped at the end of this block (well before any `.await`).
257 // The mailbox is handle-owned, so it stays drainable even if the driver has
258 // already GC'd our `ServiceCtx` after delivering the terminal — that is how
259 // a retirement `Conflict` survives a same-iteration ctx GC.
260 match self.mailbox.borrow_mut().drain() {
261 Drained::Update(upd) => return Some(upd),
262 Drained::Ended => return None,
263 Drained::Empty => {}
264 }
265 // No update ready: park on the driver's notify. The borrow above is already
266 // dropped, so this await never holds a RefCell borrow.
267 self.inner.notify.listen().await;
268 }
269 }
270}
271
272impl Drop for Service {
273 fn drop(&mut self) {
274 // RFC 6762 §10.1 graceful withdrawal is DRIVER-OWNED: flag the service
275 // cancelled and let the driver begin the endpoint-owned withdrawal on its next
276 // loop iteration (`State::sweep_cancelled_services` →
277 // `begin_service_withdrawal`), AFTER any send that was in flight when this
278 // handle dropped has latched its records via `note_service_transmit_result`.
279 //
280 // Snapshotting the withdrawal synchronously here (the previous approach) raced
281 // the driver's completion-based send pump: in the thread-per-core model another
282 // task can drop this handle while the driver is parked mid-`send_to().await`
283 // for THIS service's own announce. Snapshotting at that instant captures state
284 // BEFORE the announce latched as advertised, then retires the service — so when
285 // the send completes `note_service_transmit_result` is a no-op and a
286 // positive-TTL record reaches peers with no withdrawal. Deferring to the
287 // post-pump sweep closes that window.
288 {
289 let mut st = self.inner.state.borrow_mut();
290 st.flag_service_unregistered(self.handle);
291 }
292 // Durable wake (see `EndpointInner::dirty`): the withdrawal sweep + §10.1
293 // goodbye run on the next driver settle, which `dirty` guarantees happens even
294 // if this notify is lost across the driver's send-awaits.
295 self.inner.mark_dirty();
296 }
297}
298
299#[cfg(test)]
300mod tests;