Skip to main content

hick_smoltcp/engine/
mod.rs

1//! The runtime-agnostic mDNS engine: a synchronous *pump* that drives the
2//! [`mdns_proto::Endpoint`] (plus the per-service / per-query state machines it
3//! hands back) over a [`UdpIo`] transport.
4//!
5//! A driver (e.g. `hick-embassy`, or a bare poll loop) calls [`Engine::pump`]
6//! whenever a packet arrives or a timer fires, sends nothing itself, and reads
7//! back the next deadline to sleep until.
8
9#[cfg(feature = "stats")]
10use alloc::sync::Arc;
11use alloc::{
12  collections::{BTreeMap, VecDeque},
13  vec::Vec,
14};
15use core::{net::SocketAddr, time::Duration};
16
17use mdns_proto::{
18  CollectedAnswer, EndpointConfig, Instant, QueryHandle, QuerySpec, ServiceHandle, ServiceSpec,
19  cache::CacheEntry,
20  endpoint::{Endpoint, EndpointEventEntry, ServiceRoute, WithdrawalSend},
21  error::{RegisterServiceError, StartQueryError},
22  event::{EndpointEvent, QueryUpdate, RouteEvent, ServiceUpdate},
23  query::Query,
24  service::Service,
25  slab::Slab,
26  transmit::Transmit,
27};
28use rand_core::Rng;
29use smoltcp::wire::IpCidr;
30
31use crate::{
32  constants::{MDNS_SOCKET_V4, MDNS_SOCKET_V6},
33  onlink,
34  udpio::{SendError, UdpIo},
35};
36
37#[cfg(feature = "stats")]
38use hick_trace::stats::{Stats, StatsSnapshot};
39
40#[cfg(test)]
41mod tests;
42
43/// RFC 6762 §17 single-message ceiling — the cap applied to every encoded
44/// multicast in the normal TX path, so a service announced with a large record
45/// set can never advertise records that the endpoint-owned TTL=0 withdrawal
46/// (which encodes into the caller's `scratch`, capped to this same ceiling)
47/// could not later retract.
48const MAX_MDNS_MESSAGE: usize = 9000;
49/// Per-service cap on queued app-facing updates, so a peer flooding conflict
50/// events cannot drive unbounded allocation on the receive path.
51const MAX_SERVICE_UPDATES: usize = 16;
52/// Max inbound datagrams processed in ONE pump before yielding. `MAX_SERVICE_UPDATES`
53/// caps the app-facing `ServiceSlot::updates` queue, but a service's mdns-proto
54/// `pending_updates` pool accumulates DURING the RX drain — before `drain_service_updates`
55/// coalesces and caps it — so an on-link conflict flood could otherwise grow it
56/// proportional to the whole RX backlog (bounded only by the socket RX buffer, which
57/// the caller may size large) in a single pump. Capping the batch bounds that peak to
58/// a constant; when the cap is hit the pump asks for an immediate re-pump so a real
59/// backlog is still drained promptly.
60const MAX_RX_PER_PUMP: usize = 64;
61/// Byte budget for buffered self-sends (loopback detection). Bounds memory while
62/// preserving the FRESHEST sends, so a burst of many outstanding multicasts in
63/// one pump is still covered until their loopbacks arrive — a fixed small count
64/// would evict fresh entries mid-burst. Exact bytes are stored.
65const RECENT_SEND_BYTES: usize = 16 * 1024;
66/// How long a recorded self-send stays eligible to match a loopback — bounds the
67/// window in which a byte-identical peer datagram could be misread as self.
68const RECENT_SEND_TTL: Duration = Duration::from_secs(5);
69
70/// A recent multicast datagram we put on the wire, kept (exact bytes + send time)
71/// for self-loopback detection.
72struct SelfSend<I> {
73  data: Vec<u8>,
74  at: I,
75}
76
77// Slab-backed pools (the `alloc` tier). Mirrors `hick-reactor`'s `ProtoEndpoint`.
78type AnswerPool = Slab<CollectedAnswer>;
79type UpdatePool = Slab<QueryUpdate>;
80type ProtoQuery<I> = Query<I, AnswerPool, UpdatePool>;
81type ProtoService<I> = Service<I, Slab<Transmit>, Slab<ServiceUpdate>>;
82type ProtoEndpoint<I, R> = Endpoint<
83  I,
84  R,
85  Slab<CacheEntry<I>>,
86  Slab<ServiceRoute>,
87  Slab<ProtoQuery<I>>,
88  Slab<EndpointEventEntry>,
89  AnswerPool,
90  UpdatePool,
91>;
92
93/// Per-service driver-side state: the proto state machine, a queue of
94/// app-facing updates, and an `errored` flag that drops a structurally-dead
95/// service out of every pump (so it can't busy-spin).
96struct ServiceSlot<I: Instant> {
97  proto: ProtoService<I>,
98  updates: VecDeque<ServiceUpdate>,
99  errored: bool,
100  /// Set when the endpoint-owned withdrawal for this service has COMPLETED (its
101  /// route is already freed) but the slot is RETAINED because it still holds
102  /// un-polled app-facing updates — typically the `Conflict` queued at an internal
103  /// retirement. Such a slot is GC'd lazily: by [`Engine::pump`] (or
104  /// [`Engine::poll_service_update`]) once its `updates` queue drains. This keeps
105  /// the `Conflict` deliverable even when the withdrawal completes in the SAME pump
106  /// that began it (an empty, never-announced withdrawal completes immediately).
107  route_freed: bool,
108  /// Set when the CALLER explicitly retired this service via
109  /// [`Engine::unregister_service`] and may discard the handle WITHOUT polling its
110  /// updates. Unlike an internal retirement, no reader is guaranteed, so the
111  /// completed-withdrawal GC removes the slot regardless of pending updates —
112  /// `route_freed` deferral would otherwise pin it forever and grow `services`
113  /// without bound under register/unregister churn.
114  caller_gone: bool,
115}
116
117impl<I: Instant> ServiceSlot<I> {
118  /// Queue an app-facing update with allocation discipline. Conflict
119  /// notifications are peer-floodable and idempotent, so keep at most one of each
120  /// variant; the backstop cap then evicts conflict noise BEFORE any actionable
121  /// transition (`Established` / `Renamed`), which the application must not miss.
122  /// Prevents a hostile on-link peer from forcing unbounded growth or evicting
123  /// real lifecycle state on the RX path.
124  fn push_update(&mut self, update: ServiceUpdate) {
125    if matches!(
126      update,
127      ServiceUpdate::Conflict | ServiceUpdate::HostConflict
128    ) {
129      let kind = core::mem::discriminant(&update);
130      if self
131        .updates
132        .iter()
133        .any(|u| core::mem::discriminant(u) == kind)
134      {
135        return;
136      }
137    }
138    if self.updates.len() >= MAX_SERVICE_UPDATES {
139      let victim = self
140        .updates
141        .iter()
142        .position(|u| matches!(u, ServiceUpdate::Conflict | ServiceUpdate::HostConflict));
143      match victim {
144        Some(pos) => {
145          self.updates.remove(pos);
146        }
147        None => {
148          self.updates.pop_front();
149        }
150      }
151    }
152    self.updates.push_back(update);
153  }
154}
155
156/// Per-query driver-side state. Answers are applied inside `Endpoint::handle`;
157/// this only tracks the `errored` flag — a query the driver retired (its question
158/// is un-encodable, or permanently unsendable on every family) which every pump
159/// skips. A retired query is ALSO forced to its proto-level TIMEOUT terminal (see
160/// [`Engine::retire_query`]), so its terminal update and frozen answers come from
161/// the proto, not a synthetic driver-side signal.
162struct QuerySlot {
163  errored: bool,
164}
165
166/// The outcome of a single per-family send attempt in a multicast fan-out or
167/// goodbye burst, carrying exactly what happened to that one family's socket call.
168///
169/// `Sent(n)` — the datagram was queued: `n` bytes went on the wire.
170/// `Failed`   — a real I/O error (e.g. TooLarge in the normal TX path).
171/// `Unsupported` — no socket for this family; not an error.
172/// `Busy`     — the socket is transiently full; will be retried.
173///
174/// Separating these four cases lets accounting sites be exact: `packets_tx` /
175/// `bytes_tx` increment only for `Sent`, `send_errors` only for `Failed`.
176#[derive(Debug, Clone, Copy)]
177enum FamilySend {
178  /// Datagram placed on the wire; payload byte count is carried for `bytes_tx`.
179  Sent(usize),
180  /// Real I/O failure — the socket exists but permanently rejected the datagram.
181  Failed,
182  /// No socket for this family; not an error, not a retry candidate.
183  Unsupported,
184  /// Socket transiently full; will be retried.
185  Busy,
186}
187
188impl FamilySend {
189  /// Whether the datagram actually reached this family's socket.
190  fn is_sent(self) -> bool {
191    matches!(self, FamilySend::Sent(_))
192  }
193
194  /// Map this family's send outcome to the per-family withdrawal debt result the
195  /// endpoint consumes: a queued send spends one of the family's owed rounds
196  /// (`Sent`); a transiently-full socket keeps its debt to retry (`Busy` →
197  /// `Retry`); an absent socket or a real I/O failure writes the debt off
198  /// (`Unsupported`/`Failed` → `WriteOff`), since that family has no reachable
199  /// peers to withdraw from.
200  fn withdrawal_send(self) -> WithdrawalSend {
201    match self {
202      FamilySend::Sent(_) => WithdrawalSend::Sent,
203      FamilySend::Busy => WithdrawalSend::Retry,
204      FamilySend::Unsupported | FamilySend::Failed => WithdrawalSend::WriteOff,
205    }
206  }
207}
208
209/// The per-family results of a multicast fan-out: one [`FamilySend`] for v4
210/// and one for v6. Carry this from `send_multicast`/`burst` to the accounting
211/// site so counters are bumped from explicit per-family outcomes rather than
212/// from a coarse aggregate.
213#[derive(Debug, Clone, Copy)]
214struct Fanout {
215  v4: FamilySend,
216  v6: FamilySend,
217}
218
219impl Fanout {
220  /// Returns `true` if at least one family sent the datagram successfully.
221  fn any_sent(self) -> bool {
222    self.v4.is_sent() || self.v6.is_sent()
223  }
224
225  /// Total number of per-family sends that actually placed bytes on the wire
226  /// (0, 1, or 2). Used for `packets_tx`.
227  #[cfg_attr(not(feature = "stats"), allow(dead_code))]
228  fn sent_count(self) -> u32 {
229    u32::from(self.v4.is_sent()) + u32::from(self.v6.is_sent())
230  }
231
232  /// Total bytes placed on the wire (sum across sending families). Used for
233  /// `bytes_tx`; the byte count is per-family because both families encode the
234  /// same datagram, so a dual-stack send doubles the on-wire bytes.
235  #[cfg_attr(not(feature = "stats"), allow(dead_code))]
236  fn bytes_on_wire(self) -> u64 {
237    let mut n = 0u64;
238    if let FamilySend::Sent(b) = self.v4 {
239      n += b as u64;
240    }
241    if let FamilySend::Sent(b) = self.v6 {
242      n += b as u64;
243    }
244    n
245  }
246
247  /// Count of families that returned a real I/O failure (`Failed`). Does NOT
248  /// count `Unsupported` (absent socket) or `Busy` (transient). Used for
249  /// `send_errors`.
250  #[cfg_attr(not(feature = "stats"), allow(dead_code))]
251  fn failed_count(self) -> u32 {
252    u32::from(matches!(self.v4, FamilySend::Failed))
253      + u32::from(matches!(self.v6, FamilySend::Failed))
254  }
255
256  /// `true` if at least one family is transiently `Busy` and should be retried.
257  fn any_busy(self) -> bool {
258    matches!(self.v4, FamilySend::Busy) || matches!(self.v6, FamilySend::Busy)
259  }
260
261  /// Derive the coarse [`MulticastOutcome`] the state machine needs for the
262  /// proto confirm-on-send contract.
263  fn into_multicast_outcome(self, any_too_large: bool) -> MulticastOutcome {
264    if self.any_sent() {
265      MulticastOutcome::Delivered
266    } else if self.any_busy() {
267      MulticastOutcome::Retry
268    } else if any_too_large {
269      MulticastOutcome::Undeliverable
270    } else {
271      // Every family absent (Unsupported); keep re-offering without retiring.
272      MulticastOutcome::Retry
273    }
274  }
275}
276
277/// Which state machine produced an outgoing datagram, so the matching
278/// `note_*_transmit_result` advances the right lifecycle after the send.
279#[derive(Debug, Clone, Copy)]
280enum Origin {
281  Service(ServiceHandle),
282  Query(QueryHandle),
283}
284
285/// Order the two families for a fan-out so the one that has been waiting LONGEST
286/// (the oldest failing streak) is tried FIRST. A non-blocking transport with room
287/// for only one datagram per poll cycle would otherwise always fill the family in
288/// fixed position 0 (v4) and perpetually starve the other: v4 wins the lone
289/// slot on every probe/announce while v6 reports busy, so the proto reaches
290/// `Established` with v6 having seen nothing. Handing the next free slot to the
291/// longest-blocked family makes both groups advance in turn. With ample capacity
292/// both sends succeed regardless of order, so this is a no-op in the common case.
293fn family_order<I: Instant>(failing_since: &[Option<I>; 2]) -> [(usize, SocketAddr); 2] {
294  let v4 = (0usize, MDNS_SOCKET_V4);
295  let v6 = (1usize, MDNS_SOCKET_V6);
296  let v6_first = match (failing_since[0], failing_since[1]) {
297    // Both behind: serve whichever started failing earlier (has waited longer).
298    (Some(v4_since), Some(v6_since)) => v6_since < v4_since,
299    // Only v6 is behind → give it the first slot.
300    (None, Some(_)) => true,
301    // v4 behind, or neither → keep the default v4-first order.
302    _ => false,
303  };
304  if v6_first { [v6, v4] } else { [v4, v6] }
305}
306
307/// The result of a synchronous multicast fan-out, deciding how the pump confirms.
308enum MulticastOutcome {
309  /// At least one family queued the datagram → confirm the proto transmit.
310  Delivered,
311  /// Nothing queued, but a family is transiently busy (or merely absent) → leave
312  /// it unconfirmed; the proto re-offers and the next pump retries.
313  Retry,
314  /// Nothing queued and a family reported the datagram permanently TooLarge, with
315  /// no transient family left to wait for → it can never be sent, so the producing
316  /// service/query is retired rather than re-offered forever.
317  Undeliverable,
318}
319
320/// Record a sent datagram (exact bytes + time) for self-loopback detection,
321/// pruning expired entries then evicting oldest to fit the byte budget —
322/// preserving the freshest sends so a large simultaneous burst stays covered
323/// until its loopbacks arrive.
324fn record_into<I: Instant>(
325  recent: &mut VecDeque<SelfSend<I>>,
326  recent_bytes: &mut usize,
327  data: &[u8],
328  now: I,
329) {
330  while let Some(front) = recent.front() {
331    if now
332      .checked_duration_since(front.at)
333      .is_some_and(|age| age > RECENT_SEND_TTL)
334    {
335      if let Some(old) = recent.pop_front() {
336        *recent_bytes -= old.data.len();
337      }
338    } else {
339      break;
340    }
341  }
342  while !recent.is_empty() && recent_bytes.saturating_add(data.len()) > RECENT_SEND_BYTES {
343    if let Some(old) = recent.pop_front() {
344      *recent_bytes -= old.data.len();
345    }
346  }
347  *recent_bytes = recent_bytes.saturating_add(data.len());
348  recent.push_back(SelfSend {
349    data: data.to_vec(),
350    at: now,
351  });
352}
353
354/// The multicast transmit path: a SYNCHRONOUS per-family fan-out that honors the
355/// proto's confirm-on-send contract (each transmit is confirmed within the same
356/// pump). Tracks each family's failing streak for fair fan-out ordering (so a
357/// constrained transport does not starve one family) and owns the self-loopback
358/// fingerprint store.
359struct Multicaster<I> {
360  /// When each family ([0] = v4, [1] = v6) started its current failing streak, so
361  /// [`family_order`] serves the longest-waiting family first. `None` when the
362  /// family last succeeded.
363  failing_since: [Option<I>; 2],
364  /// Recent sent datagrams (exact bytes + time), for self-loopback detection.
365  recent: VecDeque<SelfSend<I>>,
366  /// Total bytes buffered in `recent` (for the byte budget).
367  recent_bytes: usize,
368}
369
370impl<I: Instant> Multicaster<I> {
371  fn new() -> Self {
372    Self {
373      failing_since: [None; 2],
374      recent: VecDeque::new(),
375      recent_bytes: 0,
376    }
377  }
378
379  /// Fan a multicast datagram out to BOTH mDNS groups and report per-family
380  /// outcomes exactly. Returns a [`Fanout`] describing what happened to each
381  /// family's socket call; the caller derives both the [`MulticastOutcome`] for
382  /// the proto confirm-on-send contract and the per-family stats from it.
383  ///
384  /// **Confirm-on-send contract** (the proto's own): `delivered = true` iff at
385  /// least one socket send succeeded. So `Fanout::any_sent()` decides whether
386  /// the pump confirms — NOT whether every family succeeded.
387  ///
388  /// That `sent_any` (not all-families) rule is load-bearing for one-shot
389  /// transmits. The proto re-offers a probe/announcement on `delivered = false`
390  /// (its own schedule retries the family that missed this round), but it
391  /// CONSUMES a one-shot multicast response — and spends a conflict-rename
392  /// goodbye — on the first result, latching goodbye ownership ONLY on
393  /// `delivered = true`. If a partial fan-out (v4 queued, v6 transiently busy)
394  /// reported `false`, the records v4 already put on the wire would be cached by
395  /// v4 peers yet never latched, so a later unregister/conflict would omit their
396  /// §10.1 withdrawal and leave stale peer caches. Reporting `sent_any` latches
397  /// exactly what reached the link; the family that missed this round is tried
398  /// FIRST on the next fan-out ([`family_order`]) so even a one-datagram-per-cycle
399  /// transport reaches both groups instead of starving one, and a one-shot
400  /// response is re-asked by the querier if its family missed. Only an
401  /// all-families failure (nothing queued) returns `false`, correctly re-offering
402  /// a probe/announce and latching nothing for a response that never left the
403  /// host.
404  ///
405  /// The endpoint-owned withdrawal send uses [`Self::burst`] instead — the
406  /// endpoint owns that retry schedule, so the driver just fans one due goodbye
407  /// datagram to both families per round and reports `any_sent` back.
408  ///
409  /// Records a self-send credit for every family that sent. Uses `data.len()` as
410  /// the byte count for both families (they encode the same datagram).
411  fn send_multicast<T: UdpIo>(
412    &mut self,
413    io: &mut T,
414    data: &[u8],
415    now: I,
416  ) -> (MulticastOutcome, Fanout) {
417    let mut results = [FamilySend::Unsupported; 2];
418    let mut any_too_large = false;
419    for (idx, group) in family_order(&self.failing_since) {
420      let outcome = match io.try_send(data, group) {
421        Ok(()) => {
422          self.failing_since[idx] = None;
423          FamilySend::Sent(data.len())
424        }
425        // Busy is TRANSIENT — a momentarily-full TX queue, or an embassy
426        // NoRoute/SocketNotBound that can clear. Track the failing streak for
427        // fair fan-out ordering.
428        Err(SendError::Busy) => {
429          self.failing_since[idx].get_or_insert(now);
430          FamilySend::Busy
431        }
432        // No socket for this family — absent, but the other family may carry it.
433        Err(SendError::Unsupported) => FamilySend::Unsupported,
434        // Permanently larger than this socket buffer — retrying cannot help.
435        Err(SendError::TooLarge) => {
436          any_too_large = true;
437          // Map TooLarge to Failed so the caller can count it as a send error.
438          FamilySend::Failed
439        }
440      };
441      results[idx] = outcome;
442    }
443    let fanout = Fanout {
444      v4: results[0],
445      v6: results[1],
446    };
447    if fanout.any_sent() {
448      self.record(data, now);
449    }
450    (fanout.into_multicast_outcome(any_too_large), fanout)
451  }
452
453  /// Fan ONE endpoint-owned withdrawal (TTL=0 goodbye) datagram out to every
454  /// family that still owes a send this round, in priority order ([`family_order`],
455  /// so a one-slot transport stays fair). `owed` is a per-family one-shot gate for
456  /// THIS round (the driver passes `[1, 1]` and discards the result) — the
457  /// multi-round resend schedule is owned by [`Endpoint::note_withdrawal_result`],
458  /// NOT by this method. A family that queues decrements its gate (to 0); a family
459  /// with NO socket (`Unsupported`) or a permanently-too-large datagram (`TooLarge`)
460  /// is written off; a busy family keeps its gate but, since the driver discards
461  /// `owed`, simply reports `Busy` for this round (the endpoint re-arms it).
462  /// Maintains `failing_since` so the prioritisation favours whichever family is
463  /// behind. Not fingerprinted (a goodbye loopback is harmless — it withdraws
464  /// records already being withdrawn).
465  ///
466  /// Returns a [`Fanout`] with the per-family outcome so the caller can derive
467  /// EXACT stats: `packets_tx`/`bytes_tx` for `Sent`, `send_errors` for `Failed`,
468  /// nothing for `Unsupported`/`Busy`, and `any_sent` for the
469  /// [`Endpoint::note_withdrawal_result`] delivery confirmation.
470  fn burst<T: UdpIo>(&mut self, io: &mut T, data: &[u8], owed: &mut [u8; 2], now: I) -> Fanout {
471    let mut results = [FamilySend::Unsupported; 2];
472    for (idx, group) in family_order(&self.failing_since) {
473      if owed[idx] == 0 {
474        // Already finished for this family — leave result as Unsupported
475        // (finished-not-owed, not an error, no packet, no send_errors).
476        continue;
477      }
478      let outcome = match io.try_send(data, group) {
479        Ok(()) => {
480          self.failing_since[idx] = None;
481          owed[idx] = owed[idx].saturating_sub(1);
482          FamilySend::Sent(data.len())
483        }
484        // No socket for this family: write it off (no withdrawal possible, no
485        // error — there's simply no socket to fail). Do NOT count as send_errors.
486        Err(SendError::Unsupported) => {
487          owed[idx] = 0;
488          FamilySend::Unsupported
489        }
490        // Permanently too large for this socket's buffer: write it off and
491        // count as a real send error (the socket exists but rejects the datagram).
492        // (A queued goodbye is a subset of records already announced within the
493        // §17 ceiling, so TooLarge here is defensive, but still a real failure.)
494        Err(SendError::TooLarge) => {
495          owed[idx] = 0;
496          FamilySend::Failed
497        }
498        // Busy (transiently or persistently): keep the count and retry next call.
499        Err(SendError::Busy) => {
500          self.failing_since[idx].get_or_insert(now);
501          FamilySend::Busy
502        }
503      };
504      results[idx] = outcome;
505    }
506    Fanout {
507      v4: results[0],
508      v6: results[1],
509    }
510  }
511
512  /// Whether `data` exactly matches a recent self-send within the recency window
513  /// — no hash collisions, bounded false-positive window. A byte-identical peer
514  /// could match, but suppressing it is harmless: a duplicate query is re-asked
515  /// anyway (§7.3), and our unique probe/announce records would only match an
516  /// impersonator.
517  fn is_self(&self, data: &[u8], now: I) -> bool {
518    self.recent.iter().any(|s| {
519      s.data.as_slice() == data
520        && now
521          .checked_duration_since(s.at)
522          .is_some_and(|age| age <= RECENT_SEND_TTL)
523    })
524  }
525
526  /// Record a sent datagram for self-loopback detection (see [`record_into`]).
527  fn record(&mut self, data: &[u8], now: I) {
528    record_into(&mut self.recent, &mut self.recent_bytes, data, now);
529  }
530}
531
532/// The runtime-agnostic mDNS engine.
533///
534/// Generic over the monotonic clock `I` (an [`mdns_proto::Instant`]) and the
535/// RNG `R`; the storage pools are fixed to the `alloc`-tier slab backing.
536pub struct Engine<I: Instant, R> {
537  endpoint: ProtoEndpoint<I, R>,
538  services: BTreeMap<ServiceHandle, ServiceSlot<I>>,
539  queries: BTreeMap<QueryHandle, QuerySlot>,
540  subnets: Vec<IpCidr>,
541  /// Reusable scratch for the handles of endpoint-owned withdrawals that
542  /// completed in a pump (so [`Endpoint::drain_completed_withdrawals`] can push
543  /// into it and the pump can GC each one's driver slot). Kept on the engine and
544  /// `clear()`ed each pump so the per-pump GC allocates nothing in steady state.
545  completed_withdrawals: Vec<ServiceHandle>,
546  /// Reusable scratch for the service/query handle snapshots taken by the
547  /// transmit pump (`poll_one_transmit`) and `drain_service_updates`: those loops
548  /// early-`return` and call `&mut self` withdrawal methods mid-iteration, so they
549  /// can't hold a map borrow across the body — they reuse these buffers instead of
550  /// allocating a fresh `Vec` per pump call. `clear()`ed at the start of each use.
551  svc_handle_scratch: Vec<ServiceHandle>,
552  query_handle_scratch: Vec<QueryHandle>,
553  /// The multicast transmit path: per-family fan-out, fan-out ordering, and
554  /// self-loopback detection.
555  tx: Multicaster<I>,
556  /// Shared I/O counters. Constructed once in [`Engine::new`] and handed out via
557  /// [`Engine::stats_handle`] so callers (e.g. an embassy task, a metrics poller)
558  /// can read the same counters without borrowing the engine.
559  #[cfg(feature = "stats")]
560  stats: Arc<Stats>,
561}
562
563impl<I, R> Engine<I, R>
564where
565  I: Instant,
566  R: Rng,
567{
568  /// Create an engine from a proto-layer config and an RNG (used for probe
569  /// tiebreak seeds and query transaction ids).
570  pub fn new(config: EndpointConfig, rng: R) -> Self {
571    let endpoint = ProtoEndpoint::try_new(config, rng);
572    // Unify the engine's I/O stats with the proto endpoint's stats Arc so that
573    // engine.stats() / engine.stats_handle() returns a snapshot that includes
574    // both transport-level (packets_tx, send_errors, …) and protocol-level
575    // (packets_rx, answers_rx, …) counters.
576    #[cfg(feature = "stats")]
577    let stats = endpoint.stats_handle();
578    Self {
579      endpoint,
580      services: BTreeMap::new(),
581      queries: BTreeMap::new(),
582      subnets: Vec::new(),
583      completed_withdrawals: Vec::new(),
584      svc_handle_scratch: Vec::new(),
585      query_handle_scratch: Vec::new(),
586      tx: Multicaster::new(),
587      #[cfg(feature = "stats")]
588      stats,
589    }
590  }
591
592  /// Return a cloned handle to the unified stats Arc for this engine.
593  ///
594  /// The returned `Arc` is shared with the proto endpoint, so it captures both
595  /// transport-level (packets_tx, send_errors, …) and protocol-level
596  /// (packets_rx, answers_rx, …) counters in one consistent snapshot.
597  #[cfg(feature = "stats")]
598  #[cfg_attr(docsrs, doc(cfg(feature = "stats")))]
599  pub fn stats_handle(&self) -> Arc<Stats> {
600    self.stats.clone()
601  }
602
603  /// Take a consistent point-in-time snapshot of every counter and gauge.
604  #[cfg(feature = "stats")]
605  #[cfg_attr(docsrs, doc(cfg(feature = "stats")))]
606  pub fn stats(&self) -> StatsSnapshot {
607    self.stats.snapshot()
608  }
609
610  /// Set the device's local subnets — the RFC 6762 §11 on-link heuristic used when
611  /// the transport cannot surface the received hop-limit (neither supplied transport
612  /// can; smoltcp's `UdpMetadata` carries no RX TTL).
613  ///
614  /// OPTIONAL. With no subnets configured the §11 gate accepts every inbound mDNS
615  /// datagram (the groups are link-scoped multicast routers do not forward, so it is
616  /// on-link by IP design) rather than dropping all of it and going deaf. Configure
617  /// the device's own subnets to additionally REJECT sources outside them — a
618  /// best-effort defence against a same-link host spoofing on-link traffic.
619  pub fn set_local_subnets(&mut self, subnets: Vec<IpCidr>) {
620    self.subnets = subnets;
621  }
622
623  /// Register a service. The proto state machine is owned by the engine and
624  /// driven by [`Self::pump`]; updates are read via [`Self::poll_service_update`].
625  pub fn register_service(
626    &mut self,
627    spec: ServiceSpec,
628    now: I,
629  ) -> Result<ServiceHandle, RegisterServiceError> {
630    let (handle, proto) = self
631      .endpoint
632      .try_register_service::<Slab<Transmit>, Slab<ServiceUpdate>>(spec, now)?;
633    self.services.insert(
634      handle,
635      ServiceSlot {
636        proto,
637        updates: VecDeque::new(),
638        errored: false,
639        route_freed: false,
640        caller_gone: false,
641      },
642    );
643    Ok(handle)
644  }
645
646  /// Unregister a service, beginning its RFC 6762 §10.1 endpoint-owned
647  /// withdrawal. The endpoint KEEPS the route (holding the name against a
648  /// same-name re-registration) and drives the TTL=0 goodbye resend schedule;
649  /// [`Self::pump`] pumps each due goodbye datagram and, on completion, frees the
650  /// route and GCs the driver slot.
651  ///
652  /// The withdrawal covers whatever the service must retract: the records it
653  /// confirmed-emitted under its current name (host A/AAAA filtered against
654  /// same-host siblings by the endpoint), AND — if a conflict rename left an
655  /// old-name withdrawal still pending — that old instance name too, in the SAME
656  /// goodbye ([`Service::withdrawal_snapshot`] captures both). A never-announced
657  /// service has an empty snapshot and completes on the next pump with no
658  /// datagram on the wire.
659  ///
660  /// The driver slot is NOT removed here: it is kept (marked `errored`) so any
661  /// already-queued `ServiceUpdate::Conflict` still reaches the host, and is GC'd
662  /// when the endpoint reports the withdrawal complete.
663  pub fn unregister_service(&mut self, handle: ServiceHandle, now: I) {
664    // An already-`route_freed` slot (an internal retirement whose withdrawal
665    // completed, retained only for an un-polled update) has its route freed
666    // already, so an explicit retire that may discard the handle GCs it now rather
667    // than leak it. Otherwise mark the slot errored (so no further pump polls the
668    // now-gone service for transmits) and `caller_gone` (so the completed-
669    // withdrawal GC removes it regardless of pending updates — no reader is
670    // guaranteed), then begin its endpoint-owned withdrawal.
671    let route_freed = match self.services.get_mut(&handle) {
672      Some(slot) if slot.route_freed => true,
673      Some(slot) => {
674        slot.errored = true;
675        slot.caller_gone = true;
676        false
677      }
678      None => return,
679    };
680    if route_freed {
681      self.services.remove(&handle);
682    } else {
683      self.begin_service_withdrawal(handle, now);
684    }
685  }
686
687  /// Start a query. Updates are read via [`Self::poll_query_update`].
688  pub fn start_query(&mut self, spec: QuerySpec, now: I) -> Result<QueryHandle, StartQueryError> {
689    let handle = self.endpoint.try_start_query(spec, now)?;
690    self.queries.insert(handle, QuerySlot { errored: false });
691    Ok(handle)
692  }
693
694  /// Cancel a query and free its pool slot.
695  pub fn cancel_query(&mut self, handle: QueryHandle) {
696    self.queries.remove(&handle);
697    let _ = self.endpoint.cancel_query(handle);
698  }
699
700  /// Iterate the answers a query has collected so far — the browse / discovery
701  /// results. Empty if `handle` is not an active query. Read this after any
702  /// [`Self::pump`] (or a [`Self::poll_query_update`]); the proto keeps a bounded
703  /// snapshot, so compare its length against [`Self::query_accepted_count`] to
704  /// detect answers the `max_answers` cap evicted before you read them.
705  pub fn collected_answers(
706    &self,
707    handle: QueryHandle,
708  ) -> impl Iterator<Item = &CollectedAnswer> + '_ {
709    self.endpoint.collected_answers(handle)
710  }
711
712  /// Total answers ever accepted by a query (including ones the `max_answers` cap
713  /// has since evicted from [`Self::collected_answers`]). `None` if `handle` is not
714  /// an active query.
715  pub fn query_accepted_count(&self, handle: QueryHandle) -> Option<u64> {
716    self.endpoint.query_accepted_count(handle)
717  }
718
719  /// Step the engine once: fire due timers, drain all ready RX through the
720  /// §11 gate into the proto, surface service updates, drain all pending TX via
721  /// `io`, pump any due endpoint-owned withdrawal goodbyes, and return the next
722  /// deadline to sleep until.
723  ///
724  /// **Graceful shutdown.** There is no separate flush path: `unregister_service`
725  /// begins each service's endpoint-owned §10.1 withdrawal, and `pump` drives the
726  /// goodbye sends + frees the route on completion. To flush all pending
727  /// withdrawals before exiting, drive `pump` until [`Self::poll_deadline`] returns
728  /// `None` (no service, query, cache, or withdrawal deadline remains) — at which
729  /// point every withdrawal has completed (sent its budget or hit its 2 s anti-pin
730  /// ceiling) and its route is freed.
731  pub fn pump<T: UdpIo>(&mut self, now: I, io: &mut T, scratch: &mut [u8]) -> Option<I> {
732    self.fire_timeouts(now);
733
734    // Drain queued inbound datagrams, capped at MAX_RX_PER_PUMP so a flood can't grow
735    // a service's proto update pool proportional to the whole RX backlog before
736    // `drain_service_updates` coalesces/caps it. `try_recv` returns owned
737    // (`Copy`) metadata, so the mutable borrow of `scratch` ends before `handle_one`
738    // re-borrows it immutably alongside `&mut self`.
739    let mut rx_processed = 0usize;
740    while rx_processed < MAX_RX_PER_PUMP {
741      let Some(meta) = io.try_recv(scratch) else {
742        break;
743      };
744      rx_processed += 1;
745      let len = meta.len;
746      // A zero-length receive is a transport drop marker (e.g. smoltcp truncating an
747      // oversized datagram): it still counts against the per-pump RX cap so a flood of
748      // oversized packets can't drain the whole socket backlog in one uncapped pass
749      //, but there is nothing to deliver.
750      if len == 0 {
751        // A zero-length marker means smoltcp dequeued + discarded an oversized
752        // datagram before we saw it — the datagram WAS consumed from the
753        // transport queue, so bump packets_rx as a reliable denominator plus
754        // the usual packets_dropped reject counter. bytes_rx is NOT bumped
755        // because smoltcp discards the oversized payload before reporting to
756        // us; the original datagram length is not recoverable here.
757        #[cfg(feature = "stats")]
758        {
759          self.stats.packets_rx(1);
760          self.stats.packets_dropped(1);
761        }
762        #[cfg(feature = "defmt")]
763        defmt::debug!("rx drop: oversized/truncated datagram (len=0 marker)");
764        continue;
765      }
766      // NOTE: packets_rx / bytes_rx are bumped by ProtoEndpoint::handle()
767      // on the shared Arc — do NOT bump them here too (double-count).
768      #[cfg(feature = "defmt")]
769      defmt::trace!("rx {} bytes", len);
770      if onlink::on_link(meta.hop_limit, meta.src.ip(), meta.local, &self.subnets) {
771        self.handle_one(now, meta.src, meta.local, &scratch[..len]);
772      } else {
773        // RFC 6762 §11: off-link datagram (hop-limit ≠ 255 or src not on a
774        // known subnet). Discard without calling into the proto layer. The
775        // datagram WAS received off the socket, so count packets_rx/bytes_rx
776        // here (handle() never runs for it) plus the packets_dropped reject —
777        // matching the reactor/compio pre-handle drop accounting so receive
778        // volume and the drop stay driver-consistent rather than hidden here.
779        #[cfg(feature = "stats")]
780        {
781          self.stats.packets_rx(1);
782          self.stats.bytes_rx(len as u64);
783          self.stats.packets_dropped(1);
784        }
785        #[cfg(feature = "defmt")]
786        defmt::debug!("rx drop: off-link datagram (RFC 6762 §11 trust boundary)");
787      }
788    }
789    // Hit the cap → more datagrams may be buffered; re-pump immediately (below)
790    // rather than sleeping to the next timer.
791    let rx_capped = rx_processed == MAX_RX_PER_PUMP;
792
793    self.drain_service_updates(now);
794
795    // The free-name goodbye ORDERING (a stale TTL=0 must precede a same-name
796    // replacement's fresh positive TTL) is now enforced by the endpoint: it KEEPS
797    // the route while a withdrawal is in flight, so a same-name `register_service`
798    // is rejected (`NameAlreadyRegistered`) until `drain_completed_withdrawals`
799    // frees the name. No replacement can announce ahead of the withdrawal, so the
800    // old pre-TX barrier gate is gone and the normal TX loop runs unconditionally.
801    while let Some((dst, len, origin)) = self.poll_one_transmit(now, scratch) {
802      if dst == MDNS_SOCKET_V4 || dst == MDNS_SOCKET_V6 {
803        // Multicast: fan out to BOTH groups and confirm synchronously this pump
804        // (honors the proto's confirm-on-send contract). `fanout` carries the
805        // per-family outcome so stats are bumped from EXPLICIT sends, not a
806        // coarse aggregate — consistent with reactor/compio.
807        #[cfg_attr(
808          not(any(feature = "stats", feature = "defmt")),
809          allow(unused_variables)
810        )]
811        let (outcome, fanout) = self.tx.send_multicast(io, &scratch[..len], now);
812        // ── send_errors: count per-family Failed, INDEPENDENT of coarse outcome ──
813        // A partial fan-out (v4 Sent + v6 TooLarge) yields MulticastOutcome::Delivered
814        // but still has failed_count() == 1. Counting only inside the Undeliverable
815        // arm would silently drop that error. Count here, unconditionally, before the
816        // outcome match — consistent with the withdrawal send below and reactor/compio
817        // (Busy/Unsupported are never errors; only Failed counts).
818        #[cfg(feature = "stats")]
819        {
820          let fc = fanout.failed_count();
821          if fc > 0 {
822            self.stats.send_errors(fc as u64);
823          }
824        }
825        match outcome {
826          MulticastOutcome::Delivered => {
827            // Bump per ACTUAL datagram sent: one per family that returned Sent.
828            // `fanout.sent_count()` is 2 on dual-stack (both Sent), 1 on a
829            // partial fan-out. This matches reactor/compio which each bump
830            // packets_tx once per per-family successful send_to call.
831            // `fanout.bytes_on_wire()` sums the bytes per sending family.
832            #[cfg(feature = "stats")]
833            {
834              self.stats.packets_tx(fanout.sent_count() as u64);
835              self.stats.bytes_tx(fanout.bytes_on_wire());
836            }
837            #[cfg(feature = "defmt")]
838            defmt::trace!(
839              "tx multicast {} bytes delivered ({} families)",
840              len,
841              fanout.sent_count()
842            );
843            self.note_transmit_result(origin, now, true);
844          }
845          MulticastOutcome::Retry => self.note_transmit_result(origin, now, false),
846          // Permanently undeliverable (too large for every reachable socket): retire
847          // the producer so it stops re-offering forever and the app sees an
848          // actionable update, instead of probing/announcing indefinitely.
849          MulticastOutcome::Undeliverable => {
850            #[cfg(feature = "defmt")]
851            defmt::warn!("tx multicast {} bytes undeliverable (too large)", len);
852            // send_errors was already counted per Failed family above. The
853            // all-Unsupported case (no socket on any family) is NOT a send error —
854            // Unsupported is never an error, consistent with the per-family rule
855            // and reactor/compio; "nothing sent" is visible as zero packets_tx.
856            self.retire_origin(origin, now);
857          }
858        }
859      } else {
860        // Unicast (legacy §6.7 reply): one destination, no fan-out. A failed
861        // one-shot reply is best-effort (the querier re-asks), never service-fatal.
862        // Match on the error variant so Busy/Unsupported (transient/not-applicable)
863        // are NOT counted as send_errors — consistent with multicast and reactor/compio.
864        // Only a real socket failure (TooLarge → Failed semantics) is an error.
865        let result = io.try_send(&scratch[..len], dst);
866        let delivered = result.is_ok();
867        match result {
868          Ok(()) => {
869            #[cfg(feature = "stats")]
870            {
871              self.stats.packets_tx(1);
872              self.stats.bytes_tx(len as u64);
873            }
874            #[cfg(feature = "defmt")]
875            defmt::trace!("tx unicast {} bytes delivered", len);
876          }
877          Err(SendError::TooLarge) => {
878            // Permanent failure (datagram too large for socket buffer): count as error.
879            #[cfg(feature = "stats")]
880            self.stats.send_errors(1);
881          }
882          Err(SendError::Busy) | Err(SendError::Unsupported) => {
883            // Transient (Busy) or absent socket (Unsupported): not an error,
884            // the querier will re-ask if it needs the answer.
885          }
886        }
887        self.note_transmit_result(origin, now, delivered);
888      }
889    }
890
891    // A confirmed final announcement sets `Established` (and other transitions)
892    // INSIDE the TX loop above, AFTER the pre-loop drain. The next deadline is then
893    // the distant re-announce, so without a second drain the application could not
894    // observe `Established` until the next pump ~80% of a TTL away. Drain
895    // again so confirmed transitions are visible to `poll_service_update` now.
896    self.drain_service_updates(now);
897
898    // ── Endpoint-owned withdrawals (RFC 6762 §10.1 goodbyes) ─────────────────
899    // Pump every due TTL=0 goodbye datagram. The endpoint encodes each (with
900    // fresh sibling host-address retention computed internally), hands back the
901    // multicast datagram + the item's opaque withdrawal token; the driver fans it
902    // out to BOTH groups (`tx.burst`, the SAME per-family send path the old goodbye
903    // burst used) and reports back whether at least one family sent so the endpoint
904    // can spend / re-arm the resend round.
905    while let Some((dst, len, token)) = self.endpoint.poll_withdrawal_transmit(now, scratch) {
906      // The endpoint always returns the multicast marker; the driver fans the
907      // datagram to both groups regardless. Assert the contract in debug builds.
908      debug_assert_eq!(
909        dst, MDNS_SOCKET_V4,
910        "withdrawal dst must be the multicast marker"
911      );
912      let _ = dst;
913      // `owed = [1, 1]` is a throwaway one-shot-per-family gate for THIS round —
914      // the endpoint owns the multi-round schedule, so the mutation is discarded.
915      let mut owed = [1u8; 2];
916      // Split borrow: `tx` and `endpoint` are disjoint fields. Re-borrow `scratch`
917      // immutably here (the `poll_withdrawal_transmit` borrow ended on return).
918      let fanout = self.tx.burst(io, &scratch[..len], &mut owed, now);
919      #[cfg(feature = "stats")]
920      {
921        // packets_tx / bytes_tx: one per family that returned Sent.
922        let sent_count = fanout.sent_count();
923        if sent_count > 0 {
924          self.stats.packets_tx(u64::from(sent_count));
925          self.stats.bytes_tx(fanout.bytes_on_wire());
926        }
927        // send_errors: real I/O failures only (Failed = TooLarge write-off).
928        let failed_count = fanout.failed_count();
929        if failed_count > 0 {
930          self.stats.send_errors(u64::from(failed_count));
931        }
932        // goodbyes_tx: one logical RFC 6762 retransmit round per DELIVERED round
933        // (at least one family on the wire); a fully-failed round is re-armed by
934        // the endpoint without spending and must NOT be counted.
935        if fanout.any_sent() {
936          self.stats.goodbyes_tx(1);
937        }
938      }
939      #[cfg(feature = "defmt")]
940      if fanout.any_sent() {
941        defmt::trace!(
942          "tx withdrawal {} bytes ({} families)",
943          len,
944          fanout.sent_count()
945        );
946      }
947      // Report EACH family's outcome so the endpoint tracks per-family debt: a
948      // withdrawal frees only once every reachable family has withdrawn its
949      // records. v4-Sent + v6-Busy keeps v6's debt so a v6 recovery before
950      // the 2 s ceiling still emits its TTL=0 goodbye.
951      self.endpoint.note_withdrawal_result(
952        token,
953        now,
954        fanout.v4.withdrawal_send(),
955        fanout.v6.withdrawal_send(),
956      );
957    }
958    // Free completed withdrawals (budget spent or ceiling reached): the endpoint
959    // releases each route (decrementing services_active) and reports the handle;
960    // GC its driver slot. The scratch Vec is reused across pumps — `endpoint` and
961    // `completed_withdrawals` are disjoint fields, so the borrow is accepted.
962    self.completed_withdrawals.clear();
963    self
964      .endpoint
965      .drain_completed_withdrawals(now, &mut self.completed_withdrawals);
966    while let Some(handle) = self.completed_withdrawals.pop() {
967      // GC the driver slot — but ONLY once its app-facing updates are drained, so a
968      // `Conflict` queued at an internal retirement still reaches the host even
969      // when the (empty, never-announced) withdrawal completes in the same pump
970      // that began it. A slot with pending updates is marked `route_freed` and GC'd
971      // lazily (here on a later pump, or by `poll_service_update` when it drains).
972      match self.services.get_mut(&handle) {
973        // No pending updates, OR the caller explicitly retired and may have
974        // discarded the handle (`caller_gone`): GC now. Deferring a caller-gone
975        // slot via `route_freed` would leak it forever — no reader remains.
976        Some(slot) if slot.updates.is_empty() || slot.caller_gone => {
977          self.services.remove(&handle);
978        }
979        Some(slot) => slot.route_freed = true,
980        None => {}
981      }
982    }
983
984    let deadline = self.poll_deadline();
985    if rx_capped {
986      // A capped RX drain left datagrams buffered: wake immediately (no later than
987      // `now`) to drain the rest, instead of sleeping to a possibly-distant timer.
988      Some(deadline.map_or(now, |d| d.min(now)))
989    } else {
990      deadline
991    }
992  }
993
994  /// Feed one received datagram to the endpoint and route its `ToService`
995  /// events to the owning service state machine.
996  fn handle_one(&mut self, now: I, src: SocketAddr, local: Option<core::net::IpAddr>, data: &[u8]) {
997    // `local_ip` is only used by the proto for tracing / the opt-in
998    // advertised-source check; any valid address is acceptable.
999    let local_ip = local.unwrap_or_else(|| src.ip());
1000    // RFC 6762 self-loopback guard: a datagram matching one we just multicast is
1001    // our own loopback (some stacks echo multicast to local sockets). Tell the
1002    // proto via `caller_is_self` so it does not interpret our own
1003    // probe/announcement as a conflicting peer — independent of the source
1004    // address, which the proto's advertised-source fallback cannot always match
1005    // (e.g. an IPv6 link-local source).
1006    let caller_is_self = self.tx.is_self(data, now);
1007    // Split borrow: `endpoint.handle` holds `&mut self.endpoint` while the
1008    // route-event iterator is alive, so per-service routing reads
1009    // `self.services` through the disjoint field.
1010    let Self {
1011      endpoint, services, ..
1012    } = self;
1013    let events = match endpoint.handle(now, src, local_ip, 0, data, caller_is_self) {
1014      Ok(events) => events,
1015      Err(_) => return,
1016    };
1017    for event in events {
1018      match event {
1019        Ok(RouteEvent::ToService(to_service)) => {
1020          if let Some(slot) = services.get_mut(&to_service.handle())
1021            && !slot.errored
1022          {
1023            slot.proto.handle_event(to_service.into_event(), now);
1024          }
1025        }
1026        Ok(_) => {}
1027        Err(_) => break,
1028      }
1029    }
1030  }
1031
1032  /// Fire any due endpoint / query / service timers.
1033  fn fire_timeouts(&mut self, now: I) {
1034    let _ = self.endpoint.handle_timeout(now);
1035
1036    // Split-borrow so the query sweep reads `queries` in place and ticks via the
1037    // disjoint `endpoint` field — no per-tick Vec snapshot.
1038    let Self {
1039      endpoint, queries, ..
1040    } = &mut *self;
1041    for (&handle, slot) in queries.iter() {
1042      if slot.errored {
1043        continue;
1044      }
1045      let _ = endpoint.handle_query_timeout(handle, now);
1046    }
1047
1048    for slot in self.services.values_mut() {
1049      if !slot.errored {
1050        let _ = slot.proto.handle_timeout(now);
1051      }
1052    }
1053  }
1054
1055  /// Drain each service's proto updates into its app-facing queue, performing
1056  /// the RFC 6762 §9 auto-rename routing (`handle_service_renamed`) before
1057  /// surfacing a `Renamed` update.
1058  ///
1059  /// A §9 rename of an ANNOUNCED service needs a TTL=0 withdrawal of the OLD
1060  /// instance name. The proto hands it off the instant the rename happens
1061  /// (`Service::take_rename_goodbye_handoff`); this driver enqueues it as an
1062  /// INDEPENDENT detached withdrawal item via
1063  /// [`Endpoint::enqueue_rename_withdrawal`], for BOTH a surviving rename and a
1064  /// collision teardown. The endpoint drives that item's goodbye schedule on the
1065  /// normal withdrawal pump; the proto no longer emits the old-name goodbye from
1066  /// its own `poll_transmit`.
1067  ///
1068  /// When the NEW name additionally collides with another LOCAL service
1069  /// (`handle_service_renamed` returns Err) the service is also torn down: its
1070  /// CURRENT name is withdrawn via the endpoint-owned withdrawal lifecycle
1071  /// ([`Self::begin_service_withdrawal`]), which holds the route and resends
1072  /// before freeing the name. (The old-name detached item was already enqueued.)
1073  fn drain_service_updates(&mut self, now: I) {
1074    self.svc_handle_scratch.clear();
1075    self
1076      .svc_handle_scratch
1077      .extend(self.services.keys().copied());
1078    let mut i = 0;
1079    while i < self.svc_handle_scratch.len() {
1080      let handle = self.svc_handle_scratch[i];
1081      i += 1;
1082      while let Some(update) = self
1083        .services
1084        .get_mut(&handle)
1085        .filter(|slot| !slot.errored)
1086        .and_then(|slot| slot.proto.poll())
1087      {
1088        if let ServiceUpdate::Renamed(ref renamed) = update {
1089          let new_name = renamed.new_name().clone();
1090          let rename_result = self.endpoint.handle_service_renamed(handle, new_name);
1091          // The §9 rename of an announced service hands its OLD-name TTL=0 goodbye
1092          // off as an INDEPENDENT detached withdrawal item, both for a SURVIVING
1093          // rename and a COLLISION teardown. Take it from the proto the instant the
1094          // rename is observed (into a local, releasing the `self.services` borrow
1095          // before re-borrowing `self.endpoint`) and enqueue it — the Service no
1096          // longer drains the old-name goodbye itself.
1097          let handoff = self
1098            .services
1099            .get_mut(&handle)
1100            .and_then(|slot| slot.proto.take_rename_goodbye_handoff());
1101          if let Some(handoff) = handoff {
1102            // A rename COLLISION (rename_result Err) tears the service down: its old
1103            // name must HOLD until the goodbye completes so a quick re-register
1104            // cannot cancel the only retraction. A SURVIVING rename
1105            // stays reclaimable.
1106            self
1107              .endpoint
1108              .enqueue_rename_withdrawal(handoff, now, rename_result.is_err());
1109          }
1110          if rename_result.is_err() {
1111            // The new name collides with another local service; the service has
1112            // already rebranded and can't be kept. Surface `Conflict` and mark it
1113            // errored so every pump skips it for transmits. Begin the endpoint-owned
1114            // withdrawal for the CURRENT name, which holds the route (keeping the
1115            // name reserved) while it resends, and frees the name on completion. The
1116            // OLD name's goodbye was already enqueued above as its own detached item.
1117            // The slot stays until then so this queued `Conflict` still reaches the
1118            // host (GC'd in `pump`).
1119            if let Some(slot) = self.services.get_mut(&handle) {
1120              slot.push_update(ServiceUpdate::Conflict);
1121              slot.errored = true;
1122            }
1123            self.begin_service_withdrawal(handle, now);
1124            break;
1125          }
1126        }
1127        // A terminal emitted DIRECTLY by the proto state machine (an unresolvable
1128        // §9 conflict, or the host name claimed during probing) RETIRES the
1129        // service, exactly like the rebrand-collision path above: queue the
1130        // terminal, mark the slot errored so every pump skips it, begin the
1131        // endpoint-owned §10.1 withdrawal (which holds the route and drives the
1132        // goodbye resend, GC'ing the slot on completion), and stop draining.
1133        // Without this a proto-emitted terminal left the smoltcp route registered
1134        // and still answered/driven after the caller saw the terminal.
1135        let is_terminal = update.is_conflict() || update.is_host_conflict();
1136        if let Some(slot) = self.services.get_mut(&handle) {
1137          slot.push_update(update);
1138          if is_terminal {
1139            slot.errored = true;
1140          }
1141        }
1142        if is_terminal {
1143          self.begin_service_withdrawal(handle, now);
1144          break;
1145        }
1146      }
1147    }
1148  }
1149
1150  /// Begin the endpoint-owned RFC 6762 §10.1 withdrawal for `handle`: snapshot
1151  /// what its CURRENT name's goodbye must retract
1152  /// ([`Service::withdrawal_snapshot`]) and hand it to
1153  /// [`Endpoint::begin_withdrawal`]. The endpoint KEEPS the route (holding the
1154  /// name) and drives the resend schedule; the route is freed and the driver slot
1155  /// GC'd when [`Endpoint::drain_completed_withdrawals`] reports completion in
1156  /// [`Self::pump`]. Any in-flight §9 rename old-name goodbye is a SEPARATE
1157  /// detached item already enqueued via [`Endpoint::enqueue_rename_withdrawal`].
1158  ///
1159  /// The driver slot is left in place (the caller marks it `errored`) so a queued
1160  /// `ServiceUpdate::Conflict` still reaches the host before the slot is GC'd.
1161  /// `begin_withdrawal` is idempotent, so calling this for an already-withdrawing
1162  /// service is a no-op. A no-op for an unknown driver handle.
1163  fn begin_service_withdrawal(&mut self, handle: ServiceHandle, now: I) {
1164    // Scope the `slot` borrow so it ends before `self.endpoint` is touched (the
1165    // snapshot is owned, so no borrow of `self.services` outlives this block).
1166    // ALSO take any pending §9 rename handoff here: a retirement that races a
1167    // queued `Renamed` update (closed receiver / explicit unregister) never
1168    // reaches the update-drain site that normally enqueues it, which would strand
1169    // the old-name goodbye in a proto being GC'd. `.take()` makes the handoff
1170    // exactly-once vs the update-drain path.
1171    let (snap, handoff) = match self.services.get_mut(&handle) {
1172      Some(slot) => {
1173        let handoff = slot.proto.take_rename_goodbye_handoff();
1174        (slot.proto.withdrawal_snapshot(), handoff)
1175      }
1176      None => return,
1177    };
1178    if let Some(handoff) = handoff {
1179      // Retirement = the service is dead: hold its old name until the goodbye
1180      // completes so a re-register cannot cancel it.
1181      self.endpoint.enqueue_rename_withdrawal(handoff, now, true);
1182    }
1183    self.endpoint.begin_withdrawal(handle, snap, now);
1184  }
1185
1186  /// Extract one outgoing datagram into `scratch`: services first, then
1187  /// queries. Skips errored state machines. Returns `None` when nothing is
1188  /// pending.
1189  fn poll_one_transmit(
1190    &mut self,
1191    now: I,
1192    scratch: &mut [u8],
1193  ) -> Option<(SocketAddr, usize, Origin)> {
1194    // Cap every encoded multicast at the RFC 6762 §17 ceiling, so the normal
1195    // transmit path never emits a datagram larger than the goodbye encode scratch
1196    // can later withdraw. A record set that would exceed MAX_MDNS_MESSAGE
1197    // then fails to encode here and the service is retired below (the `Err` arm),
1198    // rather than being advertised with records no §10.1 goodbye could retract.
1199    let cap = scratch.len().min(MAX_MDNS_MESSAGE);
1200    let scratch = &mut scratch[..cap];
1201    self.svc_handle_scratch.clear();
1202    self
1203      .svc_handle_scratch
1204      .extend(self.services.keys().copied());
1205    let mut i = 0;
1206    while i < self.svc_handle_scratch.len() {
1207      let handle = self.svc_handle_scratch[i];
1208      i += 1;
1209      // NLL note: the `slot` borrow is scoped to the `match` block so it ends
1210      // before the post-match in-iteration `begin_withdrawal` call below.
1211      let escalated = {
1212        let Some(slot) = self.services.get_mut(&handle) else {
1213          continue;
1214        };
1215        if slot.errored {
1216          continue;
1217        }
1218        match slot.proto.poll_transmit(now, scratch) {
1219          Ok(Some(transmit)) => {
1220            return Some((transmit.dst(), transmit.size(), Origin::Service(handle)));
1221          }
1222          Ok(None) => false,
1223          Err(_) => {
1224            // The pending datagram can't be encoded into `scratch`; the proto
1225            // re-offers it forever, so retire the service to avoid a stall.
1226            // Queue Conflict for the caller (unchanged — the host still learns the
1227            // service died) and mark the slot errored so every subsequent pump
1228            // skips it (no busy-spin). The `slot` borrow ends here, so the
1229            // in-iteration `begin_withdrawal` call below is borrow-safe.
1230            slot.push_update(ServiceUpdate::Conflict);
1231            slot.errored = true;
1232            true
1233          }
1234        }
1235      };
1236      if escalated {
1237        // Begin the endpoint-owned withdrawal immediately — in-iteration and
1238        // non-bypassable — so an `Ok(Some)` early-return for a LATER service
1239        // cannot skip it. The endpoint KEEPS the route (holding the name) and
1240        // frees it when the goodbye completes; the slot is GC'd then. This
1241        // touches only `self.endpoint`, not `self.services`, so there is no
1242        // iterator invalidation, and `begin_withdrawal` is idempotent. The slot
1243        // is NOT removed here, so a queued `Conflict` still reaches the host.
1244        self.begin_service_withdrawal(handle, now);
1245      }
1246    }
1247
1248    self.query_handle_scratch.clear();
1249    self
1250      .query_handle_scratch
1251      .extend(self.queries.keys().copied());
1252    let mut i = 0;
1253    while i < self.query_handle_scratch.len() {
1254      let handle = self.query_handle_scratch[i];
1255      i += 1;
1256      if self.queries.get(&handle).is_some_and(|slot| slot.errored) {
1257        continue;
1258      }
1259      match self.endpoint.poll_query_transmit(handle, now, scratch) {
1260        Ok(Some(transmit)) => {
1261          return Some((transmit.dst(), transmit.size(), Origin::Query(handle)));
1262        }
1263        Ok(None) => {}
1264        Err(_) => {
1265          // The question can't be encoded into `scratch`; the proto re-offers it
1266          // forever, so retire the query (driver-skip + proto TIMEOUT terminal).
1267          self.retire_query(handle);
1268        }
1269      }
1270    }
1271
1272    None
1273  }
1274
1275  /// Confirm a previously polled transmit so the proto advances its §8.1 probe /
1276  /// §8.3 announce / §5.2 query-backoff lifecycle only on a delivered send.
1277  fn note_transmit_result(&mut self, origin: Origin, now: I, delivered: bool) {
1278    match origin {
1279      Origin::Service(handle) => {
1280        if let Some(slot) = self.services.get_mut(&handle) {
1281          slot.proto.note_transmit_result(now, delivered);
1282          // Mirror the service's CONFIRMED-ADVERTISED host set into the endpoint
1283          // route so sibling host-address retention (during a same-host
1284          // withdrawal) honours what this service ACTUALLY announced, not its
1285          // configured addresses. Idempotent overwrite; only meaningful after a
1286          // delivered announce, harmless otherwise. `slot.proto` (read) and
1287          // `self.endpoint` (mut) are disjoint fields, so this borrow is fine.
1288          if delivered {
1289            self.endpoint.note_service_advertised(
1290              handle,
1291              slot.proto.advertised_a_addrs(),
1292              slot.proto.advertised_aaaa_addrs(),
1293              slot.proto.advertises_instance(),
1294            );
1295          }
1296        }
1297      }
1298      Origin::Query(handle) => {
1299        self
1300          .endpoint
1301          .note_query_transmit_result(handle, now, delivered);
1302      }
1303    }
1304  }
1305
1306  /// Retire the state machine that produced a permanently-undeliverable transmit
1307  /// (a datagram too large for every reachable socket — a TX-buffer misconfig).
1308  /// The producer is marked errored so every pump skips it, and a service surfaces
1309  /// an actionable `Conflict` (the same retirement signal as an un-encodable
1310  /// datagram) instead of probing/announcing forever.
1311  fn retire_origin(&mut self, origin: Origin, now: I) {
1312    match origin {
1313      Origin::Service(handle) => {
1314        if let Some(slot) = self.services.get_mut(&handle) {
1315          slot.push_update(ServiceUpdate::Conflict);
1316          slot.errored = true;
1317        }
1318        // Begin the endpoint-owned withdrawal: it KEEPS the route (holding the
1319        // name) and frees it on goodbye completion, decrementing services_active
1320        // then. The slot is NOT removed here, so the queued `Conflict` still
1321        // reaches the host; it is GC'd in `pump` on completion.
1322        // `begin_withdrawal` is idempotent (safe on a double retirement) and a
1323        // no-op for an unknown handle.
1324        self.begin_service_withdrawal(handle, now);
1325      }
1326      Origin::Query(handle) => self.retire_query(handle),
1327    }
1328  }
1329
1330  /// Retire a query the driver cannot transmit (un-encodable question, or a
1331  /// permanently-too-large datagram on every reachable family). It is skipped by
1332  /// every pump (`errored`) AND forced to its proto-level TIMEOUT terminal, so the
1333  /// caller observes one `QueryUpdate::Timeout` (via `poll_query_update` →
1334  /// `Endpoint::poll_query`), late answers are frozen, and `collected_answers` stay
1335  /// readable until the caller cancels — instead of the query hanging forever (kept in sync with proto state).
1336  fn retire_query(&mut self, handle: QueryHandle) {
1337    self.endpoint.retire_query(handle);
1338    if let Some(slot) = self.queries.get_mut(&handle) {
1339      slot.errored = true;
1340    }
1341  }
1342
1343  /// The earliest deadline across the endpoint, services, and queries.
1344  ///
1345  /// Endpoint-owned withdrawal deadlines (the next due goodbye round and the
1346  /// anti-pin ceiling) are already folded into [`Endpoint::poll_timeout`], so the
1347  /// driver no longer tracks them here.
1348  pub fn poll_deadline(&self) -> Option<I> {
1349    let mut best = self.endpoint.poll_timeout();
1350    for slot in self.services.values() {
1351      if slot.errored {
1352        continue;
1353      }
1354      if let Some(deadline) = slot.proto.poll_timeout() {
1355        best = Some(best.map_or(deadline, |b| b.min(deadline)));
1356      }
1357    }
1358    for (handle, slot) in &self.queries {
1359      if slot.errored {
1360        continue;
1361      }
1362      if let Some(deadline) = self.endpoint.poll_query_timeout(*handle) {
1363        best = Some(best.map_or(deadline, |b| b.min(deadline)));
1364      }
1365    }
1366    best
1367  }
1368
1369  /// Pop one app-facing update for a registered service.
1370  ///
1371  /// If this drains the LAST update of a slot whose endpoint-owned withdrawal has
1372  /// already completed (`route_freed`), the slot is GC'd here — the deferred GC
1373  /// that lets a retirement `Conflict` survive a withdrawal which completed in the
1374  /// same pump that began it (see the `ServiceSlot::route_freed` field).
1375  pub fn poll_service_update(&mut self, handle: ServiceHandle) -> Option<ServiceUpdate> {
1376    let slot = self.services.get_mut(&handle)?;
1377    let update = slot.updates.pop_front();
1378    if update.is_some() && slot.route_freed && slot.updates.is_empty() {
1379      self.services.remove(&handle);
1380    }
1381    update
1382  }
1383
1384  /// Pop one app-facing update for a query. A query the driver RETIRED (its
1385  /// question is un-encodable, or permanently unsendable on every reachable
1386  /// family) was forced to the proto's TIMEOUT terminal when the driver retired it,
1387  /// so it surfaces one [`QueryUpdate::Timeout`] here — the caller learns it died
1388  /// (and can read [`Self::collected_answers`], frozen, then cancel) instead of
1389  /// waiting forever for a result it can never request.
1390  pub fn poll_query_update(&mut self, handle: QueryHandle) -> Option<QueryUpdate> {
1391    self.endpoint.poll_query(handle)
1392  }
1393
1394  /// Pop one endpoint-level event.
1395  pub fn poll_endpoint_event(&mut self) -> Option<EndpointEvent> {
1396    self.endpoint.poll()
1397  }
1398}