Files
punktfunk/crates/pf-client-core/src/discovery.rs
T
enricobuehler b25e6eda91
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
fix(clients): host discovery heals itself, and every client can rescan
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.

Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:

- A failed resolve was never retried. `browseResultsChangedHandler`
  only fires when the result SET changes, and a host whose resolve
  failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
  throwaway UDP flow used to resolve an address could sit in
  `.preparing`/`.waiting` forever, and a service with a connection in
  flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
  re-armed). On iOS that is where the local-network privacy prompt
  lands on first launch after install: the browse starts, the system
  asks, and the browser waits. Granting does not revive that browser —
  only a new one sees the grant. That is the reported first-run bug.

HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).

Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.

The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.

Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.

Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
2026-08-06 13:30:10 +02:00

341 lines
15 KiB
Rust

//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
//! results to the UI. Removal events are forwarded too, so the hosts page can drop stale
//! cards and flip a saved host's online pip when its advert disappears.
use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
pub key: String,
/// The mDNS service fullname — what a later `Removed` event identifies the advert by.
pub fullname: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
pub fp_hex: String,
/// Pairing requirement: `"required"` or `"optional"`.
pub pair: String,
/// The management API's port (mDNS `mgmt` TXT) — where the game library is served.
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
/// library client then falls back to the well-known default.
pub mgmt_port: Option<u16>,
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
pub mac: Vec<String>,
/// The host's OS-identity chain from the mDNS `os` TXT (`windows` | `macos` |
/// `linux[/<family>][/<id>]`), sanitized ([`crate::os::sanitize_os`]) — drives the host
/// card's OS icon and is persisted like `mac`. Empty if absent (older host).
pub os: String,
}
impl DiscoveredHost {
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
pub fn advertised_id(&self) -> &str {
if self.key == self.fullname {
""
} else {
&self.key
}
}
}
/// One discovery update for the UI's advert map.
pub enum DiscoveryEvent {
/// A host advert appeared or refreshed (new address, pairing flipped, …).
Resolved(DiscoveredHost),
/// The advert went away (host stopped / left the network).
Removed { fullname: String },
}
/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request
/// made after the browse has ended is simply never read.
///
/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped
/// at one hour), so a browse that has been up a while is effectively passive — it is listening for
/// announcements rather than asking. A host that starts advertising later, or whose announcement
/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time.
/// Re-querying resets that clock, which is what a Refresh button should do.
#[derive(Clone, Debug)]
pub struct Rescan(Arc<AtomicBool>);
impl Rescan {
/// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query
/// follows within a tick. Coalesces — several requests in a row cost one query.
pub fn request(&self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker
/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so
/// it stops even on a LAN where no advert ever arrives.
pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
let (tx, rx) = async_channel::unbounded();
let flag = Arc::new(AtomicBool::new(false));
let requested = flag.clone();
std::thread::Builder::new()
.name("punktfunk-mdns".into())
.spawn(move || {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
return;
}
};
let mut receiver = match daemon.browse(SERVICE_TYPE) {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
return;
}
};
// Polled rather than blocked on: the worker has to notice that its consumer went
// away even when NOTHING is arriving, which is the normal state of a LAN with no
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
loop {
// Checked at the TOP so it also covers the arms below that `continue` without
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
// address. Those are the paths that would otherwise keep this thread alive with
// nobody to send to.
if tx.is_closed() {
break;
}
// Also at the TOP, and for the same reason: every `continue` below would skip it.
if requested.swap(false, Ordering::Relaxed) {
// Browsing the same type again REPLACES the daemon's listener for it: it
// replays the cache into the new channel (so nothing already known is lost),
// puts a fresh PTR query on the wire immediately, and — the point — resets the
// re-query backoff described on `Rescan`.
match daemon.browse(SERVICE_TYPE) {
Ok(r) => receiver = r,
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
}
}
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
Ok(event) => event,
Err(_) if receiver.is_disconnected() => break,
Err(_) => continue,
};
let update = match event {
ServiceEvent::ServiceResolved(info) => {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
// IPv4 only, on purpose (same policy as the Android client): the core
// dials `format!("{host}:{port}").parse::<SocketAddr>()` over IPv4-bound
// sockets, so a v6 pick from this unordered address set (the host's OS
// responder often answers AAAA for its hostname) would render a host card
// that fails on every click. A v6-only advert is dropped — the honest
// "not found" — until the stack actually speaks IPv6.
let Some(addr) =
info.get_addresses_v4().iter().next().map(|a| a.to_string())
else {
continue;
};
let id = val("id");
DiscoveryEvent::Resolved(DiscoveredHost {
key: if id.is_empty() {
info.get_fullname().to_string()
} else {
id
},
fullname: info.get_fullname().to_string(),
name: info
.get_fullname()
.split('.')
.next()
.unwrap_or("?")
.to_string(),
addr,
port: info.get_port(),
fp_hex: val("fp"),
pair: val("pair"),
mgmt_port: val("mgmt").parse().ok(),
mac: val("mac")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
os: crate::os::sanitize_os(&val("os")),
})
}
ServiceEvent::ServiceRemoved(_ty, fullname) => {
DiscoveryEvent::Removed { fullname }
}
_ => continue,
};
if tx.send_blocking(update).is_err() {
break; // UI gone — stop browsing
}
}
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
(rx, Rescan(flag))
}
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
/// fold — which is where dedupe and removal actually live — is testable without a network.
type Adverts = BTreeMap<String, DiscoveredHost>;
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
match event {
DiscoveryEvent::Resolved(host) => {
adverts.insert(host.key.clone(), host);
}
DiscoveryEvent::Removed { fullname } => {
adverts.retain(|_, h| h.fullname != fullname);
}
}
}
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
///
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
let (rx, _rescan) = browse();
let deadline = Instant::now() + timeout;
let mut adverts = Adverts::new();
while Instant::now() < deadline {
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
// receive has no timeout, and the whole point of this call is that it is bounded.
std::thread::sleep(Duration::from_millis(50).min(timeout));
}
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
drop(rx);
sorted(adverts)
}
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
hosts.sort_by_key(|h| {
(
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
h.addr.clone(),
h.port,
)
});
hosts
}
#[cfg(test)]
mod tests {
use super::*;
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
DiscoveredHost {
key: key.into(),
fullname: fullname.into(),
name: fullname.split('.').next().unwrap_or("?").into(),
addr: addr.into(),
port: 9777,
fp_hex: "aa".into(),
pair: "required".into(),
mgmt_port: Some(47990),
mac: vec![],
os: String::new(),
}
}
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
/// a host that moved to a new address stops being listed at the stale one.
#[test]
fn refreshed_advert_supersedes_the_earlier_one() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
);
let out = sorted(adverts);
assert_eq!(out.len(), 1, "same key must not render twice");
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
}
/// A host that goes away during the browse window is not in the answer.
#[test]
fn removal_drops_the_advert_it_names() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
);
fold(
&mut adverts,
DiscoveryEvent::Removed {
fullname: "desk._punktfunk._udp.local.".into(),
},
);
let out = sorted(adverts);
assert_eq!(out.len(), 1);
assert_eq!(out[0].key, "id-2");
}
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
/// fullname as an id, which would send a caller launching against a nonexistent reference.
#[test]
fn advertised_id_is_empty_without_the_txt() {
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
assert_eq!(named.advertised_id(), "id-1");
let anonymous = host(
"desk._punktfunk._udp.local.",
"desk._punktfunk._udp.local.",
"10.0.0.1",
);
assert_eq!(anonymous.advertised_id(), "");
}
/// Addresses sort the way a person reads them, not the way strings compare.
#[test]
fn addresses_sort_numerically() {
let mut adverts = Adverts::new();
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
.into_iter()
.enumerate()
{
fold(
&mut adverts,
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
);
}
let out = sorted(adverts);
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
}
}