network: stop polling for absent WiFi, and kick ntpd when an address arrives - #147
Conversation
…arrives Cold-boot time sync is slow, and on WiFi it is slow for two independent reasons that compound. Closes #146. 1. S40network burns a flat 20s on every WiFi boot. It runs a literal `ifup -a`, which processes EVERY `auto` stanza -- including `auto wlan1`. On the ordinary single-dongle box `iw dev wlan1 info` never succeeds, so the v9 pre-up poll (4cf2fc7 / ADR 0016, for async-registering mainline rtw88/rtw89) runs all 20 iterations waiting for a device that will never appear. Measured against an absent interface: 20s. rcS is serial, so S41dhcpcd, S49ntp, S50sshd and Main_MiSTer all queue behind it. docs/wifi-parity.md §9 already found this hazard and fixed it for the HOTPLUG caller -- that is why 70-persistent-net.rules targets %k instead of stock's `-a`. The boot path never got the same treatment. Fixed with a `pre-up [ -e /sys/class/net/$IFACE ]` guard ahead of the loop in each wlan stanza. It costs no coverage: a device that is merely late is brought up by its own "add" uevent via that same udev rule, which fires on udev's boot-time coldplug as well as on later insertion. The v9 loop stays behind the guard for the narrow case it was written for -- netdev present, nl80211 not yet ready. 2. ntpd starts before there is any DNS, and only its retry backoff gets it out. S49ntp launches `ntpd -g` at a fixed point in rcS whether or not a network exists; on WiFi, association plus DHCP normally completes well after S49, so there is no /etc/resolv.conf yet (20-resolv.conf writes it on BOUND). ntpd heals one half of that and not the other. The INTERFACE half is fine: HAVE_RTNETLINK is set (config.h:786), so an address appearing schedules an endpoint rescan (ntp_io.c:4874) and ntp_io.c:1980 -> ntp_peer.c:763 refresh_all_peerinterfaces reattaches peers within ~3s. The DNS half is not: for a `server <hostname>` line the peer is created ONLY in the DNS callback (ntp_config.c:4459-4463 -> peer_name_resolved), so with resolution failing NO PEERS EXIST for that rescan to reattach, and peer_xmit returns early on !peer->dstadr (ntp_proto.c:4157) regardless. The 301s endpt_scan_period is a red herring; it never bites us. So the clock waits on the DNS retry backoff alone. libntp/ntp_intres.c manage_dns_retry_interval walks 2-3-4-6-8-12-16-24-32- 48-64s -- config.h:1313 leaves IGNORE_DNS_ERRORS undefined, so DNSFLAGS is 0 and retmax is 64, not 1024 -- putting attempts at t = 2, 5, 9, 15, 23, 35, 51, 75, 107, 155, 219, 283 ... s after ntpd started, then every 64s. The wait is the next slot after the address arrives: a few seconds wired, 10-20s on a typical WiFi boot, 30s to minutes when the network turns up late. New dhcpcd hook 91-ntp-kick restarts ntpd once, when an address actually arrives, so it re-resolves against the resolv.conf that now exists. /etc/ntp.conf's iburst and S49ntp's -g then give correct time within ~10-15s. A dhcpcd hook and not an init script or a udev rule, for the reason 90-timezone's header already gives: the event we want is "this box now has an address", and dhcpcd publishes exactly that. A net "add" uevent fires when the netdev is CREATED -- before association, lease, route or resolv.conf -- so kicking a DNS-dependent daemon there reproduces the bug one layer down. Not sntp: it would reach correct time ~10s sooner, but BR2_PACKAGE_NTP_SNTP also installs Buildroot's S48sntp (package/ntp/ntp.mk:110-114), which runs sntp at boot one script BEFORE ntpd with no network, and would have to be suppressed. Not worth a package symbol plus a post-build prune. Not service_condcommand, which 50-ntp.conf uses: dhcpcd's detect_init finds no systemctl/rc-service/invoke-rc.d/service/sv here and falls through to its /etc/init.d branch, which tests `[ -x /etc/init.d/ntpd ]` -- ours is S49ntp -- and service_status then runs `$x/$1 status`, a verb S49ntp has no case for. Both fail silently. The hook calls the script directly. (This is also why the pinned 50-ntp.conf is inert here, alongside etc/dhcpcd.conf leaving `option ntp_servers` commented out per stock; init-parity.md now says so.) The two fixes belong together: removing the 20s stall moves S49ntp 20s EARLIER while association does not move, so the gap between ntpd starting and the lease landing grows and ntpd lands deeper in its backoff. Hook design, all asserted by the new harness: fires at most once per boot (mkdir /run/ntp-kick is the atomic test-and-set, on a tmpfs, so it re-arms each boot -- RENEW recurs for the life of the session and restarting ntpd each time would discard its accumulated clock discipline); never starts an ntpd that is not running, so a by-hand or /etc/default/ntpd disable stands; and the stamp is claimed LAST, after every gate, so a pass that bails out leaves the one kick available. That last property is what makes the wired-box ordering case correct -- S41dhcpcd blocks until the first lease, so the hook fires before S49ntp has started ntpd at all. Backgrounded subshell (S49ntp restart contains a sleep 1), sourced not executed: no shebang, no exec bit, no exit. A static-IP box never runs dhcpcd and never gets kicked. Accepted -- the same gap ADR 0025 accepts -- and ntpd's own backoff still converges there. Tests: scripts/test-ntp-kick.sh, 29 sandboxed cases, no build/board/network needed. Wired into ci-tests.sh twice, host shell and the target's own BusyBox ash under qemu-arm (both 29/29 here). Mutation-checked: dropping the once-per-boot gate fails 2 cases, dropping the liveness check fails 2 others. ci-tests.sh also asserts the hook and S49ntp ship, and that /etc/ntp.conf keeps iburst -- without it the restart converges in minutes, not seconds, so a package bump could quietly undo the point of this change. check-linux-img.sh's pinned dhcpcd hook set gains 91-ntp-kick. Docs: init-parity.md gains a 91-ntp-kick row and corrects the pinned-hook-set row to say 50-ntp.conf is inert; init-parity/wifi-parity's "exactly 11 added lines" claims for etc/network/interfaces are now 47, zero removals (recounted against c196569, which was byte-identical to stock); wifi-parity §9 records that %k fixed only the hotplug caller and the boot path needed its own guard. Not addressed, deliberately: the clock still reads 1970 from power-on until the first sync. fake-hwclock-style persistence to /media/fat is the better answer to that and belongs in its own change; #146 says so. Verify-on-hardware: boot with a slow-enumerating rtw88 dongle and confirm the udev path alone brings it up now that S40network no longer waits for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SBpBTbtBsFwjAgKCx5T9S
There was a problem hiding this comment.
🟡 Changes recommended
The new 91-ntp-kick hook can unintentionally restart ntpd on a later DHCP RENEW (e.g., wired ordering case) and the new iburst CI assertion is currently too weak to enforce its stated invariant.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR improves cold-boot time synchronization behavior (especially on WiFi) by preventing ifup -a from stalling on absent WiFi interfaces and by re-triggering ntpd DNS resolution once an address is actually available.
Changes:
- Add a device-presence guard to
wlan0/wlan1stanzas to avoid the 20siw dev ...polling loop when the interface doesn’t exist. - Add a
dhcpcdhook (91-ntp-kick) to restartntpdonce when an address is acquired (to break out of DNS retry backoff). - Add/extend CI coverage: a dedicated sandboxed test harness for the hook, plus image assertions for hook presence and
iburstretention.
File summaries
| File | Description |
|---|---|
| scripts/test-ntp-kick.sh | Adds a sandboxed functional test suite for the new 91-ntp-kick hook behavior. |
| scripts/ci-tests.sh | Wires the new hook test into CI and asserts iburst remains present in /etc/ntp.conf. |
| scripts/check-linux-img.sh | Updates the pinned dhcpcd hook-set assertion to include 91-ntp-kick. |
| docs/wifi-parity.md | Updates parity documentation to reflect the revised WiFi bring-up behavior and rationale. |
| docs/init-parity.md | Documents the new hook and the revised /etc/network/interfaces divergence details. |
| board/mister/de10nano/rootfs-overlay/usr/lib/dhcpcd/dhcpcd-hooks/91-ntp-kick | Introduces the new dhcpcd hook that restarts ntpd once after an address arrives. |
| board/mister/de10nano/rootfs-overlay/etc/network/interfaces | Adds a /sys/class/net/$IFACE presence guard before the existing iw dev polling loop. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Both findings from the Copilot review of #147. Both were real. 1. RENEW/REBIND could restart a long-synchronised ntpd. The reason set was copied from 90-timezone (BOUND|REBOOT|REBIND|RENEW + *6), which is right for a hook gated on a persistent file it writes once, and wrong here: this hook's guard is deliberately re-armable, so a pass that bails out leaves the kick available for any later matching event. Match a renewal and that "later event" includes one arriving hours into the session, against an ntpd that has been synchronised the whole time -- discarding its accumulated clock discipline for nothing. The wired ordering case makes it reachable rather than theoretical: it always bails out first (S41dhcpcd blocks until the first lease, so the hook fires before S49ntp has started ntpd), leaving the kick armed for exactly such an event. Narrowed to BOUND|REBOOT|BOUND6|REBOOT6. The review suggested keeping REBIND; dhcpcd's own selection logic says otherwise -- src/dhcp.c:2499-2513 picks RENEW *and* REBIND only when `state->old` is non-NULL, i.e. the address was already there, differing just in whether the address or server changed. Neither is an address ARRIVING. BOUND is a new lease and REBOOT is a cached lease confirmed at startup (DHS_REBOOT); those two are the acquisitions. Nothing is lost by narrowing: every case this hook was written for -- WiFi associating minutes into a boot, a dongle plugged in later, an AP that comes back after the box is up -- is a new lease, hence BOUND. And an address that changes under a RENEW/REBIND needs no kick anyway, since ntpd holds peers by then and the HAVE_RTNETLINK path reattaches them within ~3s. 2. The iburst assertion could pass with iburst on only some server lines. `grep -qE '^server .*iburst'` matched if ANY line carried it, while the message claimed all of them. A bump dropping iburst from two of the four pool lines would have gone green, leaving ntpd at minpoll for those -- the exact regression the check exists to catch. Now counts `^server` lines against those carrying iburst and requires equal and non-zero, reporting n/total either way. Verified against the real /etc/ntp.conf (4/4 pass), one line stripped (3/4 fail), all stripped (0/4 fail), no server lines at all (fails with its own message), and iburst followed by another option (pass). Tests: 29 -> 34 cases, all passing on the host shell and on the target's own BusyBox ash under qemu-arm. New cases cover REBOOT as an acquisition, the renewal-after-bailout scenario end to end, and RENEW/REBIND as no-ops with ntpd up and the stamp unspent. Mutation-checked: restoring RENEW|REBIND to the case list fails 5 of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SBpBTbtBsFwjAgKCx5T9S
There was a problem hiding this comment.
🟡 Changes recommended
PID liveness checking can target an unrelated process, and several documented line counts are incorrect.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 4
- Review effort level: Balanced
Second Copilot pass on #147. One of four comments was a real bug; the other three were a line count that is wrong. REAL: `kill -0` proved only that SOME process existed, not that it was ntpd. The pidfile cannot outlive a boot (/var/run -> /run, tmpfs), but a crashed ntpd within one boot leaves one behind, and a long-running box can wrap pid_max and reuse that pid for something unrelated. We would then "restart ntpd" -- and S49ntp's stop() is stock's, `start-stop-daemon -K -p "$PIDFILE"` with no -x, so it would SIGTERM that unrelated process before starting a daemon that was not running. This hook is the first automatic caller of that script (50-ntp.conf's service_condcommand never fires here), so the exposure is ours to avoid. Replaced with `start-stop-daemon -K -t -q -p "$NTPD_PID" -x /usr/sbin/ntpd`, which does liveness and identity in one probe, rather than the /proc/PID/exe read the review suggested -- it reuses a primitive already validated on both BusyBox vintages and needs no /proc parsing. Measured against a live decoy on ours (1.38.0) and stock's (1.33.1, from Linux_Image_creator_MiSTer's rootfs.tar.bz2 under qemu-arm), identically on both: no -x -> 0, `-x /usr/sbin/ntpd` -> 1, decoy's own name -> 0. The match is by NAME, not resolved path (a decoy whose exe was /usr/lib/.../sleep still matched -x /usr/bin/sleep), which is ample: the case being excluded is a reused pid that is not ntpd. Also real, and called out in the same comment: pid 0 was accepted. `kill -0 0` signals the whole process GROUP, and the start-stop-daemon probe was measured to SUCCEED on a pidfile containing 0 -- so the probe will not reject it and the hook has to. Now rejected explicitly alongside empty and non-numeric. Tests 34 -> 38. The two new pidfile states run REAL processes so identity is exercised rather than mocked: bash copies named `ntpd` and `notntpd` (bash because coreutils and busybox dispatch on argv[0] and refuse to run renamed; the trailing `:` in `-c 'sleep 60; :'` defeats bash's exec optimisation, which would otherwise leave comm as "sleep"). start-stop-daemon is stubbed in the sandbox, encoding exactly the semantics measured on both real binaries -- including the pid-0 quirk and the not-gated-on-`-q` stderr warning -- because the harness also runs under target busybox ash via qemu and nesting qemu is not portable. Mutation-checked: dropping -x fails 2 cases, dropping the 0 rejection fails 2 others. 38/38 on the host shell and on target BusyBox ash. NOT CHANGED: the three line-count comments. They ask for 46 added lines in etc/network/interfaces; the measured value is 47, and diffing the new file against c196569 (P2.3, byte-identical to stock) enumerates them -- 43 comment lines plus 4 code lines, 0 removals. The review's "42 comment lines (3-44)" omits file line 2, a bare `#`, which is genuinely added: stock's banner on line 1 is followed immediately by a blank line. Changing 47 to 46 would make three documents wrong, so they stand. The one valid half of that group is fixed: wifi-parity.md's cross-reference to docs/init-parity.md:147 was stale, and the row is now at :150. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SBpBTbtBsFwjAgKCx5T9S
Closes #146, which carries the full causal chain and the forum report it came from.
Two independent causes of slow cold-boot time sync, which compound on WiFi.
1.
S40networkburns a flat 20 s on every WiFi bootIt runs a literal
ifup -a, which processes everyautostanza — includingauto wlan1. On the ordinary single-dongle boxiw dev wlan1 infonever succeeds, so the v9pre-uppoll (4cf2fc7 / ADR 0016, added for async-registering mainlinertw88/rtw89) runs all 20 iterations waiting for a device that will never appear. Measured against an absent interface: 20 s.rcSis serial, soS41dhcpcd,S49ntp,S50sshdand Main_MiSTer all queue behind it.docs/wifi-parity.md§9 already found this hazard and fixed it for the hotplug caller — that is why70-persistent-net.rulestargets%kinstead of stock's-a. The boot path never got the same treatment.Fixed with a
pre-up [ -e /sys/class/net/$IFACE ]guard ahead of the loop in eachwlanstanza. It costs no coverage: a device that is merely late is brought up by its ownadduevent via that same udev rule, which fires on udev's boot-time coldplug as well as on later insertion. The v9 loop stays behind the guard for the narrow case it was written for — netdev present,nl80211not yet ready.2. ntpd starts before there is any DNS, and only its retry backoff gets it out
S49ntplaunchesntpd -gat a fixed point inrcSwhether or not a network exists. On WiFi there is none yet.ntpd heals one half of that and not the other:
HAVE_RTNETLINKis set (config.h:786), so an address appearing schedules an endpoint rescan (ntp_io.c:4874) andntp_io.c:1980→ntp_peer.c:763refresh_all_peerinterfacesreattaches peers within ~3 s.server <hostname>line the peer is created only in the DNS callback (ntp_config.c:4459-4463→peer_name_resolved). With resolution failing, no peers exist for that rescan to reattach, andpeer_xmitreturns early on!peer->dstadr(ntp_proto.c:4157) regardless. The 301 sendpt_scan_periodis a red herring; it never bites us.So the clock waits on the DNS retry backoff alone.
libntp/ntp_intres.cmanage_dns_retry_intervalwalks 2-3-4-6-8-12-16-24-32-48-64 s (config.h:1313leavesIGNORE_DNS_ERRORSundefined →DNSFLAGS0 →retmax64, not 1024), putting attempts att = 2, 5, 9, 15, 23, 35, 51, 75, 107, 155, 219, 283 …s after ntpd started, then every 64 s. The wait is the next slot after the address arrives: a few seconds wired, 10-20 s on a typical WiFi boot, 30 s to minutes when the network turns up late.New
91-ntp-kickdhcpcd hook restarts ntpd once, when an address actually arrives, so it re-resolves against theresolv.confthat now exists./etc/ntp.conf'siburstandS49ntp's-gthen give correct time within ~10-15 s.The two fixes belong together: removing the 20 s stall moves
S49ntp20 s earlier while association does not move, so the gap between ntpd starting and the lease landing grows and ntpd lands deeper in its backoff.Design notes
A dhcpcd hook, not an init script or a udev rule, for the reason
90-timezone's header already gives: the event we want is "this box now has an address", and dhcpcd publishes exactly that. Anetadduevent fires when the netdev is created — before association, lease, route orresolv.conf— so kicking a DNS-dependent daemon there reproduces the bug one layer down.Not
sntp. It would reach correct time ~10 s sooner, butBR2_PACKAGE_NTP_SNTPalso installs Buildroot'sS48sntp(package/ntp/ntp.mk:110-114), which runs sntp at boot one script before ntpd with no network, and would have to be suppressed. Not worth a package symbol plus a post-build prune.Not
service_condcommand, which50-ntp.confuses: dhcpcd'sdetect_initfinds no systemctl/rc-service/invoke-rc.d/service/sv here and falls through to its/etc/init.dbranch, which tests[ -x /etc/init.d/ntpd ]— ours isS49ntp— andservice_statusthen runs$x/$1 status, a verbS49ntphas no case for. Both fail silently. The hook calls the script directly. This is also why the pinned50-ntp.confis inert here, alongsideetc/dhcpcd.confleavingoption ntp_serverscommented out per stock;init-parity.mdnow says so rather than implying it works.Hook contract, all asserted by the new harness: fires at most once per boot (
mkdir /run/ntp-kickis the atomic test-and-set, on a tmpfs, so it re-arms each boot —RENEWrecurs for the life of the session and restarting ntpd each time would discard its accumulated clock discipline); never starts an ntpd that is not running, so a by-hand or/etc/default/ntpddisable stands; and the stamp is claimed last, after every gate, so a pass that bails out leaves the one kick available. That last property is what makes the wired-box ordering case correct —S41dhcpcdblocks until the first lease, so the hook fires beforeS49ntphas started ntpd at all. Backgrounded subshell (S49ntp restartcontains asleep 1), sourced not executed: no shebang, no exec bit, noexit.A static-IP box never runs dhcpcd and never gets kicked. Accepted — the same gap ADR 0025 accepts — and ntpd's own backoff still converges there.
Tests
scripts/test-ntp-kick.sh, 29 sandboxed cases, no build/board/network needed. Wired intoci-tests.shtwice — host shell, and the target's own BusyBox ash under qemu-arm. Both 29/29 locally. Mutation-checked: dropping the once-per-boot gate fails 2 cases, dropping the liveness check fails 2 others.ci-tests.shalso asserts the hook andS49ntpship, and that/etc/ntp.confkeepsiburst— without it the restart converges in minutes, not seconds, so a package bump could quietly undo the point of this change.check-linux-img.sh's pinned dhcpcd hook set gains91-ntp-kick.shellcheck clean (
test-ntp-kick.sh,ci-tests.sh,check-linux-img.sh); all threepre-upcommands re-checked undersh -n.No Kconfig symbol changes, so
configs/fragments/golden.sha256is untouched.Docs
init-parity.mdgains a91-ntp-kickrow and corrects the pinned-hook-set row to say50-ntp.confis inert. Both parity docs' "exactly 11 added lines" claims foretc/network/interfacesare now 47 with zero removals (recounted againstc196569, which was byte-identical to stock, sincework/imgrootisn't extracted here).wifi-parity.md§9 records that%kfixed only the hotplug caller and that the boot path needed its own guard.Not addressed, deliberately
The clock still reads 1970 from power-on until the first sync.
fake-hwclock-style persistence to/media/fatis the better answer to that and belongs in its own change; #146 says so.Verify on hardware before merge
Boot with a slow-enumerating
rtw88dongle and confirm the udev path alone brings it up, now thatS40networkno longer waits for it.4cf2fc7's hardware test ("RTL8822BU auto-connects WPA3 5GHz at boot") predates the udev rule, so it does not cover this case.🤖 Generated with Claude Code
https://claude.ai/code/session_014SBpBTbtBsFwjAgKCx5T9S