Skip to content

network: stop polling for absent WiFi, and kick ntpd when an address arrives - #147

Merged
mcfbytes merged 3 commits into
masterfrom
fix/cold-boot-time-sync
Sep 4, 2026
Merged

network: stop polling for absent WiFi, and kick ntpd when an address arrives#147
mcfbytes merged 3 commits into
masterfrom
fix/cold-boot-time-sync

Conversation

@mcfbytes

@mcfbytes mcfbytes commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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. S40network burns a flat 20 s 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, added for async-registering mainline rtw88/rtw89) runs all 20 iterations waiting for a device that will never appear. Measured against an absent interface: 20 s. 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 there is none yet.

ntpd heals one half of that and not the other:

  • interface half — fine. HAVE_RTNETLINK is set (config.h:786), so an address appearing schedules an endpoint rescan (ntp_io.c:4874) and ntp_io.c:1980ntp_peer.c:763 refresh_all_peerinterfaces reattaches peers within ~3 s.
  • DNS half — not. For a server <hostname> line the peer is created only in the DNS callback (ntp_config.c:4459-4463peer_name_resolved). 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 301 s 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-64 s (config.h:1313 leaves IGNORE_DNS_ERRORS undefined → DNSFLAGS 0 → retmax 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 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-kick dhcpcd hook 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-15 s.

The two fixes belong together: removing the 20 s stall moves S49ntp 20 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. 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 ~10 s 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 rather than implying it works.

Hook contract, 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 locally. 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.

shellcheck clean (test-ntp-kick.sh, ci-tests.sh, check-linux-img.sh); all three pre-up commands re-checked under sh -n.

No Kconfig symbol changes, so configs/fragments/golden.sha256 is untouched.

Docs

init-parity.md gains a 91-ntp-kick row and corrects the pinned-hook-set row to say 50-ntp.conf is inert. Both parity docs' "exactly 11 added lines" claims for etc/network/interfaces are now 47 with zero removals (recounted against c196569, which was byte-identical to stock, since work/imgroot isn't extracted here). wifi-parity.md §9 records that %k fixed 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/fat is the better answer to that and belongs in its own change; #146 says so.

Verify on hardware before merge

Boot with a slow-enumerating rtw88 dongle and confirm the udev path alone brings it up, now that S40network no 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

…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
Copilot AI lite review requested due to automatic review settings September 3, 2026 19:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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/wlan1 stanzas to avoid the 20s iw dev ... polling loop when the interface doesn’t exist.
  • Add a dhcpcd hook (91-ntp-kick) to restart ntpd once 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 iburst retention.
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.

Comment thread board/mister/de10nano/rootfs-overlay/usr/lib/dhcpcd/dhcpcd-hooks/91-ntp-kick Outdated
Comment thread scripts/ci-tests.sh Outdated
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread board/mister/de10nano/rootfs-overlay/usr/lib/dhcpcd/dhcpcd-hooks/91-ntp-kick Outdated
Comment thread docs/init-parity.md
Comment thread docs/wifi-parity.md Outdated
Comment thread docs/wifi-parity.md
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
@mcfbytes
mcfbytes merged commit 51dabf6 into master Sep 4, 2026
6 checks passed
@mcfbytes
mcfbytes deleted the fix/cold-boot-time-sync branch September 4, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cold-boot clock sync is slow, especially on WiFi: 20s dead wait in S40network plus ntpd's DNS retry backoff

2 participants