From b25e6eda913f750f71d147830d093f0ac46817ad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:30:10 +0200 Subject: [PATCH] fix(clients): host discovery heals itself, and every client can rescan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 52 ++- .../punktfunk/kit/discovery/HostDiscovery.kt | 21 + .../Sources/PunktfunkClient/ContentView.swift | 14 + .../Home/GamepadHomeView.swift | 23 +- .../PunktfunkClient/Home/HomeView.swift | 43 +- .../Connection/HostDiscovery.swift | 366 ++++++++++++++---- .../HostDiscoveryTests.swift | 16 + clients/linux/src/ui_hosts.rs | 25 +- clients/linux/src/ui_trust.rs | 11 +- clients/session/src/console.rs | 14 +- clients/windows/src/app/connect.rs | 10 +- clients/windows/src/app/hosts.rs | 16 + clients/windows/src/app/mod.rs | 8 +- clients/windows/src/discovery.rs | 62 ++- clients/windows/src/main.rs | 2 +- crates/pf-client-core/src/discovery.rs | 50 ++- 16 files changed, 625 insertions(+), 108 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index c8e80229..a0264bac 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -168,8 +168,7 @@ fun ConnectScreen( lnpPrompt = false // The browse started while blocked (its sockets failed or received nothing) — restart it // now that the grant makes them work. - discovery.stop() - discovery.start() + discovery.restart() } else { lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly) } @@ -191,12 +190,27 @@ fun ConnectScreen( // or otherwise notify the app — this observer is what turns the grant into a live discovery. DisposableEffect(Unit) { val lifecycle = (context as? LifecycleOwner)?.lifecycle + // Whether we've actually been away. ON_RESUME also fires on first entry, right after the + // effect below starts the browse — restarting it there would be pure churn. + var wasPaused = false val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) { - lnpGranted = true - lnpPrompt = false - discovery.stop() - discovery.start() + when (event) { + Lifecycle.Event.ON_PAUSE -> wasPaused = true + Lifecycle.Event.ON_RESUME -> { + if (!lnpGranted && hasLocalNetworkPermission(context)) { + lnpGranted = true + lnpPrompt = false + discovery.restart() + } else if (wasPaused) { + // Coming back from the background: the browse may have been sitting idle + // (or had its multicast socket torn out from under it) while we were away, + // and its own re-query interval has kept doubling. Re-arm and ask again, + // so returning to the screen is enough — no app restart. + discovery.restart() + } + wasPaused = false + } + else -> {} } } lifecycle?.addObserver(obs) @@ -1009,20 +1023,28 @@ fun ConnectScreen( // rather than looking idle/empty. Suppressed while local network access is denied — // a spinner would be a lie there (the browse can't receive anything); the banner above // owns that state. - if (lnpGranted && !connecting && discovered.isEmpty()) { + // Scan again is offered whether or not anything turned up: the case that sends people + // here is ONE expected host missing, not an empty list, and a browse that quietly went + // deaf (blocked when it started, or backed off to its hour-long re-query) looks + // exactly like a network without that host on it. + if (lnpGranted && !connecting) { item(span = { GridItemSpan(maxLineSpan) }) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - Text( - "Searching the local network…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (discovered.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text( + "Searching the local network…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + } + TextButton(onClick = { discovery.restart() }) { Text("Scan again") } } } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt index e67b766a..18b6bd39 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/discovery/HostDiscovery.kt @@ -132,6 +132,27 @@ class HostDiscovery(context: Context) { handler.post(poll) } + /** + * Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path + * for a browse that started while blocked (permission not yet granted, multicast filtered) or + * that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and + * nothing else would ever retry it). + * + * It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps + * at an hour, so a long-lived browse is effectively passive — a host that appeared since, or + * whose announcement was lost to multicast, may never be asked for again. + * + * The currently-shown host set is left alone across the swap (rather than blinking empty via + * [stop]'s notification); the first poll of the new browse publishes the fresh set. + */ + fun restart() { + val keep = onChange + onChange = null + stop() + onChange = keep + start() + } + fun stop() { if (!running && nativeHandle == 0L) return running = false diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..d4b0e776 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -206,6 +206,20 @@ struct ContentView: View { model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal) } #if os(iOS) || os(tvOS) + // Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do + // NOT fire across background/foreground, and a browse the system suspended while we were + // away does not resume on its own — so the host grid came back empty and stayed empty + // until the app was relaunched. No-op unless the browse is already running (mid-session + // the home has deliberately torn it down). + // + // Mobile only: macOS never suspends the process, and its `scenePhase` flips on every + // window focus change — re-arming there would rebuild the browser each time you alt-tab. + // A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead. + .onChange(of: scenePhase) { _, phase in + if phase == .active { discovery.refreshIfRunning() } + } + #endif + #if os(iOS) || os(tvOS) // Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is // ignored so neither branch fires for a Control-Center pull. // diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 2db6afc4..1282fdb8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -23,14 +23,15 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) import GameController -/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host -/// action. Hashable so it can be the carousel's scroll-position identity. +/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing +/// actions. Hashable so it can be the carousel's scroll-position identity. private enum GamepadHomeTarget: Hashable { /// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a /// controller-first surface are THE profile affordance: focus and press, no menus. case saved(UUID, profile: String?) case discovered(String) case addHost + case rescan } /// A fully-resolved launcher tile — display fields + the activate action, built fresh each render @@ -262,10 +263,14 @@ struct GamepadHomeView: View { private var hints: [GamepadHint] { let selected = tiles.first { $0.id == selection } + let action: String? = switch selected?.id { + case .addHost: "Add Host" + case .rescan: "Rescan" + default: nil + } var hints = [GamepadHint( glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), - text: selected?.id == .addHost ? "Add Host" - : (selected?.canWake == true ? "Wake & Connect" : "Connect"))] + text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))] if libraryEnabled, selected?.hasLibrary == true { hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) } @@ -325,7 +330,15 @@ struct GamepadHomeView: View { subtitle: "Register a host by address", icon: "plus", activate: { showAddHost = true }) - return saved + discovered + [add] + // A controller surface has no toolbar and no pull-to-refresh, so the rescan the field + // asked for is a tile like any other — one press from wherever the stick already is. + let rescan = HomeTile( + id: .rescan, + title: "Rescan", + subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network", + icon: "arrow.clockwise", + activate: { discovery.refresh() }) + return saved + discovered + [add, rescan] } /// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index f0f68331..c0936b1e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -53,7 +53,18 @@ struct HomeView: View { NavigationStack { Group { if store.hosts.isEmpty && discoveredUnsaved.isEmpty { - emptyState + #if os(tvOS) + emptyState // no pull-to-refresh on a remote; the action row carries Refresh + #else + // Inside a ScrollView purely so the pull gesture works on the ONE screen + // where a rescan matters most: the one that found nothing. + ScrollView { + emptyState + .frame(maxWidth: .infinity) + .containerRelativeFrame(.vertical) + } + .refreshable { await discovery.rescan() } + #endif } else { ScrollView { if !store.hosts.isEmpty { @@ -94,6 +105,7 @@ struct HomeView: View { } label: { Label("Settings", systemImage: "gearshape") } + refreshButton } .padding(.top, 24) // One FULL-WIDTH focus target for any downward move out of the grid. @@ -106,6 +118,9 @@ struct HomeView: View { .focusSection() #endif } + #if !os(tvOS) + .refreshable { await discovery.rescan() } + #endif } } .navigationTitle("Punktfunk") @@ -151,6 +166,7 @@ struct HomeView: View { if showsArrangeMenu { ToolbarItem(placement: .topBarTrailing) { arrangeMenu } } + ToolbarItem(placement: .topBarTrailing) { refreshButton } ToolbarItem(placement: .topBarTrailing) { addHostButton } #else if showsArrangeMenu { @@ -159,6 +175,10 @@ struct HomeView: View { .help("Sort and group the host list") } } + ToolbarItem(placement: .primaryAction) { + refreshButton + .help("Scan the network for hosts again") + } ToolbarItem(placement: .primaryAction) { addHostButton .help("Add a host") @@ -324,13 +344,20 @@ struct HomeView: View { ContentUnavailableView { Label("No Hosts", systemImage: "rectangle.connected.to.line.below") } description: { - Text("Add your punktfunk host with the + button.") + Text("Add your punktfunk host with the + button, or scan the network again.") } actions: { Button("Add Host") { showAddHost = true } .glassProminentButtonStyle() #if os(iOS) .controlSize(.large) #endif + // The screen a host SHOULD have appeared on is where a rescan is worth offering + // outright rather than hiding behind a pull gesture. + Button("Scan Again") { discovery.refresh() } + .disabled(discovery.isScanning) + #if os(iOS) + .controlSize(.large) + #endif #if os(tvOS) Button("Settings") { showSettings = true } #endif @@ -345,6 +372,18 @@ struct HomeView: View { } } + /// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep), + /// so this is the fallback the field asked for — and the fastest way past the iOS + /// local-network permission gate, which only a NEW browser can clear. + private var refreshButton: some View { + Button { + discovery.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(discovery.isScanning) + } + #if !os(tvOS) /// One host has no order and nothing to divide, so the control stays out of the way until /// there is a list to arrange. diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index 3bc17a8a..316f2c8d 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -9,6 +9,25 @@ // // iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp` // (Config/Info.plist) — without it the system blocks the browse and nothing is returned. +// +// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive +// recovers on its own, and all three failure modes read as "the host isn't there": +// +// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is +// found but whose resolve fails is never re-offered — from the browser's point of view +// nothing changed — so one unlucky resolve hid that host for the life of the process. +// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our +// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting` +// forever instead of failing, so the retry path above was never even reached. +// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL +// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system +// puts up its "find and connect to devices on your local network" prompt, and the browser +// waits. Granting permission does NOT revive that browser — only a new one sees the grant. +// +// Every one of those presented as "restarting the app fixes it", which is what field reports +// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff +// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately, +// behind the UI's pull-to-refresh and Refresh button. #if canImport(Network) import Foundation @@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable { public final class HostDiscovery: ObservableObject { /// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor. @Published public private(set) var hosts: [DiscoveredHost] = [] + /// True for a moment after a rescan is kicked off, so a Refresh control can show that it did + /// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS). + @Published public private(set) var isScanning = false private var browser: NWBrowser? - /// Keyed by the service endpoint's description (a stable, Sendable handle we can capture - /// into the resolve callbacks without smuggling non-Sendable Network types across hops). - private var resolved: [String: DiscoveredHost] = [:] + /// Every service the browser currently reports, keyed by the endpoint's description (a stable, + /// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable + /// Network types across hops). Held — not just diffed — so a retry can re-resolve a service + /// the browser will never report again (see the file header). + private var services: [String: NWBrowser.Result] = [:] + /// The transport address a completed resolve produced, per service key. The rest of a + /// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report. + private var addresses: [String: (host: String, port: UInt16)] = [:] private var connections: [String: NWConnection] = [:] + /// Deadline for each in-flight resolve — `NWConnection` has none of its own. + private var deadlines: [String: Date] = [:] + /// Consecutive failed resolves per service, and when the next attempt is allowed. + private var failures: [String: Int] = [:] + private var retryAt: [String: Date] = [:] + /// Services whose address should be re-resolved even though we already have one — set by + /// `refresh()`. The old address keeps showing until the new one lands, so a rescan never + /// blinks the list empty; without this a manual Refresh silently skipped every host it had + /// already resolved, which is exactly the host whose address may have moved. + private var staleAddresses: Set = [] + /// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy. + private var browserFailures = 0 + private var browserRearmAt: Date? + /// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it + /// started — are ignored instead of clobbering the current generation's bookkeeping. + private var generation = 0 + /// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser. + private var sweep: Task? + private var scanningUntil: Date? + + /// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake. + private static let resolveTimeout: TimeInterval = 6 + /// How long `isScanning` holds — and `rescan()` waits — after a manual refresh. + private static let scanSettle: TimeInterval = 1.5 + /// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long + /// enough that a genuinely-down network doesn't spin the main queue, short enough that a host + /// coming back is picked up while the user is still looking at the screen. + private static func backoff(_ failures: Int) -> TimeInterval { + min(pow(2, Double(max(0, failures - 1))), 30) + } public init() {} @@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject { guard !debugPinned else { return } // a seeded advert set outranks the live LAN #endif guard browser == nil else { return } - let browser = NWBrowser( - for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), - using: NWParameters()) - browser.browseResultsChangedHandler = { results, _ in - MainActor.assumeIsolated { [weak self] in self?.reconcile(results) } - } - browser.stateUpdateHandler = { state in - // A failed browser never recovers on its own; tear down and re-arm so transient - // network changes (Wi-Fi flip, VPN) don't leave discovery silently dead. - MainActor.assumeIsolated { [weak self] in - if case .failed = state { self?.restart() } - } - } - self.browser = browser - browser.start(queue: .main) + armBrowser() + startSweep() } /// Stop browsing and drop all discovered state. public func stop() { + sweep?.cancel() + sweep = nil + generation &+= 1 browser?.cancel() browser = nil for conn in connections.values { conn.cancel() } connections.removeAll() - resolved.removeAll() + deadlines.removeAll() + services.removeAll() + addresses.removeAll() + failures.removeAll() + retryAt.removeAll() + staleAddresses.removeAll() + browserFailures = 0 + browserRearmAt = nil + scanningUntil = nil + if isScanning { isScanning = false } if !hosts.isEmpty { hosts = [] } } + /// Force a rescan now: re-arm the browser and retry every service whose resolve had failed, + /// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the + /// failure modes in the file header — and the only thing that clears the iOS local-network + /// permission gate without an app restart, since only a NEW browser sees a permission the + /// user granted after the old one started. + /// + /// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing. + public func refresh() { + #if DEBUG + guard !debugPinned else { return } // as in `start()` — the harness's set is the truth + #endif + isScanning = true + scanningUntil = Date().addingTimeInterval(Self.scanSettle) + failures.removeAll() + retryAt.removeAll() + staleAddresses = Set(services.keys) + browserFailures = 0 + armBrowser() + startSweep() + pump() + } + + /// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a + /// browse that had time to answer instead of blinking out instantly. + public func rescan() async { + refresh() + try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000)) + } + + /// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS + /// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across + /// background/foreground, so a browse that died while suspended stayed dead on return; this + /// re-arms it without starting a browse on a screen that deliberately isn't browsing + /// (mid-session, where the home tore discovery down). + public func refreshIfRunning() { + guard browser != nil else { return } + refresh() + } + deinit { + sweep?.cancel() browser?.cancel() for conn in connections.values { conn.cancel() } } @@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject { } #endif - private func restart() { - stop() - start() + // MARK: - Browser + + /// Build and start a fresh browser, retiring the previous one and every resolve it started. + /// Those resolves' callbacks are gated on `generation`, so they must not be left holding map + /// entries — `pump()` restarts them against the new generation. + private func armBrowser() { + generation &+= 1 + browser?.cancel() + for conn in connections.values { conn.cancel() } + connections.removeAll() + deadlines.removeAll() + browserRearmAt = nil + + let generation = self.generation + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), + using: NWParameters()) + browser.browseResultsChangedHandler = { results, _ in + MainActor.assumeIsolated { [weak self] in + guard let self, generation == self.generation else { return } + self.reconcile(results) + } + } + browser.stateUpdateHandler = { state in + MainActor.assumeIsolated { [weak self] in + guard let self, generation == self.generation else { return } + self.browserStateChanged(state) + } + } + self.browser = browser + browser.start(queue: .main) } - /// Diff the browser's current result set against what we're tracking: drop departed - /// services, resolve newly-seen ones. - private func reconcile(_ results: Set) { - let live = Set(results.map { Self.key($0) }) - for key in resolved.keys where !live.contains(key) { resolved[key] = nil } - for key in connections.keys where !live.contains(key) { - connections[key]?.cancel() - connections[key] = nil + /// A browser that stops working never recovers on its own, and it has two ways to stop: + /// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network + /// permission gate described in the file header). Schedule a re-arm for both, on a backoff: + /// re-arming synchronously on `.failed` alone both missed the permission case entirely and + /// could spin the main queue on a browser that fails instantly every time. + private func browserStateChanged(_ state: NWBrowser.State) { + switch state { + case .ready: + browserFailures = 0 + browserRearmAt = nil + case .failed, .waiting: + guard browserRearmAt == nil else { return } // one re-arm already scheduled + browserFailures += 1 + browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures)) + default: + break // .setup / .cancelled — nothing to heal } + } + + /// Diff the browser's current result set against what we're tracking: drop departed services, + /// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips + /// its pairing policy republishes under the same name and the card follows it — then resolve + /// whatever still needs an address. + private func reconcile(_ results: Set) { + var live: Set = [] for result in results { let key = Self.key(result) - if resolved[key] == nil, connections[key] == nil { resolve(result) } + live.insert(key) + services[key] = result } + for key in Array(services.keys) where !live.contains(key) { forget(key) } publish() + pump() + } + + private func forget(_ key: String) { + connections[key]?.cancel() + connections[key] = nil + deadlines[key] = nil + services[key] = nil + addresses[key] = nil + failures[key] = nil + retryAt[key] = nil + staleAddresses.remove(key) + } + + // MARK: - Resolve + + /// Start the resolves that are due: every live service with no address yet, nothing in flight, + /// and past its retry time. + private func pump() { + let now = Date() + for (key, result) in services { + guard addresses[key] == nil || staleAddresses.contains(key) else { continue } + guard connections[key] == nil else { continue } + if let at = retryAt[key], at > now { continue } + resolve(key, result) + } } /// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the - /// path is established — no data is sent), reading the TXT up front so the callback only - /// captures Sendable values + the endpoint key. - private func resolve(_ result: NWBrowser.Result) { - let key = Self.key(result) - let name = Self.instanceName(result.endpoint) - var fp: String? - var pair: String? - var id: String? - var macs: [String] = [] - var osChain = "" - if case let .bonjour(txt) = result.metadata { - fp = Self.entry(txt, "fp") - pair = Self.entry(txt, "pair") - id = Self.entry(txt, "id") - macs = (Self.entry(txt, "mac") ?? "") - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "") - } + /// path is established — no data is sent). The TXT is NOT read here: it comes from the browse + /// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read. + private func resolve(_ key: String, _ result: NWBrowser.Result) { // 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 @@ -177,44 +328,125 @@ public final class HostDiscovery: ObservableObject { } let conn = NWConnection(to: result.endpoint, using: params) connections[key] = conn + deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout) + let generation = self.generation conn.stateUpdateHandler = { state in MainActor.assumeIsolated { [weak self] in - guard let self, let conn = self.connections[key] else { return } + // Look the connection back up rather than capturing it — capturing it here would + // retain the connection through its own handler. + guard let self, generation == self.generation, + let conn = self.connections[key] else { return } switch state { case .ready: - if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint, - let address = Self.hostString(host) { - self.resolved[key] = DiscoveredHost( - id: (id?.isEmpty == false) ? id! : name, - name: name, host: address, port: port.rawValue, - fingerprintHex: fp, requiresPairing: pair == "required", - allowsTofu: pair == "optional", macAddresses: macs, - osChain: osChain) - self.publish() - } - conn.cancel() + let endpoint = conn.currentPath?.remoteEndpoint self.connections[key] = nil + self.deadlines[key] = nil + conn.cancel() + if case let .hostPort(host, port)? = endpoint, + let address = Self.hostString(host) { + self.addresses[key] = (address, port.rawValue) + self.failures[key] = nil + self.retryAt[key] = nil + self.staleAddresses.remove(key) + self.publish() + } else { + // Ready but no usable remote — a failed attempt, not a finished one. + self.resolveFailed(key) + } case .failed, .cancelled: self.connections[key] = nil + self.deadlines[key] = nil + self.resolveFailed(key) default: - break + break // .preparing / .waiting — the sweep's deadline is what ends these } } } conn.start(queue: .main) } - /// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising - /// collapses to one row), sorted by name. + private func resolveFailed(_ key: String) { + let count = (failures[key] ?? 0) + 1 + failures[key] = count + retryAt[key] = Date().addingTimeInterval(Self.backoff(count)) + } + + // MARK: - Sweep + + private func startSweep() { + sweep?.cancel() + sweep = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled, let self else { return } + self.tick() + } + } + } + + private func tick() { + let now = Date() + // Time out the resolves that parked. Without this they never end, and `pump()` skips a + // service that has a connection in flight — so that host stayed invisible indefinitely. + for key in deadlines.filter({ $0.value <= now }).keys { + connections[key]?.cancel() + connections[key] = nil + deadlines[key] = nil + resolveFailed(key) + } + if let at = browserRearmAt, at <= now { armBrowser() } + pump() + if let until = scanningUntil, until <= now { + scanningUntil = nil + isScanning = false + } + } + + // MARK: - Publish + + /// Publish the live adverts that have an address, deduped by `id` (a host on several + /// interfaces / re-advertising collapses to one row), sorted by name. private func publish() { var byID: [String: DiscoveredHost] = [:] - for host in resolved.values { byID[host.id] = host } + for key in services.keys.sorted() { + guard let result = services[key], let address = addresses[key] else { continue } + let host = Self.host(from: result, address: address.host, port: address.port) + byID[host.id] = host + } let next = byID.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } if next != hosts { hosts = next } } + /// Join a browse result's advert (instance name + TXT) to a resolved address. + private static func host( + from result: NWBrowser.Result, address: String, port: UInt16 + ) -> DiscoveredHost { + let name = instanceName(result.endpoint) + var fp: String? + var pair: String? + var id: String? + var macs: [String] = [] + var osChain = "" + if case let .bonjour(txt) = result.metadata { + fp = entry(txt, "fp") + pair = entry(txt, "pair") + id = entry(txt, "id") + macs = (entry(txt, "mac") ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + osChain = sanitizeOsChain(entry(txt, "os") ?? "") + } + return DiscoveredHost( + id: (id?.isEmpty == false) ? id! : name, + name: name, host: address, port: port, + fingerprintHex: fp, requiresPairing: pair == "required", + allowsTofu: pair == "optional", macAddresses: macs, + osChain: osChain) + } + private static func key(_ result: NWBrowser.Result) -> String { "\(result.endpoint)" } diff --git a/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift b/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift index c8b58346..59db6ff3 100644 --- a/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift @@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase { XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32)) XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect") XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect") + + // A rescan tears the browser down and re-arms it (the only way past the iOS local-network + // permission gate without relaunching). The host must come BACK — `refresh()` cancels every + // in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that + // failed to re-drive them would leave the list permanently empty. + await discovery.rescan() + var reappeared = false + let rescanDeadline = Date().addingTimeInterval(10) + while Date() < rescanDeadline { + if await discovery.hosts.contains(where: { $0.id == uniqueid }) { + reappeared = true + break + } + try await Task.sleep(nanoseconds: 200_000_000) + } + XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising") } } diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index b1da11fd..e40a0894 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -674,6 +674,9 @@ pub struct HostsPage { saved: FactoryVecDeque, discovered: FactoryVecDeque, widgets: PageWidgets, + /// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the + /// browse never started — the button then just re-renders, which is what it did before. + rescan: Option, } struct PageWidgets { @@ -693,6 +696,10 @@ pub enum HostsMsg { }, /// Reload the disk store and re-render (fresh pairings, renames, the library gate). Refresh, + /// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`], + /// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a + /// host that appeared since (or whose announcement was lost) needs an actual query to show up. + Rescan, /// A completed reachability sweep: saved-host key → reachable. Merged into the online pips. Probed(HashMap), /// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores. @@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage { add_host_btn.set_tooltip_text(Some("Add host")); add_host_btn.set_action_name(Some("win.add-host")); header.pack_start(&add_host_btn); + let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic"); + rescan_btn.set_tooltip_text(Some("Scan the network for hosts again")); + { + let sender = sender.clone(); + rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan)); + } + header.pack_start(&rescan_btn); let menu = gio::Menu::new(); menu.append(Some("Preferences"), Some("win.preferences")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); @@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage { } // Stream mDNS adverts into the model; every add/remove re-evaluates both grids. + let (rx, rescan) = discovery::browse(); { - let rx = discovery::browse(); let sender = sender.clone(); glib::spawn_future_local(async move { while let Ok(event) = rx.recv().await { @@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage { disc_heading, searching, }, + rescan: Some(rescan), }; model.rebuild(); @@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage { self.rebuild(); } HostsMsg::Refresh => self.rebuild(), + HostsMsg::Rescan => { + if let Some(rescan) = &self.rescan { + rescan.request(); + } + // Adverts stream in as they answer; re-render now so the local half is current + // either way. + self.rebuild(); + } HostsMsg::Probed(map) => { self.probed = map; self.rebuild(); diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index f078462f..791218b4 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -46,8 +46,13 @@ pub fn wake_and_connect( let sender = sender.clone(); glib::spawn_future_local(async move { use std::time::Duration; - let events = crate::discovery::browse(); + let (events, rescan) = crate::discovery::browse(); let mut wait = WakeWait::new(); + // A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own + // re-query interval has doubled well past a minute by the time a boot finishes — so ask + // again periodically instead of waiting to be told. Every 5th tick: often enough that a + // host that came up is noticed promptly, rare enough not to hammer multicast. + let mut ticks: u32 = 0; loop { if cancel.get() { waiting.close(); @@ -100,6 +105,10 @@ pub fn wake_and_connect( } None => {} } + ticks += 1; + if ticks % 5 == 0 { + rescan.request(); + } glib::timeout_future(Duration::from_secs(1)).await; } }); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index a424d70a..a2a7bdfc 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -343,6 +343,7 @@ impl Service { probe_inflight: Arc::new(AtomicBool::new(false)), last_probe: Instant::now() - Duration::from_secs(60), wake_cancel: None, + rescan: None, } .run(stop_w) }) @@ -373,11 +374,14 @@ struct ServiceState { last_probe: Instant, /// Cancels the active wake thread (it owns the model's wake status). wake_cancel: Option>, + /// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts. + rescan: Option, } impl ServiceState { fn run(mut self, stop: Arc) { - let discovery_rx = discovery::browse(); + let (discovery_rx, rescan) = discovery::browse(); + self.rescan = Some(rescan); while !stop.load(Ordering::SeqCst) { // mDNS churn. while let Ok(ev) = discovery_rx.try_recv() { @@ -512,6 +516,14 @@ impl ServiceState { } ConsoleCmd::Probe => { self.last_probe = Instant::now() - Duration::from_secs(60); + // "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse + // runs for the process's lifetime and `mdns-sd` backs its re-query interval off to + // as much as an hour, so a host that appeared since startup may never be asked + // for again. (No console screen emits Probe yet — every face button on the home + // screen is spoken for — but the plumbing is correct for when one does.) + if let Some(r) = &self.rescan { + r.request(); + } } ConsoleCmd::SetPin { key, diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 7e5a4fde..af2ba382 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -490,9 +490,13 @@ fn wake_and_connect( let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); std::thread::spawn(move || { - let rx = crate::discovery::browse(); + let (rx, rescan) = crate::discovery::browse(); let mut seen: Vec = Vec::new(); let mut wait = WakeWait::new(); + // A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own + // re-query interval has doubled well past a minute by the time a boot finishes — so ask + // again periodically instead of waiting to be told (matches the GTK client's wake wait). + let mut ticks: u32 = 0; loop { // Cancel already returned the UI to the host list — stop re-sending and tear down. if cancel.load(Ordering::SeqCst) { @@ -555,6 +559,10 @@ fn wake_and_connect( } None => {} } + ticks += 1; + if ticks % 5 == 0 { + rescan.request(); + } std::thread::sleep(Duration::from_secs(1)); } }); diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index a27245f4..eb56ba73 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { move || sa.call(true) }) .into()]; + // Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its + // re-query interval off to as much as an hour — so a host that appeared since + // startup, or whose announcement was lost to multicast, may need an actual ask. + actions.push( + icon_btn("Scan the network for hosts again", Symbol::Refresh) + .on_click({ + let (c, st) = (ctx.clone(), set_status.clone()); + move || { + if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() { + r.request(); + } + st.call("Scanning the network\u{2026}".to_string()); + } + }) + .into(), + ); // The couch UI's front door, beside the other page actions. Absent on ARM64, // where the session binary ships without its Skia console. if CONSOLE_UI_AVAILABLE { diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index df1826ef..fbf74786 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -147,6 +147,10 @@ impl PartialEq for Svc { #[derive(Default)] pub(crate) struct Shared { pub(crate) target: Mutex, + /// Forces the app's single LAN browse to re-query — the hosts page's Refresh. Installed by + /// the discovery effect below; `None` until then (and if the browse never started, in which + /// case Refresh is simply inert rather than a second, competing browse). + pub(crate) rescan: Mutex>, /// The live session child (spawn mode) — the status page's Disconnect and the /// request-access Cancel kill it. A FRESH handle is installed per spawn. pub(crate) session: Mutex, @@ -459,8 +463,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { cx.use_effect((), { let set_hosts = set_hosts.clone(); + let ctx = ctx.clone(); move || { - let rx = discovery::browse(); + let (rx, rescan) = discovery::browse(); + *ctx.shared.rescan.lock().unwrap() = Some(rescan); std::thread::spawn(move || { let mut acc: Vec = Vec::new(); while let Ok(h) = rx.recv_blocking() { diff --git a/clients/windows/src/discovery.rs b/clients/windows/src/discovery.rs index c10ab505..afce6dd5 100644 --- a/clients/windows/src/discovery.rs +++ b/clients/windows/src/discovery.rs @@ -3,6 +3,12 @@ //! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform). use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`). +const SERVICE_TYPE: &str = "_punktfunk._udp.local."; #[derive(Clone, Debug, PartialEq)] pub struct DiscoveredHost { @@ -24,10 +30,25 @@ pub struct DiscoveredHost { pub os: String, } -/// Browse continuously for the app's lifetime. The thread exits when the receiver is -/// dropped (the send fails) or the daemon dies. -pub fn browse() -> async_channel::Receiver { +/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors +/// `pf_client_core::discovery::Rescan`; see there for why a client needs one (`mdns-sd` re-queries +/// on a backoff that doubles out to an hour, so a long-lived browse is effectively passive). +#[derive(Clone, Debug)] +pub struct Rescan(Arc); + +impl Rescan { + /// Ask the browse thread to put a fresh query on the wire. Coalesces; returns immediately. + pub fn request(&self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Browse continuously for the app's lifetime, with a handle that forces an immediate re-query. +/// The thread exits when the receiver is dropped (the send fails) or the daemon dies. +pub fn browse() -> (async_channel::Receiver, 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 || { @@ -38,18 +59,45 @@ pub fn browse() -> async_channel::Receiver { return; } }; - let receiver = match daemon.browse("_punktfunk._udp.local.") { + let mut receiver = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "mDNS browse failed — discovery disabled"); return; } }; - while let Ok(event) = receiver.recv() { + loop { + // The worker has to notice that its consumer went away even when NOTHING is + // arriving — the normal state of a LAN with no hosts on it. The old blocking + // `recv()` only ever learned that from a failed send, so a bounded consumer (the + // wake-and-wait below spawns one browse per wake) left this thread and its daemon + // — another thread, and a socket bound to :5353 — running for the app's lifetime. + // Checked at the TOP so the `continue` arms below can't skip it either. + if tx.is_closed() { + break; + } + // Re-browsing the same type replaces the daemon's listener: it replays the cache + // into the new channel, queries immediately, and resets the backoff. + if requested.swap(false, Ordering::Relaxed) { + 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() && receiver.is_empty() => break, + Err(_) => continue, // timed out — go round and look for a rescan request + }; if let ServiceEvent::ServiceResolved(info) = event { 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, like every other client (`pf_client_core::discovery`): the core + // dials `format!("{host}:{port}").parse::()`, which cannot parse a + // bare IPv6 literal, and the host stack binds IPv4 sockets exclusively. Taking + // an arbitrary first address here rendered cards that failed on every click, + // because a host's OS responder commonly answers AAAA for its hostname. + let Some(addr) = info.get_addresses_v4().iter().next().map(|a| a.to_string()) else { continue; }; @@ -85,5 +133,5 @@ pub fn browse() -> async_channel::Receiver { let _ = daemon.shutdown(); }) .expect("spawn mdns thread"); - rx + (rx, Rescan(flag)) } diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 80644027..a8624466 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -245,7 +245,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { fn discover_and_print() { use std::time::{Duration, Instant}; println!("Browsing the LAN for punktfunk hosts (~5 s)…"); - let rx = discovery::browse(); + let (rx, _rescan) = discovery::browse(); let deadline = Instant::now() + Duration::from_secs(5); let mut seen = std::collections::HashSet::new(); while Instant::now() < deadline { diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index 7df42411..4432a6cb 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -5,8 +5,13 @@ 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. @@ -54,10 +59,32 @@ pub enum DiscoveryEvent { Removed { fullname: String }, } -/// Browse continuously. 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 { +/// 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); + +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, 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 || { @@ -68,7 +95,7 @@ pub fn browse() -> async_channel::Receiver { return; } }; - let receiver = match daemon.browse("_punktfunk._udp.local.") { + let mut receiver = match daemon.browse(SERVICE_TYPE) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, "mDNS browse failed — discovery disabled"); @@ -88,6 +115,17 @@ pub fn browse() -> async_channel::Receiver { 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, @@ -147,7 +185,7 @@ pub fn browse() -> async_channel::Receiver { let _ = daemon.shutdown(); }) .expect("spawn mdns thread"); - rx + (rx, Rescan(flag)) } /// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the @@ -174,7 +212,7 @@ fn fold(adverts: &mut Adverts, event: DiscoveryEvent) { /// 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 { - let rx = browse(); + let (rx, _rescan) = browse(); let deadline = Instant::now() + timeout; let mut adverts = Adverts::new(); while Instant::now() < deadline { -- 2.54.0