TLDR: millis() deadline comparisons wrap at 49.7 days.
Draft rollup PR here: #11291
millis() returns a uint32_t that wraps every ~49.7 days. The firmware mostly handles this correctly — Throttle::isWithinTimespanMs() uses unsigned elapsed-since arithmetic and is used at 94 call sites, and the OSThread scheduler is rollover-safe inside the vendored ArduinoThread library. The problem is roughly 17 places that bypass both, storing a future deadline and comparing it directly against millis(). Those silently misbehave across the wrap: pending work either stalls until the counter catches up, or the whole queue fires at once.
Routers and infrastructure nodes stay up for months, so this is reachable in normal operation. The affected sites include reboot and shutdown scheduling (Power.cpp), the 12-hour Ethernet NTP renew (ethClient.cpp), GPS fix-hold and timeouts, retransmission timing (NextHopRouter.cpp), notification banners, and several touch-suppression windows. The same feature sometimes exists twice with only one version correct: WiFi NTP renewal uses Throttle properly, while the Ethernet path hand-rolls a deadline and is broken.
This is an adoption problem, not a design problem — the correct helper already exists and dominates usage. One hazard blocks a mechanical fix: several of these variables overload their value with a sentinel (0 for inactive, -1/UINT32_MAX in Power.cpp and ExternalNotificationModule.cpp). Under today's naive comparison -1 reads as "never fire"; under any corrected comparison it becomes "expired 49 days ago", causing a reboot loop. The sentinels must be untangled first. A CI guard rejecting new millis() > deadline comparisons would stop the list growing again.
Open PRs
TLDR:
millis()deadline comparisons wrap at 49.7 days.Draft rollup PR here: #11291
millis()returns auint32_tthat wraps every ~49.7 days. The firmware mostly handles this correctly —Throttle::isWithinTimespanMs()uses unsigned elapsed-since arithmetic and is used at 94 call sites, and theOSThreadscheduler is rollover-safe inside the vendored ArduinoThread library. The problem is roughly 17 places that bypass both, storing a future deadline and comparing it directly againstmillis(). Those silently misbehave across the wrap: pending work either stalls until the counter catches up, or the whole queue fires at once.Routers and infrastructure nodes stay up for months, so this is reachable in normal operation. The affected sites include reboot and shutdown scheduling (
Power.cpp), the 12-hour Ethernet NTP renew (ethClient.cpp), GPS fix-hold and timeouts, retransmission timing (NextHopRouter.cpp), notification banners, and several touch-suppression windows. The same feature sometimes exists twice with only one version correct: WiFi NTP renewal usesThrottleproperly, while the Ethernet path hand-rolls a deadline and is broken.This is an adoption problem, not a design problem — the correct helper already exists and dominates usage. One hazard blocks a mechanical fix: several of these variables overload their value with a sentinel (
0for inactive,-1/UINT32_MAXinPower.cppandExternalNotificationModule.cpp). Under today's naive comparison-1reads as "never fire"; under any corrected comparison it becomes "expired 49 days ago", causing a reboot loop. The sentinels must be untangled first. A CI guard rejecting newmillis() >deadline comparisons would stop the list growing again.Open PRs
NextHopRouter: fix 49.7-day millis() rollover in retransmission timing #10227
Fixes one of the ~17 sites (
NextHopRouterretransmission timing) with a signed-delta compare; correct but narrow, currently needs a rebase.Power airtime monotonic windows due to light sleep. #10582
Rewrites airtime windows to advance from monotonic uptime; the implementation model appears very good - we should probably adopt this approach.
fix(airtime): set secSinceBoot from millis() to address inaccurate airtime calculations #9778
Competing fix for the same airtime bug in the same file as Power airtime monotonic windows due to light sleep. #10582 — they cannot both merge, and this one regresses
secSinceBootinto a 49.7-day wrap by assigningmillis() / 1000directly. This may be dropped - sorry @m1nl.