Skip to main content

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}