Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/HostDiscoveryTests.swift
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

71 lines
3.2 KiB
Swift

// Advertise a fake punktfunk/1 host over real mDNS (NWListener) and assert HostDiscovery's
// NWBrowser finds it, resolves an address+port, and parses the TXT (id / pair / fp). This
// exercises the whole client discovery path on the loopback/LAN; it self-skips if the test
// environment blocks Bonjour (sandboxed CI without local-network access).
import Network
import PunktfunkKit
import XCTest
final class HostDiscoveryTests: XCTestCase {
func testFindsAdvertisedHost() async throws {
let serviceName = "PunktfunkTest-\(UUID().uuidString.prefix(8))"
let uniqueid = "test-\(UUID().uuidString)"
var txt = NWTXTRecord()
txt["proto"] = "punktfunk/1"
txt["fp"] = String(repeating: "ab", count: 32) // 64 hex chars, like a real cert SHA-256
txt["pair"] = "required"
txt["id"] = uniqueid
let listener = try NWListener(using: .udp)
listener.service = NWListener.Service(
name: String(serviceName), type: "_punktfunk._udp", txtRecord: txt)
// The resolver opens a throwaway UDP flow to read the resolved endpoint — accept and
// drop it so it doesn't linger.
listener.newConnectionHandler = { connection in connection.cancel() }
listener.start(queue: .global())
defer { listener.cancel() }
let discovery = await HostDiscovery()
await discovery.start()
defer { Task { await discovery.stop() } }
// Poll up to ~10s for the advert to be browsed AND resolved.
var found: DiscoveredHost?
let deadline = Date().addingTimeInterval(10)
while Date() < deadline {
if let host = await discovery.hosts.first(where: { $0.name == String(serviceName) }) {
found = host
break
}
try await Task.sleep(nanoseconds: 200_000_000)
}
guard let host = found else {
throw XCTSkip("mDNS discovery unavailable in this environment (no local network).")
}
XCTAssertEqual(host.id, uniqueid, "the stable mDNS id should key the host")
XCTAssertTrue(host.requiresPairing, "pair=required must surface as requiresPairing")
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")
}
}