Skip to main content

hick_reactor/
query.rs

1//! Caller-side handle for a running query.
2
3use std::{
4  collections::VecDeque,
5  sync::{Arc, Mutex, MutexGuard},
6};
7
8use mdns_proto::{CollectedAnswer, QueryHandle, QueryUpdate};
9
10use crate::{command::Command, error::CancelError};
11
12/// Upper bound on undelivered answers buffered per query.
13///
14/// The proto layer's `max_answers` bounds the *collected* answer set; this is
15/// an independent backstop on the *undelivered event* backlog so a peer that
16/// streams matching answers faster than the application drains
17/// [`Query::next`] cannot grow the queue without bound. In normal operation
18/// the consumer keeps up and the backlog stays near zero; the cap only
19/// engages under a pathological slow-consumer + flood, where
20/// [`QueryMailbox::push_answer`] coalesces duplicates and drops the oldest
21/// pending answer. The terminal event has its own reserved slot and is never
22/// dropped.
23const MAX_QUERY_EVENT_BACKLOG: usize = 1024;
24
25/// Per-answer event surfaced on the [`Query`] stream.
26#[derive(Debug, Clone)]
27pub enum QueryEvent {
28  /// A new answer collected by the underlying state machine.
29  ///
30  /// The answer stream is **lossy under sustained backpressure**: if the
31  /// application drains [`Query::next`] slower than answers arrive, the
32  /// oldest buffered answers are dropped once the per-query backlog fills
33  /// (see [`Query::dropped_answers`] to observe how many). Discovery is
34  /// eventually-consistent — mDNS responders re-advertise — so a dropped
35  /// answer is normally re-collected, but a caller must not assume the
36  /// stream is gap-free.
37  Answer(CollectedAnswer),
38  /// The query reached a terminal state. Always the last event delivered,
39  /// and never dropped under backpressure.
40  Terminal(QueryUpdate),
41}
42
43/// Bounded, coalescing delivery buffer shared between the driver task (which
44/// fills it) and the [`Query`] handle (which drains it via [`Query::next`]).
45///
46/// answers are bounded ([`MAX_QUERY_EVENT_BACKLOG`]) and coalesced
47/// by `(rtype, rclass, rdata)` so a flooding peer cannot grow the queue, while
48/// the terminal event keeps a dedicated slot so it is delivered even under
49/// answer backpressure.
50pub(crate) struct QueryMailbox {
51  answers: VecDeque<CollectedAnswer>,
52  terminal: Option<QueryUpdate>,
53  /// Set once the terminal has been handed to the consumer, so subsequent
54  /// drains report end-of-stream rather than waiting forever.
55  terminal_delivered: bool,
56  /// running count of answers dropped by the backlog cap, so the
57  /// otherwise-silent loss is observable via [`Query::dropped_answers`].
58  dropped: u64,
59}
60
61/// Result of draining one event from a [`QueryMailbox`].
62enum Drained {
63  /// An event is ready for the consumer.
64  Event(QueryEvent),
65  /// No more events will ever arrive (terminal already delivered).
66  Ended,
67  /// Nothing ready right now; the consumer should wait for a wakeup.
68  Empty,
69}
70
71impl QueryMailbox {
72  fn new() -> Self {
73    Self {
74      answers: VecDeque::new(),
75      terminal: None,
76      terminal_delivered: false,
77      dropped: 0,
78    }
79  }
80
81  /// Buffer a newly collected answer. Coalesces a re-collected record (same
82  /// `rtype`/`rclass` and case-folded rdata identity) onto the pending copy
83  /// instead of accumulating, and drops the oldest pending answer (incrementing
84  /// the dropped counter) once the backlog cap is reached.
85  ///
86  /// coalescing compares the case-FOLDED `rdata_key` (DNS names are
87  /// case-insensitive), so case-only variants of one logical record collapse to
88  /// a single delivered answer rather than reaching the caller as duplicates.
89  /// The newer copy replaces the slot, keeping its (display-case) rdata.
90  pub(crate) fn push_answer(&mut self, ans: CollectedAnswer) {
91    if let Some(slot) = self.answers.iter_mut().find(|a| {
92      a.rtype() == ans.rtype() && a.rclass() == ans.rclass() && a.rdata_key() == ans.rdata_key()
93    }) {
94      *slot = ans;
95      return;
96    }
97    if self.answers.len() >= MAX_QUERY_EVENT_BACKLOG && self.answers.pop_front().is_some() {
98      self.dropped = self.dropped.saturating_add(1);
99    }
100    self.answers.push_back(ans);
101  }
102
103  /// Account for answers lost UPSTREAM of the mailbox — i.e. evicted by the
104  /// proto `max_answers` cap before the driver could scan them.
105  /// Folded into the same public [`Query::dropped_answers`] counter as the
106  /// mailbox's own drop-oldest, so every loss path is observable through one
107  /// number.
108  pub(crate) fn record_dropped(&mut self, n: u64) {
109    self.dropped = self.dropped.saturating_add(n);
110  }
111
112  /// Record the terminal event in its reserved slot (idempotent).
113  pub(crate) fn set_terminal(&mut self, terminal: QueryUpdate) {
114    if self.terminal.is_none() && !self.terminal_delivered {
115      self.terminal = Some(terminal);
116    }
117  }
118
119  /// Pull the next event for the consumer: answers first (FIFO), then the
120  /// reserved terminal, then end-of-stream.
121  fn drain(&mut self) -> Drained {
122    if let Some(ans) = self.answers.pop_front() {
123      Drained::Event(QueryEvent::Answer(ans))
124    } else if let Some(terminal) = self.terminal.take() {
125      self.terminal_delivered = true;
126      Drained::Event(QueryEvent::Terminal(terminal))
127    } else if self.terminal_delivered {
128      Drained::Ended
129    } else {
130      Drained::Empty
131    }
132  }
133}
134
135/// Lock the mailbox, recovering the inner guard if a previous holder panicked
136/// (we never hold the lock across a fallible operation, so the data is sound).
137fn lock(mailbox: &Mutex<QueryMailbox>) -> MutexGuard<'_, QueryMailbox> {
138  mailbox
139    .lock()
140    .unwrap_or_else(|poisoned| poisoned.into_inner())
141}
142
143/// Handle to an in-flight query.
144///
145/// Dropping the handle implicitly cancels the query.
146pub struct Query {
147  handle: QueryHandle,
148  mailbox: Arc<Mutex<QueryMailbox>>,
149  /// Capacity-1 wakeup signal: the driver rings it after filling the mailbox.
150  /// Closure of the sender (driver dropped our `QueryCtx`) means no further
151  /// events will arrive.
152  doorbell: async_channel::Receiver<()>,
153  cmd: async_channel::Sender<Command>,
154}
155
156impl Query {
157  pub(crate) fn new(
158    handle: QueryHandle,
159    mailbox: Arc<Mutex<QueryMailbox>>,
160    doorbell: async_channel::Receiver<()>,
161    cmd: async_channel::Sender<Command>,
162  ) -> Self {
163    Self {
164      handle,
165      mailbox,
166      doorbell,
167      cmd,
168    }
169  }
170
171  /// The underlying proto-layer query handle.
172  #[inline]
173  pub const fn handle(&self) -> QueryHandle {
174    self.handle
175  }
176
177  /// Number of answers dropped so far because the per-query backlog filled.
178  /// Monotonic for the life of the query. A non-zero, growing
179  /// value means the consumer is not keeping up with a burst of answers and
180  /// some [`QueryEvent::Answer`] values were discarded; see that variant for
181  /// the lossy-stream contract.
182  pub fn dropped_answers(&self) -> u64 {
183    lock(&self.mailbox).dropped
184  }
185
186  /// A cheap, cloneable view of this query's dropped-answer counter that stays
187  /// valid after the `Query` is moved (e.g. into a stream adapter). Shares the
188  /// mailbox, so it reports the same count as [`Self::dropped_answers`]. Used by
189  /// the discovery layer to fold the browse query's drops into
190  /// [`crate::Lookup::dropped`].
191  pub(crate) fn dropped_handle(&self) -> DroppedHandle {
192    DroppedHandle {
193      mailbox: Arc::clone(&self.mailbox),
194    }
195  }
196
197  /// Wait for the next answer or terminal. Returns `None` once the query has
198  /// ended (terminal delivered) or the driver task has exited.
199  ///
200  /// Single-consumer: this takes `&mut self`, so there is at most one
201  /// in-flight `next()` future per `Query`. That is deliberate — the wakeup
202  /// path assumes one waiter, and it makes the lossy-stream contract on
203  /// [`QueryEvent::Answer`] the only source of gaps.
204  pub async fn next(&mut self) -> Option<QueryEvent> {
205    loop {
206      match lock(&self.mailbox).drain() {
207        Drained::Event(ev) => return Some(ev),
208        Drained::Ended => return None,
209        Drained::Empty => {}
210      }
211      // Nothing buffered: wait for the driver to ring the doorbell. A closed
212      // doorbell means the driver dropped our context — do one final drain
213      // (a terminal it set just before exiting is still in the mailbox).
214      if self.doorbell.recv().await.is_err() {
215        return match lock(&self.mailbox).drain() {
216          Drained::Event(ev) => Some(ev),
217          _ => None,
218        };
219      }
220    }
221  }
222
223  /// Explicitly cancel the query. Equivalent to dropping the handle but
224  /// returns an error if the driver task has already exited.
225  pub async fn cancel(self) -> Result<(), CancelError> {
226    // Use the channel send through Drop. We can't reasonably do an .await
227    // send because Drop is sync; the Drop impl below uses try_send. To
228    // surface a deterministic error here, we send before dropping.
229    self
230      .cmd
231      .send(Command::CancelQuery {
232        handle: self.handle,
233      })
234      .await
235      .map_err(|_| CancelError::DriverGone)?;
236    // The Drop impl will also try_send a CancelQuery; the driver tolerates
237    // a second cancel (no-op since the handle is already gone).
238    Ok(())
239  }
240}
241
242impl Drop for Query {
243  fn drop(&mut self) {
244    let _ = self.cmd.try_send(Command::CancelQuery {
245      handle: self.handle,
246    });
247  }
248}
249
250/// A cloneable view of a query's dropped-answer counter, decoupled from the
251/// [`Query`] handle's lifetime (see [`Query::dropped_handle`]).
252pub(crate) struct DroppedHandle {
253  mailbox: Arc<Mutex<QueryMailbox>>,
254}
255
256impl DroppedHandle {
257  /// The query's current dropped-answer count.
258  pub(crate) fn get(&self) -> u64 {
259    lock(&self.mailbox).dropped
260  }
261}
262
263/// Construct a fresh mailbox plus its capacity-1 doorbell channel. Returns the
264/// shared mailbox, the driver-side doorbell sender, and the consumer-side
265/// doorbell receiver.
266pub(crate) fn new_mailbox() -> (
267  Arc<Mutex<QueryMailbox>>,
268  async_channel::Sender<()>,
269  async_channel::Receiver<()>,
270) {
271  let mailbox = Arc::new(Mutex::new(QueryMailbox::new()));
272  let (doorbell_tx, doorbell_rx) = async_channel::bounded(1);
273  (mailbox, doorbell_tx, doorbell_rx)
274}
275
276#[cfg(test)]
277mod tests;