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:
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
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.
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.
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.
`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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:primary_local_ip()works byconnect()ing a UDP socket toward8.8.8.8and 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 registeredServiceStartType::AutoStartwithdependencies: 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-runsdetect()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:
_punktfunk._udp,_nvstream._tcp) published127.0.0.1as their A record — the address a client lists and dials;session_url_xml()handed Moonlightrtsp://127.0.0.1:48010after/launch, so even a manually-added host could not stream;wol::wake_macs()found no interface bearing loopback and dropped themacTXT record, silently disabling Wake-on-LAN;HostInfo.local_ipreported 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
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 asfirst_lan_ipv4()so the branch the boot race actually takes is testable.Host::local_ipis a method that re-reads, not a field that freezes. Aconnect(2)on an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is serialized into.discovery::advertise_live, shared by both service types). It polls the routed address rather than subscribing to the daemon'sIpAddevents 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
register()re-announces on every interface (observedAnnounce(…:lo0)andAnnounce(…:en0)from the responder's ownmonitor()stream after the address change). This matches mdns-sd's documented contract: "To re-announce a service with an updatedservice_info, just call thisregisterfunction again. No need to callunregisterfirst."pf-client-core'sfold()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.cargo fmt --all --checkandscripts/ci/check-docs-drift.shclean.Limits of local verification — please let CI be the judge.
punktfunk-hostdoes not build on macOS at all for pre-existing unrelated reasons (opus,vdisplay,zerocopyare 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 ownmDNSResponderowns port 5353 and prevents a second responder from receiving multicast.Notes
api/openapi.jsonwas 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), anddocs-site/public/openapi.jsonre-synced.sdk/src/gen/punktfunk.tsdeliberately left alone: it is already stale versus the spec onmain(missing whole schemas such asClientLogMeta/CheckSource) and is regenerated at publish time, so regenerating here would have swept 346 lines of unrelated catch-up into this diff.`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.