A cold-booted host advertised 127.0.0.1 and never recovered #366

Merged
enricobuehler merged 3 commits from worktree-fix-frozen-local-ip into main 2026-08-21 12:04:21 +00:00
Owner

Reported by a user: "whenever i restart my pc host adress becomes 127.0.0.1 and i can't connect to the host, i restart the host and adress gets fixed".

Root cause

Host::detect() snapshotted the LAN address once, at process start, and every consumer read that frozen field for the life of the process:

local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),

primary_local_ip() works by connect()ing a UDP socket toward 8.8.8.8 and reading back the local address the OS would route through. On a cold boot the host wins the race against the network — the Windows service is registered ServiceStartType::AutoStart with dependencies: vec![], so it starts alongside the network stack rather than after it — that probe fails with ENETUNREACH, and the loopback fallback sticks. Nothing ever re-read it. Restarting the host re-runs detect() on a live network, which is the workaround the reporter found.

Blast radius

The reporter noticed one symptom; four surfaces broke together, all off that one field:

  • both mDNS adverts (_punktfunk._udp, _nvstream._tcp) published 127.0.0.1 as their A record — the address a client lists and dials;
  • session_url_xml() handed Moonlight rtsp://127.0.0.1:48010 after /launch, so even a manually-added host could not stream;
  • wol::wake_macs() found no interface bearing loopback and dropped the mac TXT record, silently disabling Wake-on-LAN;
  • HostInfo.local_ip reported loopback to the web console.

Patching only the mDNS path would have left the other three broken, so this fixes it where all callers route through.

The fix

  1. primary_local_ip() never returns loopback or the unspecified address. When the route probe fails it falls back to the first non-loopback interface address, which the NIC has as soon as it is configured even if the default route is not installed yet — the usual shape of the boot race. Split out as first_lan_ipv4() so the branch the boot race actually takes is testable.
  2. Host::local_ip is a method that re-reads, not a field that freezes. A connect(2) on an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is serialized into.
  3. mDNS records are pushed, not polled, so a live advert re-registers when the routed address changes (discovery::advertise_live, shared by both service types). It polls the routed address rather than subscribing to the daemon's IpAdd events because the boot race usually resolves without one: the NIC often has its address before we register, and only the route lands late.

This also covers siblings nobody reported — a new DHCP lease, and a host moved between Wi-Fi and Ethernet.

