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;