hick_reactor/service.rs
1//! Caller-side handle for a registered service.
2
3use std::{
4 collections::VecDeque,
5 sync::{Arc, Mutex, MutexGuard},
6};
7
8use mdns_proto::{ServiceHandle, ServiceUpdate};
9
10use crate::{command::Command, error::CancelError};
11
12/// Upper bound on undelivered NON-terminal service updates buffered per
13/// service.
14///
15/// `Established` is one-time and `Renamed(..)` coalesces to the latest name, so
16/// in normal operation the backlog stays at one or two entries. The cap is a
17/// backstop: an on-link peer can force endless conflict-renames, and a
18/// non-draining caller would otherwise let the buffer grow without bound. Beyond
19/// the cap [`ServiceMailbox::push_update`] drops the oldest pending non-terminal
20/// update. The terminal retirement update (`Conflict`/`HostConflict`) has its
21/// own reserved slot and is never dropped.
22pub(crate) const SERVICE_UPDATE_CAPACITY: usize = 16;
23
24/// Bounded, coalescing delivery buffer shared between the driver task (which
25/// fills it) and the [`Service`] handle (which drains it via [`Service::next`]).
26///
27/// This mirrors [`crate::query::QueryMailbox`]: non-terminal updates are bounded
28/// ([`SERVICE_UPDATE_CAPACITY`]) and coalesced by kind so a flooding peer cannot
29/// grow the queue, while the terminal retirement update keeps a dedicated slot
30/// so it is delivered even under non-terminal backpressure and survives an
31/// immediate ctx GC (the mailbox is owned by the handle, not the driver ctx).
32pub(crate) struct ServiceMailbox {
33 /// NON-terminal updates (`Established` / `Renamed(..)`), coalesced by kind and
34 /// bounded by [`SERVICE_UPDATE_CAPACITY`]. Drained FIFO before the terminal.
35 updates: VecDeque<ServiceUpdate>,
36 /// RESERVED slot for the terminal retirement update (`Conflict` /
37 /// `HostConflict`). Independent of the non-terminal cap and idempotent (first
38 /// terminal wins), so it is always deliverable.
39 terminal: Option<ServiceUpdate>,
40 /// Set once the terminal has been handed to the consumer, so subsequent
41 /// drains report end-of-stream rather than waiting forever.
42 terminal_delivered: bool,
43}
44
45/// Result of draining one update from a [`ServiceMailbox`].
46#[cfg_attr(test, derive(Debug))]
47enum Drained {
48 /// An update is ready for the consumer.
49 Update(ServiceUpdate),
50 /// No more updates will ever arrive (terminal already delivered).
51 Ended,
52 /// Nothing ready right now; the consumer should wait for a wakeup.
53 Empty,
54}
55
56impl ServiceMailbox {
57 fn new() -> Self {
58 Self {
59 updates: VecDeque::new(),
60 terminal: None,
61 terminal_delivered: false,
62 }
63 }
64
65 /// Buffer a NON-terminal update (`Established` / `Renamed(..)`), bounding
66 /// memory while keeping insertion order:
67 ///
68 /// * `Renamed` — drop any prior pending `Renamed` and append the new one, so
69 /// only the LATEST name is kept, at its true (most recent) position (the
70 /// caller only needs the current name).
71 /// * `Established` — drop any prior pending `Established` and append, keeping
72 /// only the LATEST at its most-recent position. A post-rename `Established`
73 /// therefore lands AFTER the pending `Renamed`, so the caller learns the new
74 /// name became advertised after a conflict-rename
75 /// (`Established -> Renamed -> Established`), not merely that a rename
76 /// happened; a duplicate or pre-rename `Established` coalesces away.
77 ///
78 /// The ring therefore holds at most one `Renamed` plus one trailing
79 /// `Established` under conflict-rename churn; the [`SERVICE_UPDATE_CAPACITY`]
80 /// cap is a hard backstop that drops the oldest pending update if a future
81 /// non-terminal kind ever fills it.
82 ///
83 /// A terminal update passed here is ignored (terminals belong in
84 /// [`Self::set_terminal`]); the driver routes by kind, so this is defensive.
85 pub(crate) fn push_update(&mut self, upd: ServiceUpdate) {
86 if upd.is_conflict() || upd.is_host_conflict() {
87 // Terminals never go into the non-terminal ring; route to the reserved
88 // slot instead so the caller can never lose a retirement reason.
89 self.set_terminal(upd);
90 return;
91 }
92 if upd.is_renamed() {
93 self.updates.retain(|u| !u.is_renamed());
94 self.bounded_push_back(upd);
95 return;
96 }
97 if upd.is_established() {
98 // Keep only the LATEST `Established`, at its most-recent position: drop any
99 // prior `Established`, then append. A post-rename `Established` (the
100 // `Established -> Renamed -> Established` lifecycle: established, conflict,
101 // auto-rename, re-establish under the new name) therefore lands AFTER the
102 // pending `Renamed`, so a slow reader observes "renamed, then established
103 // under the new name" — not just that a rename happened.
104 // Globally deduping by kind instead kept the EARLIER `Established` at the
105 // front and dropped this post-rename one. Bounds the ring to one `Renamed`
106 // plus one trailing `Established` under conflict-rename churn.
107 self.updates.retain(|u| !u.is_established());
108 self.bounded_push_back(upd);
109 return;
110 }
111 // Any future non-terminal kind: append (bounded by the cap).
112 self.bounded_push_back(upd);
113 }
114
115 /// Append `upd`, evicting the oldest pending non-terminal update first if the
116 /// ring is already at capacity (drop-oldest backstop).
117 fn bounded_push_back(&mut self, upd: ServiceUpdate) {
118 if self.updates.len() >= SERVICE_UPDATE_CAPACITY {
119 self.updates.pop_front();
120 }
121 self.updates.push_back(upd);
122 }
123
124 /// Record the terminal retirement update in its reserved slot (idempotent —
125 /// the first terminal wins, and a terminal is never recorded after one has
126 /// already been delivered).
127 pub(crate) fn set_terminal(&mut self, terminal: ServiceUpdate) {
128 if self.terminal.is_none() && !self.terminal_delivered {
129 self.terminal = Some(terminal);
130 }
131 }
132
133 /// Number of buffered NON-terminal updates (excludes the reserved terminal
134 /// slot). Test-only window into the bound + coalesce behaviour.
135 #[cfg(test)]
136 pub(crate) fn non_terminal_len(&self) -> usize {
137 self.updates.len()
138 }
139
140 /// Drain one update (non-terminal first, then the reserved terminal) and
141 /// report it to the caller, or `None` at end-of-stream / when empty. Test-only
142 /// synchronous peek used by the driver-level tests to assert what was
143 /// delivered without awaiting the async [`Service::next`].
144 #[cfg(test)]
145 pub(crate) fn drain_for_test(&mut self) -> Option<ServiceUpdate> {
146 match self.drain() {
147 Drained::Update(upd) => Some(upd),
148 Drained::Ended | Drained::Empty => None,
149 }
150 }
151
152 /// Saturate the NON-terminal ring to [`SERVICE_UPDATE_CAPACITY`] with distinct,
153 /// non-coalescing entries (bypassing `push_update`'s by-kind coalescing).
154 /// Test-only: lets a driver test exercise a FULL non-terminal ring while the
155 /// reserved terminal slot stays independently deliverable.
156 #[cfg(test)]
157 pub(crate) fn fill_non_terminal_to_cap_for_test(&mut self) {
158 use mdns_proto::event::ServiceRenamed;
159 self.updates.clear();
160 for i in 0..SERVICE_UPDATE_CAPACITY {
161 self
162 .updates
163 .push_back(ServiceUpdate::Renamed(ServiceRenamed::new(
164 mdns_proto::Name::try_from_str(&format!("fill-{i}._ipp._tcp.local.")).unwrap(),
165 )));
166 }
167 }
168
169 /// Pull the next update for the consumer: non-terminal updates first (FIFO),
170 /// then the reserved terminal, then end-of-stream.
171 fn drain(&mut self) -> Drained {
172 if let Some(upd) = self.updates.pop_front() {
173 Drained::Update(upd)
174 } else if let Some(terminal) = self.terminal.take() {
175 self.terminal_delivered = true;
176 Drained::Update(terminal)
177 } else if self.terminal_delivered {
178 Drained::Ended
179 } else {
180 Drained::Empty
181 }
182 }
183}
184
185/// Lock the mailbox, recovering the inner guard if a previous holder panicked
186/// (we never hold the lock across a fallible operation, so the data is sound).
187fn lock(mailbox: &Mutex<ServiceMailbox>) -> MutexGuard<'_, ServiceMailbox> {
188 mailbox
189 .lock()
190 .unwrap_or_else(|poisoned| poisoned.into_inner())
191}
192
193/// Handle to a registered service.
194///
195/// Dropping the handle implicitly unregisters the service.
196pub struct Service {
197 handle: ServiceHandle,
198 mailbox: Arc<Mutex<ServiceMailbox>>,
199 /// Capacity-1 wakeup signal: the driver rings it after filling the mailbox.
200 /// Closure of the sender (driver dropped our `ServiceCtx`) means no further
201 /// updates will arrive.
202 doorbell: async_channel::Receiver<()>,
203 cmd: async_channel::Sender<Command>,
204}
205
206impl Service {
207 pub(crate) fn new(
208 handle: ServiceHandle,
209 mailbox: Arc<Mutex<ServiceMailbox>>,
210 doorbell: async_channel::Receiver<()>,
211 cmd: async_channel::Sender<Command>,
212 ) -> Self {
213 Self {
214 handle,
215 mailbox,
216 doorbell,
217 cmd,
218 }
219 }
220
221 /// The underlying proto-layer service handle.
222 #[inline]
223 pub const fn handle(&self) -> ServiceHandle {
224 self.handle
225 }
226
227 /// Wait for the next [`ServiceUpdate`]. Returns `None` once the service has
228 /// been retired (terminal delivered) or the driver task has exited.
229 ///
230 /// Single-consumer: this mirrors [`crate::Query::next`]'s wakeup discipline,
231 /// where the capacity-1 doorbell assumes a single waiter.
232 pub async fn next(&self) -> Option<ServiceUpdate> {
233 loop {
234 match lock(&self.mailbox).drain() {
235 Drained::Update(upd) => return Some(upd),
236 Drained::Ended => return None,
237 Drained::Empty => {}
238 }
239 // Nothing buffered: wait for the driver to ring the doorbell. A closed
240 // doorbell means the driver dropped our context — do one final drain (a
241 // terminal it set just before exiting is still in the mailbox).
242 if self.doorbell.recv().await.is_err() {
243 return match lock(&self.mailbox).drain() {
244 Drained::Update(upd) => Some(upd),
245 _ => None,
246 };
247 }
248 }
249 }
250
251 // an in-place `rename` API was removed because the proto-layer
252 // `Service` exposes no atomic "rename instance" operation. The driver
253 // would have to drop the proto Service and reconstruct one with the new
254 // ServiceSpec, which changes the underlying `ServiceHandle` and forces a
255 // full probing round anyway — better to express that as
256 // `unregister` + `Endpoint::register_service(new_spec).await` at the
257 // caller site so the handle invalidation is explicit.
258 //
259 // The auto-rename path (`ServiceUpdate::Renamed`) is still observed via
260 // `next().await`; the driver keeps the endpoint's route table in sync
261 // before forwarding the event so post-rename queries route correctly.
262
263 /// Explicitly unregister the service. Equivalent to dropping the handle
264 /// but returns an error if the driver task has already exited.
265 pub async fn unregister(self) -> Result<(), CancelError> {
266 self
267 .cmd
268 .send(Command::UnregisterService {
269 handle: self.handle,
270 })
271 .await
272 .map_err(|_| CancelError::DriverGone)?;
273 // The Drop impl below will also try_send an Unregister; driver
274 // tolerates the second one (no-op since the handle is already gone).
275 Ok(())
276 }
277}
278
279impl Drop for Service {
280 fn drop(&mut self) {
281 let _ = self.cmd.try_send(Command::UnregisterService {
282 handle: self.handle,
283 });
284 }
285}
286
287/// Construct a fresh mailbox plus its capacity-1 doorbell channel. Returns the
288/// shared mailbox, the driver-side doorbell sender, and the consumer-side
289/// doorbell receiver. Mirrors [`crate::query::new_mailbox`].
290pub(crate) fn new_service_mailbox() -> (
291 Arc<Mutex<ServiceMailbox>>,
292 async_channel::Sender<()>,
293 async_channel::Receiver<()>,
294) {
295 let mailbox = Arc::new(Mutex::new(ServiceMailbox::new()));
296 let (doorbell_tx, doorbell_rx) = async_channel::bounded(1);
297 (mailbox, doorbell_tx, doorbell_rx)
298}
299
300#[cfg(test)]
301mod tests;