Verification

  • Confirmed empirically that a second register() re-announces on every interface (observed Announce(…:lo0) and Announce(…:en0) from the responder's own monitor() stream after the address change). This matches mdns-sd's documented contract: "To re-announce a service with an updated service_info, just call this register function again. No need to call unregister first."
  • pf-client-core's fold() already lets a refreshed advert win over a known host id ("a host that changed DHCP lease re-announces"), so the client side needed no change.
  • 3 new unit tests; cargo fmt --all --check and scripts/ci/check-docs-drift.sh clean.

Limits of local verification — please let CI be the judge. punktfunk-host does not build on macOS at all for pre-existing unrelated reasons (opus, vdisplay, zerocopy are referenced unconditionally but exist only on Linux/Windows), and the CI builder image was unavailable (Docker/OrbStack would not start on the dev box). The changed code was compiled and tested verbatim in an isolated crate pinned to the same dependency versions — clean under -D warnings, 3 tests passing — but that is not a full-crate build. Cold-client mDNS timing also could not be measured locally, because macOS's own mDNSResponder owns port 5353 and prevents a second responder from receiving multicast.

Notes

  • api/openapi.json was updated by hand for the two changed doc comments (the JSON round-trip produced a 2-line diff, confirming the serialization matches what the generator emits), and docs-site/public/openapi.json re-synced.
  • sdk/src/gen/punktfunk.ts deliberately left alone: it is already stale versus the spec on main (missing whole schemas such as ClientLogMeta/CheckSource) and is regenerated at publish time, so regenerating here would have swept 346 lines of unrelated catch-up into this diff.
Reported by a user: *"whenever i restart my pc host adress becomes 127.0.0.1 and i can't connect to the host, i restart the host and adress gets fixed"*. ## Root cause `Host::detect()` snapshotted the LAN address **once**, at process start, and every consumer read that frozen field for the life of the process: ```rust local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)), ``` `primary_local_ip()` works by `connect()`ing a UDP socket toward `8.8.8.8` and reading back the local address the OS would route through. On a cold boot the host wins the race against the network — the Windows service is registered `ServiceStartType::AutoStart` with `dependencies: vec![]`, so it starts alongside the network stack rather than after it — that probe fails with ENETUNREACH, and the loopback fallback sticks. Nothing ever re-read it. Restarting the host re-runs `detect()` on a live network, which is the workaround the reporter found. ## Blast radius The reporter noticed one symptom; four surfaces broke together, all off that one field: * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1` as their A record — the address a client lists and dials; * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after `/launch`, so even a manually-added host could not stream; * `wol::wake_macs()` found no interface bearing loopback and dropped the `mac` TXT record, silently disabling Wake-on-LAN; * `HostInfo.local_ip` reported loopback to the web console. Patching only the mDNS path would have left the other three broken, so this fixes it where all callers route through. ## The fix 1. **`primary_local_ip()` never returns loopback or the unspecified address.** When the route probe fails it falls back to the first non-loopback interface address, which the NIC has as soon as it is configured even if the default route is not installed yet — the usual shape of the boot race. Split out as `first_lan_ipv4()` so the branch the boot race actually takes is testable. 2. **`Host::local_ip` is a method that re-reads, not a field that freezes.** A `connect(2)` on an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is serialized into. 3. **mDNS records are pushed, not polled**, so a live advert re-registers when the routed address changes (`discovery::advertise_live`, shared by both service types). It polls the routed address rather than subscribing to the daemon's `IpAdd` events because the boot race usually resolves without one: the NIC often has its address before we register, and only the route lands late. This also covers siblings nobody reported — a new DHCP lease, and a host moved between Wi-Fi and Ethernet. ## Verification * Confirmed empirically that a second `register()` re-announces on every interface (observed `Announce(…:lo0)` and `Announce(…:en0)` from the responder's own `monitor()` stream after the address change). This matches mdns-sd's documented contract: *"To re-announce a service with an updated `service_info`, just call this `register` function again. No need to call `unregister` first."* * `pf-client-core`'s `fold()` already lets a refreshed advert win over a known host id ("a host that changed DHCP lease re-announces"), so the client side needed no change. * 3 new unit tests; `cargo fmt --all --check` and `scripts/ci/check-docs-drift.sh` clean. **Limits of local verification — please let CI be the judge.** `punktfunk-host` does not build on macOS at all for pre-existing unrelated reasons (`opus`, `vdisplay`, `zerocopy` are referenced unconditionally but exist only on Linux/Windows), and the CI builder image was unavailable (Docker/OrbStack would not start on the dev box). The changed code was compiled and tested verbatim in an isolated crate pinned to the same dependency versions — clean under `-D warnings`, 3 tests passing — but that is **not** a full-crate build. Cold-client mDNS timing also could not be measured locally, because macOS's own `mDNSResponder` owns port 5353 and prevents a second responder from receiving multicast. ## Notes * `api/openapi.json` was updated by hand for the two changed doc comments (the JSON round-trip produced a 2-line diff, confirming the serialization matches what the generator emits), and `docs-site/public/openapi.json` re-synced. * `sdk/src/gen/punktfunk.ts` deliberately left alone: it is already stale versus the spec on `main` (missing whole schemas such as `ClientLogMeta`/`CheckSource`) and is regenerated at publish time, so regenerating here would have swept 346 lines of unrelated catch-up into this diff.
enricobuehler added 3 commits 2026-08-21 11:56:24 +00:00
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field forever. On a cold boot the host wins the race
against the network — the Windows service is registered `AutoStart` with no
dependencies — so `primary_local_ip()`'s route probe to 8.8.8.8 failed with
ENETUNREACH and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround
users found.

Four surfaces broke together, all off that one field:

  * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
    as their A record — the address a client lists and dials;
  * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
    so even a manually-added host could not stream;
  * `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
    record, silently disabling Wake-on-LAN;
  * `HostInfo.local_ip` reported loopback to the web console.

Fixed at the choke point rather than per-caller:

  * `primary_local_ip()` never returns loopback or the unspecified address. When
    the route probe fails it falls back to the first non-loopback interface
    address, which exists as soon as the NIC is configured even if the default
    route is not installed yet — the common shape of the boot race.
  * `Host::local_ip` becomes a method that re-reads instead of a field that
    freezes. A `connect(2)` on an unconnected UDP socket sends no packets and
    costs nothing beside the HTTP response it is serialized into.
  * mDNS records are pushed, not polled, so a live advert re-registers when the
    routed address changes (`discovery::advertise_live`, shared by both service
    types). It polls the routed address rather than subscribing to the daemon's
    IpAdd events because the boot race usually resolves without one: the NIC
    often has its address before we register and only the route lands late.

This also covers the sibling cases that were never reported — DHCP handing out a
different lease, and a host moved between Wi-Fi and Ethernet.
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
test(host): cover the interface fallback the boot race actually takes
ci / bun-nix (pull_request) Successful in 42s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m48s
ci / rust (pull_request) Successful in 5m30s
android / android (pull_request) Successful in 6m48s
2898f6b049
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
enricobuehler merged commit b05bb1dd48 into main 2026-08-21 12:04:21 +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#366