fix(clients): host discovery heals itself, and every client can rescan #67

Merged
enricobuehler merged 1 commits from worktree-host-discovery-refresh into main 2026-08-06 11:51:31 +00:00
Owner

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.

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.
enricobuehler added 1 commit 2026-08-06 11:43:20 +00:00
fix(clients): host discovery heals itself, and every client can rescan
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
b25e6eda91
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.
enricobuehler merged commit fbdad8d917 into main 2026-08-06 11:51:31 +00:00
enricobuehler deleted branch worktree-host-discovery-refresh 2026-08-06 11:51:33 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#67