mdns_proto/endpoint/mod.rs
1//! `Endpoint` orchestrator: demuxes incoming datagrams, holds routing
2//! metadata + cache, drives Service/Query registration.
3
4#[cfg(all(test, feature = "std", feature = "slab"))]
5#[allow(
6 clippy::unwrap_used,
7 clippy::expect_used,
8 clippy::panic,
9 clippy::indexing_slicing,
10 clippy::arithmetic_side_effects
11)]
12mod tests;
13
14mod matching;
15pub(crate) use matching::*;
16mod route;
17pub use route::RouteEvents;
18pub(crate) use route::Section;
19mod query;
20mod receive;
21mod service;
22mod withdrawal;
23
24use core::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
25
26use rand_core::Rng;
27
28use crate::{
29 Instant, Name, Pool, QueryHandle, ServiceHandle,
30 cache::{Cache, CacheEntry},
31 config::{EndpointConfig, QuerySpec, ServiceSpec},
32 error::{
33 CancelQueryError, HandleError, HandleServiceRenamedError, HandleTimeoutError,
34 RegisterServiceError, StartQueryError, StorageFullError, TransmitError,
35 },
36 event::{
37 EndpointEvent, HostConflict, KnownAnswer, ProbeConflict, QueryEvent, QueryUpdate, RouteEvent,
38 ServiceEvent, ServiceQuestion, ToQuery, ToService,
39 },
40 query::{CollectedAnswer, Query},
41 service::Service,
42 trace::*,
43 transmit::Transmit,
44 wire::{MessageReader, NameRef, ResourceClass, ResourceType},
45};
46
47cfg_heap! {
48 /// Number of goodbye sends during an orderly withdrawal (RFC 6762 §10.1),
49 /// counted PER FAMILY so each reachable family withdraws its records.
50 const WITHDRAWAL_SENDS: u8 = 3;
51
52 /// Spacing between successive withdrawal goodbye resends (loss resilience).
53 // Used by `poll_withdrawal_transmit`.
54 #[allow(dead_code)]
55 const WITHDRAWAL_INTERVAL: core::time::Duration = core::time::Duration::from_millis(250);
56
57 /// Back-off added to `next_at` on a missed send (delivery not yet confirmed).
58 // Used by `note_withdrawal_result`.
59 #[allow(dead_code)]
60 const WITHDRAWAL_RETRY_BACKOFF: core::time::Duration = core::time::Duration::from_millis(20);
61
62 /// Hard deadline by which a withdrawal is force-completed regardless of
63 /// pending sends, to prevent a stale withdrawing route from pinning the name
64 /// slot indefinitely.
65 const WITHDRAWAL_CEILING: core::time::Duration = core::time::Duration::from_secs(2);
66}
67
68cfg_heap! {
69 /// Per-family result of sending one withdrawal (RFC 6762 §10.1 goodbye)
70 /// datagram, reported to [`Endpoint::note_withdrawal_result`] for EACH address
71 /// family so a withdrawal only completes once every reachable family has
72 /// withdrawn its records.
73 #[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Display)]
74 #[display("{}", self.as_str())]
75 pub enum WithdrawalSend {
76 /// The datagram reached the wire on this family — spend one of its owed rounds.
77 Sent,
78 /// Transiently undeliverable (socket busy) — keep this family's debt, retry.
79 Retry,
80 /// This family is permanently unavailable (no socket / permanent send error) —
81 /// write its debt off (it has no reachable peers to withdraw from).
82 WriteOff,
83 }
84
85 impl WithdrawalSend {
86 /// Canonical lowercase slug for this per-family send outcome.
87 pub const fn as_str(&self) -> &'static str {
88 match self {
89 Self::Sent => "sent",
90 Self::Retry => "retry",
91 Self::WriteOff => "write_off",
92 }
93 }
94 }
95
96 /// Opaque identity for a single in-progress `WithdrawalItem`, handed back by
97 /// [`Endpoint::poll_withdrawal_transmit`] and round-tripped to
98 /// [`Endpoint::note_withdrawal_result`] to confirm exactly that item's send.
99 ///
100 /// A monotonic counter (`next_withdrawal_token`) mints a fresh value
101 /// per item and never reuses one, so a token can only ever name the item it was
102 /// minted for (or no item, once that item has been drained). It is deliberately
103 /// distinct from [`ServiceHandle`]: one teardown can spawn TWO items (a
104 /// route-attached current-name goodbye and a detached old-name rename goodbye),
105 /// so the poll/note key cannot be the handle.
106 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
107 pub struct WithdrawalToken(u64);
108
109 /// In-progress withdrawal state for ONE name (one TTL=0 goodbye lifecycle).
110 /// Stored in [`Endpoint::withdrawals`] keyed by an opaque [`WithdrawalToken`].
111 /// The `I` type parameter is the [`Instant`] type of the enclosing endpoint.
112 ///
113 /// A single name — never a dual current+rename pair. A teardown DURING a §9
114 /// rename therefore enqueues TWO independent items: a route-attached one for the
115 /// current (re-announced) name, and a detached one for the old name still draining
116 /// its rename goodbye. Modelling each goodbye as its own item means neither can
117 /// starve the other, and two names that each fit `scratch` individually are both
118 /// emitted even when their combined message would not.
119 ///
120 /// `route` carries the item's relationship to a [`ServiceRoute`]:
121 /// * `Some(handle)` — a TEARDOWN item. It HOLDS the route `handle`: the name
122 /// stays blocked against re-registration until the item settles, and on
123 /// completion [`Endpoint::drain_completed_withdrawals`] frees the route
124 /// (releasing the name, decrementing `services_active`) and reports `handle`
125 /// to the driver. Only these items withdraw host A/AAAA (and so honour
126 /// sibling host-address retention).
127 /// * `None` — a DETACHED item (a renamed-away OLD name). It owns no route and
128 /// no host addresses (`host_a`/`host_aaaa` are always empty); when it settles
129 /// it is simply removed, reported to NOBODY.
130 ///
131 /// Stored as a parallel `Vec` rather than inline on [`ServiceRoute`] because
132 /// `ServiceRoute` has no generic parameter: it is a public struct used by
133 /// every downstream crate as `Pool<ServiceRoute>`, and adding `I` would
134 /// require updating every type alias / `Slab<ServiceRoute>` declaration
135 /// across the whole workspace — including external users.
136 struct WithdrawalItem<I> {
137 /// The service records (names, port, TXT) for this name's goodbye sends.
138 // Read by `poll_withdrawal_transmit`.
139 #[allow(dead_code)]
140 records: crate::records::ServiceRecords,
141 /// Which instance record kinds (PTR/SRV/TXT/subtypes) this name put on the
142 /// wire — only these are withdrawn (§7.1 KAS can suppress a subset).
143 #[allow(dead_code)]
144 owned: crate::service::EmittedRecords,
145 /// Host A (IPv4) addresses confirmed-emitted; sibling-filtered per round before
146 /// encoding. ALWAYS empty for a detached item (`route == None`) — a rename
147 /// never withdraws host A/AAAA (the host name is invariant across renames).
148 #[allow(dead_code)]
149 host_a: std::vec::Vec<Ipv4Addr>,
150 /// Host AAAA (IPv6) addresses confirmed-emitted. Always empty for a detached
151 /// item (see `host_a`).
152 #[allow(dead_code)]
153 host_aaaa: std::vec::Vec<Ipv6Addr>,
154 /// PER-FAMILY goodbye-send debt: `[0]` IPv4, `[1]` IPv6, each initialised to
155 /// `WITHDRAWAL_SENDS` (or `[0, 0]` when this name has nothing to withdraw —
156 /// never announced, no host addrs). A family's counter is decremented only when
157 /// THAT family confirms a send ([`WithdrawalSend::Sent`]) and zeroed on a
158 /// permanent write-off ([`WithdrawalSend::WriteOff`]).
159 // Read and mutated by `note_withdrawal_result`.
160 #[allow(dead_code)]
161 owed: [u8; 2],
162 /// When the next send is due. Set to `now` at construction so the first
163 /// send fires immediately.
164 // Read by `poll_withdrawal_transmit`.
165 #[allow(dead_code)]
166 next_at: I,
167 /// Hard force-complete deadline. The item is terminated at or after this
168 /// instant regardless of debt (anti-pin guard).
169 // Read by `drain_completed_withdrawals`.
170 #[allow(dead_code)]
171 ceiling_at: I,
172 /// `true` once a FINAL goodbye has been emitted AT/just-before the ceiling for
173 /// a still-owed item. Without this, a family that becomes
174 /// reachable only in the `[last_attempt, ceiling]` window — because the last
175 /// backoff overshot `ceiling_at` — would never get a try: `poll_withdrawal_transmit`
176 /// only emits while `now < ceiling_at`, so the route would be force-completed
177 /// with debt still owed. When an item is past its ceiling but still owes AND
178 /// has not yet been final-attempted, `poll_withdrawal_transmit` emits ONE last
179 /// goodbye and sets this flag; `drain_completed_withdrawals` then force-completes
180 /// a past-ceiling item only once this is set (or its debt already reached
181 /// `[0, 0]`). The flag also guarantees termination: the past-ceiling branch
182 /// fires at most once per item, so the pump loop can never re-select the same
183 /// item for another final attempt.
184 // Read/written by `poll_withdrawal_transmit`; read by
185 // `drain_completed_withdrawals`.
186 #[allow(dead_code)]
187 final_attempt: bool,
188 /// The route this item relates to. `Some(handle)` is a teardown item HOLDING
189 /// the route (blocks name-reuse, freed + reported on completion, withdraws host
190 /// addresses); `None` is a detached old-name item (no route, no host, completes
191 /// silently). See the type-level docs.
192 #[allow(dead_code)]
193 route: Option<ServiceHandle>,
194 /// Whether this DETACHED item must HOLD its instance name against fresh
195 /// `try_register_service` reuse until its goodbye completes (`route: None` items
196 /// only — a route-attached item already holds via the route table).
197 ///
198 /// `false` (the default) is a SURVIVING rename's old name: reclaimable, so a
199 /// fresh registration of the vacated name cancels the goodbye rather than being
200 /// blocked. `true` is a rename-COLLISION teardown's old
201 /// name: the service is DEAD, so its stale records must be retracted BEFORE the
202 /// name is reused; without the hold, the empty route-attached current-name
203 /// withdrawal completes first and a quick re-register cancels the only real
204 /// goodbye, leaving peers with stale PTR/SRV/TXT until TTL. Auto-
205 /// rename reclaim via `handle_service_renamed` still CANCELS even a held name —
206 /// that path must not reject (it would kill the renaming service), and
207 /// the reclaiming service re-announces the name.
208 #[allow(dead_code)]
209 holds_name: bool,
210 }
211}
212
213/// Routing metadata for a registered service.
214#[derive(Debug, Clone)]
215pub struct ServiceRoute {
216 /// DNS-SD service-type PTR owner (e.g. `_ipp._tcp.local.`).
217 service_type: Name,
218 /// Instance name (e.g. `MyPrinter._ipp._tcp.local.`).
219 name: Name,
220 /// Host name that owns the A/AAAA records (e.g. `printer-host.local.`).
221 host: Name,
222 handle: ServiceHandle,
223 /// IPv4 addresses advertised in this service's A records. Used by
224 /// `Endpoint::handle` to recognise multicast-loopback datagrams whose
225 /// source IP matches an address we are publishing. IPv6
226 /// PKTINFO carries the multicast destination rather than the local
227 /// interface address, so the IPv4-only `src == local_ip` shortcut from
228 /// cannot detect IPv6 self-packets — membership against this
229 /// list is the positive signal for both v4 and v6.
230 a_addrs: std::vec::Vec<Ipv4Addr>,
231 /// IPv6 addresses advertised in this service's AAAA records. See
232 /// `a_addrs` for the rationale.
233 aaaa_addrs: std::vec::Vec<Ipv6Addr>,
234 /// Parallel to `aaaa_addrs`: interface scope id for each AAAA (0 = any).
235 /// IPv6 link-local addresses are scoped per interface; a peer
236 /// reusing the same `fe80::*` on a different interface must NOT be
237 /// classified as self. A non-zero scope binds the address to a
238 /// specific receiving `interface_index` in [`Endpoint::handle`].
239 aaaa_scopes: std::vec::Vec<u32>,
240 /// RFC 6763 §7.1 subtype browse names (`<sub>._sub.<service_type>`). A browse
241 /// question for any of these routes to this service so it can answer with the
242 /// shared subtype PTR.
243 subtypes: std::vec::Vec<Name>,
244 /// IPv4 host addresses this service has actually CONFIRMED-ADVERTISED on the
245 /// wire — the subset of `a_addrs` a peer truly holds in its cache. EMPTY at
246 /// registration (a never-announced service has advertised nothing); the
247 /// driver mirrors the live `Service::advertised_a_addrs` set here via
248 /// [`Endpoint::note_service_advertised`] after each confirmed announce. This
249 /// (NOT the configured `a_addrs`) is what `sibling_retained_addrs` honours so
250 /// a withdrawing service only retains addresses a LIVE same-host sibling
251 /// genuinely owns in peer caches.
252 #[cfg(any(feature = "alloc", feature = "std", feature = "no-atomic"))]
253 advertised_a: std::vec::Vec<Ipv4Addr>,
254 /// IPv6 host addresses this service has actually CONFIRMED-ADVERTISED. See
255 /// `advertised_a`; this is the AAAA counterpart, also EMPTY at registration.
256 #[cfg(any(feature = "alloc", feature = "std", feature = "no-atomic"))]
257 advertised_aaaa: std::vec::Vec<Ipv6Addr>,
258 /// `true` once [`Endpoint::begin_withdrawal`] has been called for this
259 /// service. The route is kept alive (name guard + dispatch) until the
260 /// goodbye sequence completes; this flag lets downstream code distinguish a
261 /// live service from one that is in the process of being torn down.
262 // Read by `poll_timeout` dispatch skip.
263 #[allow(dead_code)]
264 withdrawing: bool,
265}
266
267impl ServiceRoute {
268 /// The DNS-SD service-type (PTR owner), e.g. `_ipp._tcp.local.`.
269 #[inline(always)]
270 pub fn service_type(&self) -> &Name {
271 &self.service_type
272 }
273
274 /// The service's instance name.
275 #[inline(always)]
276 pub fn name(&self) -> &Name {
277 &self.name
278 }
279
280 /// The service's host name (owner of A/AAAA records).
281 #[inline(always)]
282 pub fn host(&self) -> &Name {
283 &self.host
284 }
285
286 /// The handle assigned to this service.
287 #[inline(always)]
288 pub const fn handle(&self) -> ServiceHandle {
289 self.handle
290 }
291
292 /// Advertised IPv4 addresses for this service (A records).
293 #[inline(always)]
294 pub fn a_addrs(&self) -> &[Ipv4Addr] {
295 &self.a_addrs
296 }
297
298 /// Advertised IPv6 addresses for this service (AAAA records).
299 #[inline(always)]
300 pub fn aaaa_addrs(&self) -> &[Ipv6Addr] {
301 &self.aaaa_addrs
302 }
303
304 /// Per-AAAA interface scope ids (parallel to [`Self::aaaa_addrs`]).
305 /// A scope of `0` matches any receiving interface; a non-zero scope
306 /// matches only the same `interface_index` passed to
307 /// [`Endpoint::handle`].
308 #[inline(always)]
309 pub fn aaaa_scopes(&self) -> &[u32] {
310 &self.aaaa_scopes
311 }
312
313 cfg_heap! {
314 /// IPv4 host addresses this service has CONFIRMED-ADVERTISED on the wire.
315 /// Distinct from [`Self::a_addrs`] (the configured set used for self-/
316 /// loopback detection): this is the subset peers actually hold in cache, kept
317 /// current by [`Endpoint::note_service_advertised`] and consumed by
318 /// sibling host-address retention during withdrawal.
319 #[inline(always)]
320 pub(crate) fn advertised_a(&self) -> &[Ipv4Addr] {
321 &self.advertised_a
322 }
323
324 /// IPv6 host addresses this service has CONFIRMED-ADVERTISED on the wire (the
325 /// AAAA counterpart of [`Self::advertised_a`]).
326 #[inline(always)]
327 pub(crate) fn advertised_aaaa(&self) -> &[Ipv6Addr] {
328 &self.advertised_aaaa
329 }
330 }
331}
332
333/// Internal queued endpoint event.
334#[derive(Debug, Clone)]
335pub struct EndpointEventEntry(EndpointEvent);
336
337impl EndpointEventEntry {
338 /// Borrow the inner event.
339 #[inline(always)]
340 pub const fn event(&self) -> &EndpointEvent {
341 &self.0
342 }
343}
344
345/// The orchestrator. Holds routing metadata + cache + per-handle state
346/// machines for Service (caller-driven) and Query (Endpoint-owned).
347///
348/// The `Query` state machines live in the `QS` pool — callers receive only
349/// a `QueryHandle` from [`Self::try_start_query`] and drive each query via
350/// the `*_query*` accessors on `Endpoint`.
351///
352/// # Query lifecycle and cleanup
353///
354/// Queries are NOT auto-pruned. After
355/// [`Self::poll_query`] returns the terminal `QueryUpdate` for a handle,
356/// the underlying state machine is RETAINED so the caller can drain
357/// final results via [`Self::collected_answers`]. Late matching
358/// responses arriving after terminal are frozen out: they do not
359/// mutate `collected_answers` or trigger fan-out events.
360///
361/// Cleanup is the caller's responsibility — terminated queries leak
362/// pool slots until explicitly freed. Two equivalent options:
363///
364/// * [`Self::cancel_query`] — drop a specific handle.
365/// * [`Self::sweep_terminated_queries`] — drop every query whose
366/// terminal has already been delivered.
367///
368/// Failing to clean up exhausts a fixed-capacity `QS` pool just as the
369/// leak would have, so this contract must be honoured.
370pub struct Endpoint<I, R, C, SR, QS, EV, AN, EvQ> {
371 config: EndpointConfig,
372 rng: R,
373 services: SR,
374 queries: QS,
375 cache: Cache<I, C>,
376 pending_events: EV,
377 next_service_handle: u32,
378 next_query_handle: u32,
379 next_txid: u16,
380 /// In-progress withdrawal items, keyed by an opaque [`WithdrawalToken`]. Each
381 /// entry is ONE name's TTL=0 goodbye lifecycle; a route-attached item keeps its
382 /// route in `self.services` alive until the goodbye sequence completes (so the
383 /// name guard continues to reject same-name re-registration).
384 ///
385 /// Stored as a `Vec` rather than as an inline field on [`ServiceRoute`]
386 /// because `ServiceRoute` is non-generic (adding `I` there would require
387 /// updating every `Pool<ServiceRoute>` / `Slab<ServiceRoute>` site across
388 /// the whole workspace, including external users).
389 #[cfg(any(feature = "alloc", feature = "std", feature = "no-atomic"))]
390 withdrawals: std::vec::Vec<(WithdrawalToken, WithdrawalItem<I>)>,
391 /// Monotonic source of [`WithdrawalToken`] values. Incremented on every item
392 /// insert and NEVER reused, so a token names exactly the item it was minted for
393 /// (or nothing, once that item drained) — there is no ABA on the poll/note key.
394 #[cfg(any(feature = "alloc", feature = "std", feature = "no-atomic"))]
395 next_withdrawal_token: u64,
396 #[cfg(feature = "stats")]
397 stats: std::sync::Arc<hick_trace::stats::Stats>,
398 _phantom: core::marker::PhantomData<(AN, EvQ)>,
399}
400
401impl<I, R, C, SR, QS, EV, AN, EvQ> Endpoint<I, R, C, SR, QS, EV, AN, EvQ>
402where
403 I: Instant,
404 R: Rng,
405 C: Pool<CacheEntry<I>>,
406 SR: Pool<ServiceRoute>,
407 QS: Pool<Query<I, AN, EvQ>>,
408 EV: Pool<EndpointEventEntry>,
409 AN: Pool<CollectedAnswer>,
410 EvQ: Pool<QueryUpdate>,
411{
412 /// Build a new endpoint.
413 pub fn try_new(config: EndpointConfig, mut rng: R) -> Self {
414 let raw_txid = rng.next_u32() as u16;
415 let next_txid = if raw_txid == 0 { 1 } else { raw_txid };
416 #[cfg(feature = "stats")]
417 let stats = std::sync::Arc::new(hick_trace::stats::Stats::default());
418 #[cfg(feature = "stats")]
419 let mut cache = Cache::new();
420 #[cfg(feature = "stats")]
421 cache.set_stats(stats.clone());
422 #[cfg(not(feature = "stats"))]
423 let cache = Cache::new();
424 Self {
425 config,
426 rng,
427 services: SR::new(),
428 queries: QS::new(),
429 cache,
430 pending_events: EV::new(),
431 next_service_handle: 0,
432 next_query_handle: 0,
433 next_txid,
434 #[cfg(any(feature = "alloc", feature = "std", feature = "no-atomic"))]
435 withdrawals: std::vec::Vec::new(),
436 #[cfg(any(feature = "alloc", feature = "std", feature = "no-atomic"))]
437 next_withdrawal_token: 0,
438 #[cfg(feature = "stats")]
439 stats,
440 _phantom: core::marker::PhantomData,
441 }
442 }
443
444 cfg_stats! {
445 /// Return a point-in-time snapshot of all counters and gauges.
446 pub fn stats(&self) -> hick_trace::stats::StatsSnapshot {
447 self.stats.snapshot()
448 }
449
450 /// Return a cloned handle to the shared [`hick_trace::stats::Stats`] so the I/O driver can
451 /// bump transport-level counters (e.g. `bytes_tx`, `packets_tx`).
452 pub fn stats_handle(&self) -> std::sync::Arc<hick_trace::stats::Stats> {
453 self.stats.clone()
454 }
455 }
456
457 /// Returns the configuration.
458 #[inline(always)]
459 pub const fn config(&self) -> &EndpointConfig {
460 &self.config
461 }
462}