From d0fa8bd3ee91dac0feb3f50abe3475e30cb1c884 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 9 Jul 2026 10:56:49 +0200 Subject: [PATCH] fix(clients): pin mDNS discovery to IPv4 on every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host machines' OS mDNS responders answer AAAA for hostname.local even though the punktfunk stack binds IPv4 sockets exclusively, so a v6-resolved address rendered a host card whose connect always failed. Apple's Network.framework prefers IPv6 (RFC 6724) — force the resolve connection to v4; pf-client-core (Linux/Windows/Deck shells) now picks get_addresses_v4() instead of an arbitrary first address. Android already did exactly this, for the same reason. Lift all three when the stack actually speaks IPv6 (per-family shard sizing is the gating item — the IPv6 header costs 20 bytes of the MTU budget shard_payload is maximal against). Co-Authored-By: Claude Fable 5 --- .../PunktfunkKit/Connection/HostDiscovery.swift | 11 ++++++++++- crates/pf-client-core/src/discovery.rs | 9 ++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index 4db7e914..301e23d1 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -127,7 +127,16 @@ public final class HostDiscovery: ObservableObject { .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } - let conn = NWConnection(to: result.endpoint, using: .udp) + // Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS + // mDNS responder often answers AAAA for its hostname even though the punktfunk host stack + // (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would + // produce a host card whose connect always fails in the Rust core (`host:port` parse). + // Same policy as the Android/desktop clients; lift when the stack speaks IPv6. + let params = NWParameters.udp + if let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { + ip.version = .v4 + } + let conn = NWConnection(to: result.endpoint, using: params) connections[key] = conn conn.stateUpdateHandler = { state in MainActor.assumeIsolated { [weak self] in diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index 47b5c9e8..6aeccaa5 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -61,7 +61,14 @@ pub fn browse() -> async_channel::Receiver { ServiceEvent::ServiceResolved(info) => { let props = info.get_properties(); let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); - let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string()) + // IPv4 only, on purpose (same policy as the Android client): the core + // dials `format!("{host}:{port}").parse::()` 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; };