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}