Fix millis() rollover in deadline, interval, and timestamp handling - #11291
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR adds wrap-safe time helpers, monotonic uptime publication, updated airtime and timestamp handling, migrated deadline checks, native tests, CI enforcement, and timing guidance. ChangesRollover-safe time handling
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Feature
participant Throttle
participant Time
Feature->>Throttle: request elapsed or deadline status
Throttle->>Time: read current time
Time-->>Throttle: wrap-safe clock value
Throttle-->>Feature: timing result
sequenceDiagram
participant Radio
participant NodeDB
participant RTC
Radio->>NodeDB: record uptime placeholder
RTC->>NodeDB: signal trusted clock
NodeDB->>NodeDB: backfill last_heard epoch
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@RCGV1 here's my time PR |
⚡ Try this PR in the Web FlasherWarning This is an automated, unreviewed CI test build. Back up your device configuration Supported boards built by this PR (14)
Build artifacts expire on 2026-08-30. Updated for |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (2)
src/airtime.cpp (1)
78-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDrop the unused
lastUtilPeriodmarkers.
lastUtilPeriodandlastUtilPeriodTXare only assigned inAirTime::syncNow()and never read elsewhere. Remove the declarations fromsrc/airtime.hand the assignments atsrc/airtime.cpp:78-79and134, 145.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/airtime.cpp` around lines 78 - 79, Remove the unused lastUtilPeriod and lastUtilPeriodTX member declarations from AirTime, and delete every assignment to them in AirTime::syncNow() and the other referenced code paths, including the assignments near lines 78–79, 134, and 145. Leave the surrounding synchronization logic unchanged.src/modules/Telemetry/Sensor/BME680Sensor.cpp (1)
172-172: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the injected clock for
lastStateSaveMs.
millis()bypasses the injectable timing path, making this checkpoint uncontrolled byTime::setTestMillis().As per coding guidelines, use the repository timing abstraction so timing can be injected and tested with
Time::setTestMillis().🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/modules/Telemetry/Sensor/BME680Sensor.cpp` at line 172, Replace the direct millis() assignment in the lastStateSaveMs update with the repository’s injected timing abstraction, using the same Time-based API supported by Time::setTestMillis(). Preserve the checkpoint assignment behavior while ensuring tests can control the recorded timestamp.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/test_native.yml:
- Around line 74-96: Update the shell options at the start of the workflow run
block to enable errexit alongside nounset and pipefail, matching the sibling
suite-count-check job. Ensure failures in the find/xargs/awk scan terminate the
job instead of allowing partial output to be reported as a successful scan;
preserve the existing intentional non-zero handling later in the block.
In `@src/airtime.cpp`:
- Around line 60-146: Guard all shared AirTime state with a concurrency::Lock:
include concurrency/Lock.h, add a lock member to AirTime, and acquire it across
syncNow(), logAirtime(), all bucket/utilization getters, and airtimeReport()
paths. Ensure airtimeReport() keeps the lock while constructing or copying any
returned report data so pointers cannot outlive protected storage, and preserve
existing accounting behavior.
In `@src/mesh/NextHopRouter.cpp`:
- Around line 408-417: Add a snapshot-aware Throttle helper in
src/mesh/Throttle.h that accepts now and deadline and performs the unsigned
half-range due-time comparison. Update the retransmission check in NextHopRouter
to call this helper instead of the inline expression, and remove the
now-redundant explanatory comment while preserving wraparound-safe behavior.
In `@src/modules/Telemetry/Sensor/BME680Sensor.cpp`:
- Around line 167-172: Update the state-save flow around Throttle::hasElapsed so
lastStateSaveMs is assigned only after FSCom.open() and the state write both
succeed. Ensure the initial save uses this same successful-write path to
initialize the checkpoint, while failed persistence leaves the checkpoint
unchanged so the save is retried promptly.
In `@src/motion/MotionSensor.cpp`:
- Around line 262-266: Guard the remaining-time calculation in the calibration
countdown before casting endCalibrationAt to int32_t: when
screen->getEndCalibration() returns the inactive sentinel 0, keep timeRemaining
at its inactive value and skip the countdown calculation. Preserve the existing
signed-delta and rounding behavior for nonzero deadlines.
In `@test/test_packet_signing/test_main.cpp`:
- Line 1220: Update the notDueTxMsec construction in the test to use the
injectable Time::getMillis() clock instead of raw millis(), while preserving the
one-hour deadline offset and the existing router timing path.
In `@test/test_throttle/test_main.cpp`:
- Around line 104-116: Correct the rollover-math comment in
test_deadlinePassed_survives_millis_wrap to state that advancing 400
milliseconds reaches 0x00000090. Leave the deadlinePassed assertions and test
behavior unchanged.
---
Nitpick comments:
In `@src/airtime.cpp`:
- Around line 78-79: Remove the unused lastUtilPeriod and lastUtilPeriodTX
member declarations from AirTime, and delete every assignment to them in
AirTime::syncNow() and the other referenced code paths, including the
assignments near lines 78–79, 134, and 145. Leave the surrounding
synchronization logic unchanged.
In `@src/modules/Telemetry/Sensor/BME680Sensor.cpp`:
- Line 172: Replace the direct millis() assignment in the lastStateSaveMs update
with the repository’s injected timing abstraction, using the same Time-based API
supported by Time::setTestMillis(). Preserve the checkpoint assignment behavior
while ensuring tests can control the recorded timestamp.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 8733a089-2251-4ea4-a3f6-8a514733c3ad
📒 Files selected for processing (35)
.github/copilot-instructions.md.github/millis-deadline-allowlist.txt.github/workflows/test_native.ymlAGENTS.mdCLAUDE.mdsrc/Power.cppsrc/PowerFSMThread.hsrc/UptimeClock.cppsrc/UptimeClock.hsrc/airtime.cppsrc/airtime.hsrc/gps/GPS.cppsrc/graphics/EInkDynamicDisplay.cppsrc/graphics/Screen.cppsrc/graphics/draw/NotificationRenderer.cppsrc/input/RotaryEncoderImpl.cppsrc/mesh/NextHopRouter.cppsrc/mesh/Throttle.cppsrc/mesh/Throttle.hsrc/mesh/eth/ethClient.cppsrc/modules/DropzoneModule.cppsrc/modules/ExternalNotificationModule.cppsrc/modules/NodeInfoModule.cppsrc/modules/StatusLEDModule.cppsrc/modules/Telemetry/Sensor/BME680Sensor.cppsrc/modules/Telemetry/Sensor/BME680Sensor.hsrc/motion/MotionSensor.cppsrc/platform/extra_variants/t5s3_epaper/variant.cppsrc/platform/nrf52/NRF52Bluetooth.cppsrc/platform/nrf52/main-nrf52.cpptest/native-suite-counttest/test_airtime/test_main.cpptest/test_packet_signing/test_main.cpptest/test_throttle/test_main.cpptest/test_uptime_clock/test_main.cpp
💤 Files with no reviewable changes (1)
- src/UptimeClock.cpp
There was a problem hiding this comment.
Pull request overview
This PR hardens firmware timing logic against the 32-bit millis() rollover by replacing direct millis() deadline comparisons with rollover-safe helpers in Throttle, removing the stateful 64-bit uptime helper, and adding CI + native tests to prevent regressions.
Changes:
- Added
Throttle::deadlinePassed()(absolute deadlines) andThrottle::hasElapsed()(elapsed-since) and migrated multiple rollover-sensitive call sites to these helpers. - Introduced
Time::getMillis()as an injectable uptime seam (UptimeClock) and added new native unit test suites to exercise wrap behavior directly. - Added a CI guard (
millis-deadline-check) plus an allowlist for the remaining non-deadlinemillis()comparisons, and documented the rule in agent guidance.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/test_uptime_clock/test_main.cpp | Adds unit tests for UptimeClock injection, stepping, and real-clock fallback. |
| test/test_throttle/test_main.cpp | Adds wrap-boundary unit tests for Throttle elapsed/deadline helpers. |
| test/test_packet_signing/test_main.cpp | Updates a retransmission sentinel in tests to be compatible with wrap-safe comparisons. |
| test/test_airtime/test_main.cpp | Adds unit tests for airtime window rotation/decay, including across wrap. |
| test/native-suite-count | Bumps canonical native suite count to include the new suites. |
| src/UptimeClock.h | Removes 64-bit uptime API and documents using Throttle instead. |
| src/UptimeClock.cpp | Deletes Time::getMillis64() implementation. |
| src/PowerFSMThread.h | Converts battery shutdown timing to Throttle::hasElapsed(). |
| src/Power.cpp | Converts reboot/shutdown scheduling to Throttle::deadlinePassed() and removes UINT32_MAX sentinel reboot behavior. |
| src/platform/nrf52/NRF52Bluetooth.cpp | Makes passkey wait loop wrap-safe by using Throttle::deadlinePassed(). |
| src/platform/nrf52/main-nrf52.cpp | Fixes flash-corruption backoff logic to be wrap-safe and sentinel-guarded. |
| src/platform/extra_variants/t5s3_epaper/variant.cpp | Fixes touch resume/suppress windows to be wrap-safe and sentinel-guarded. |
| src/motion/MotionSensor.cpp | Updates calibration countdown computation to be wrap-resilient (see review comment). |
| src/modules/Telemetry/Sensor/BME680Sensor.h | Adds lastStateSaveMs tracking for periodic state saves. |
| src/modules/Telemetry/Sensor/BME680Sensor.cpp | Replaces counter×period millis() compare with Throttle::hasElapsed() (see review comment). |
| src/modules/StatusLEDModule.cpp | Converts multiple LED timing comparisons to Throttle helpers. |
| src/modules/NodeInfoModule.cpp | Switches dedup window to monotonic ms-based stamps and wrap-safe eviction by elapsed time. |
| src/modules/ExternalNotificationModule.cpp | Fixes nag window + output toggle timing to use Throttle helpers with proper armed-flag guarding. |
| src/modules/DropzoneModule.cpp | Converts send delay to Throttle::hasElapsed(). |
| src/mesh/Throttle.h | Adds hasElapsed() and declares deadlinePassed(). |
| src/mesh/Throttle.cpp | Implements deadlinePassed() and routes all time reads through Time::getMillis(). |
| src/mesh/NextHopRouter.cpp | Fixes retransmission due checks using a well-defined unsigned half-range compare. |
| src/mesh/eth/ethClient.cpp | Fixes Ethernet NTP renew deadline handling, including the 0 sentinel. |
| src/input/RotaryEncoderImpl.cpp | Fixes button debounce timing to be wrap-safe via Throttle::hasElapsed(). |
| src/graphics/Screen.cpp | Fixes boot-screen timeout logic to be wrap-safe via Throttle::hasElapsed(). |
| src/graphics/EInkDynamicDisplay.cpp | Removes an unnecessary millis-overflow special-case now that Throttle checks are wrap-correct. |
| src/graphics/draw/NotificationRenderer.cpp | Fixes banner timeout checks and “show indefinitely” logic using Throttle::deadlinePassed(). |
| src/gps/GPS.cpp | Fixes ACK wait loop + GPS hold timing using Throttle::deadlinePassed() with sentinel guarding. |
| src/airtime.h | Adds lastSyncMsec and declares syncNow() for monotonic bucket rotation. |
| src/airtime.cpp | Reworks airtime window maintenance to advance by elapsed uptime, not scheduler ticks; adds wrap-safe rotation/clearing. |
| CLAUDE.md | Updates quick-reference table to point to Throttle for time/deadlines. |
| AGENTS.md | Documents “never compare against millis() directly” rule and sentinel hazards, pointing to CI enforcement. |
| .github/workflows/test_native.yml | Adds millis-deadline-check CI job to reject new naive millis() comparisons in src/. |
| .github/millis-deadline-allowlist.txt | Introduces allowlist entries for legitimate non-deadline millis() comparisons. |
| .github/copilot-instructions.md | Documents the required Throttle idioms and sentinel handling guidance. |
|
@coderabbitai can you check if I've resolved your comments? |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
repo="meshtastic/firmware"
pr="11291"
echo "== CodeRabbit review comments =="
gh api --paginate "repos/$repo/pulls/$pr/comments?per_page=100" \
--jq '.[] | select(.user.login == "coderabbitai") | {id, path, line, original_line, body, html_url}'
echo
echo "== CodeRabbit top-level PR comments =="
gh api --paginate "repos/$repo/issues/$pr/comments?per_page=100" \
--jq '.[] | select(.user.login == "coderabbitai") | {id, body, html_url}'
echo
echo "== Review threads with CodeRabbit comments =="
gh api graphql \
-F owner="meshtastic" \
-F name="firmware" \
-F number="$pr" \
-f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
nodes {
isResolved
path
line
comments(first: 50) {
nodes {
author { login }
body
url
}
}
}
}
}
}
}' \
--jq '.data.repository.pullRequest.reviewThreads.nodes[]
| select(any(.comments.nodes[]; .author.login == "coderabbitai"))
| {isResolved, path, line, comments: [.comments.nodes[] | select(.author.login == "coderabbitai") | {body, url}]}'Length of output: 28469 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Structure maps =="
ast-grep outline src/mesh/Throttle.h --items all --match 'Throttle|deadlinePassed' --view expanded
ast-grep outline src/mesh/NextHopRouter.cpp --items all --match 'doRetransmissions' --view expanded
ast-grep outline src/modules/Telemetry/Sensor/BME680Sensor.cpp --items all --match 'updateState' --view expanded
ast-grep outline test/test_throttle/test_main.cpp --items all --match 'test_deadlinePassed_survives_millis_wrap' --view expanded
echo
echo "== CI guard shell options =="
rg -n -C 18 'millis-deadline-check|set -[a-z]*uo pipefail|millis-hits' .github/workflows/test_native.yml
echo
echo "== Throttle and retransmission due check =="
rg -n -C 12 'deadlinePassed|doRetransmissions|nextTxMsec|HALF|0x80000000' src/mesh/Throttle.h src/mesh/Throttle.cpp src/mesh/NextHopRouter.cpp
echo
echo "== BME680 state-save checkpoint =="
rg -n -C 22 'lastStateSaveMs|STATE_SAVE_PERIOD|FSCom\.open|file\.write|file\.flush|updateState' \
src/modules/Telemetry/Sensor/BME680Sensor.cpp src/modules/Telemetry/Sensor/BME680Sensor.h
echo
echo "== Packet-signing test clock source =="
rg -n -C 12 'notDueTxMsec|pendingNextTx|Time::getMillis\(\)|millis\(\)' test/test_packet_signing/test_main.cpp
echo
echo "== Throttle wrap-test comment =="
rg -n -C 10 'test_deadlinePassed_survives_millis_wrap|advanceTestMillis\(400\)|0x0000009' test/test_throttle/test_main.cpp
echo
echo "== Current PR diff for affected files =="
git diff --unified=20 HEAD^ HEAD -- \
.github/workflows/test_native.yml \
src/mesh/Throttle.h \
src/mesh/Throttle.cpp \
src/mesh/NextHopRouter.cpp \
src/modules/Telemetry/Sensor/BME680Sensor.cpp \
test/test_packet_signing/test_main.cpp \
test/test_throttle/test_main.cppLength of output: 51409
The two other findings were already withdrawn as inapplicable. Resolving my review comments now. [resolve] You are interacting with an AI system. |
millis() rollover in deadline and interval comparisonsmillis() rollover in deadline, interval, and timestamp handling
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/test_packet_signing/test_main.cpp (1)
1492-1514: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winRestore test-global duty-cycle state in teardown
test_C14leavesconfig.lora.regionatEU_868andairTime->utilizationTX[0]atMS_IN_HOURwhen a body assertion fails, becausetearDown()only deletes the mock NodeDB. Reset these globals in teardown or use a RAII guard so later tests do not pick up this unrelated duty-cycle/region state.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/test_packet_signing/test_main.cpp` around lines 1492 - 1514, Update tearDown() to restore the global duty-cycle test state changed by test_C14_duty_cycle_limited_reliable_send_remains_pending, including config.lora.region and airTime->utilizationTX[0], even when an assertion aborts the test body. Use teardown reset logic or an RAII guard, while preserving the existing NodeDB cleanup.
🧹 Nitpick comments (1)
src/airtime.cpp (1)
88-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRotation copies
air_period_*from theairtimes.*arrays.Lines 107-108 source
air_period_tx[i+1]/air_period_rx[i+1]fromairtimes.periodTX[i]/periodRX[i]rather than fromair_period_tx[i]/air_period_rx[i]. Today the two arrays hold identical values (both incremented inlogAirtime), so behavior is unchanged, but the cross-array copy silently couples them and will diverge if either accumulation path changes.♻️ Keep each array rotating on its own values
- air_period_tx[i + 1] = this->airtimes.periodTX[i]; - air_period_rx[i + 1] = this->airtimes.periodRX[i]; + air_period_tx[i + 1] = air_period_tx[i]; + air_period_rx[i + 1] = air_period_rx[i];🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/airtime.cpp` around lines 88 - 118, Update the rotation loop in the airtime period handling so air_period_tx and air_period_rx receive values from their own corresponding arrays at index i, rather than from airtimes.periodTX and airtimes.periodRX. Leave the airtimes array rotations unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@AGENTS.md`:
- Line 93: Update the sentinel hazard guidance in AGENTS.md so the
`nagCycleCutoff` case explicitly checks for and excludes `UINT32_MAX` before
calling `Throttle::deadlinePassed`, while retaining the existing zero-sentinel
guard for the other deadline variables.
In `@src/modules/NodeInfoModule.cpp`:
- Around line 45-50: Make the NodeInfo suppression cache multi-wrap-safe by
replacing the 32-bit timestamp handling around lastNodeInfoSeen, including its
eviction logic, with a 64-bit monotonic timestamp or explicit expiration
representation that cannot misclassify entries after uptime wraps. Preserve the
12-hour suppression behavior for recent senders, and add a regression test
covering timestamp rollover and entries older than the signed half-range.
In `@src/modules/Telemetry/Sensor/BME680Sensor.cpp`:
- Around line 168-172: Update the save flow around stateUpdateCounter and the
FSCom write so the schedule advances only after a verified successful write: do
not increment stateUpdateCounter before opening or writing, require the write to
persist the complete state blob rather than relying only on the file handle
check, and update stateUpdateCounter plus lastStateSaveMs only after that full
write succeeds. Preserve the pending first-save behavior and retry immediately
after any open or partial-write failure.
---
Outside diff comments:
In `@test/test_packet_signing/test_main.cpp`:
- Around line 1492-1514: Update tearDown() to restore the global duty-cycle test
state changed by test_C14_duty_cycle_limited_reliable_send_remains_pending,
including config.lora.region and airTime->utilizationTX[0], even when an
assertion aborts the test body. Use teardown reset logic or an RAII guard, while
preserving the existing NodeDB cleanup.
---
Nitpick comments:
In `@src/airtime.cpp`:
- Around line 88-118: Update the rotation loop in the airtime period handling so
air_period_tx and air_period_rx receive values from their own corresponding
arrays at index i, rather than from airtimes.periodTX and airtimes.periodRX.
Leave the airtimes array rotations unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 48428d2d-08d6-4002-b67b-30f783cd1d01
📒 Files selected for processing (49)
.github/copilot-instructions.md.github/millis-deadline-allowlist.txt.github/workflows/test_native.ymlAGENTS.mdCLAUDE.mdsrc/Power.cppsrc/PowerFSMThread.hsrc/UptimeClock.cppsrc/UptimeClock.hsrc/airtime.cppsrc/airtime.hsrc/gps/GPS.cppsrc/gps/RTC.cppsrc/graphics/EInkDynamicDisplay.cppsrc/graphics/Screen.cppsrc/graphics/draw/NotificationRenderer.cppsrc/input/RotaryEncoderImpl.cppsrc/mesh/MeshService.cppsrc/mesh/MeshService.hsrc/mesh/NextHopRouter.cppsrc/mesh/NodeDB.cppsrc/mesh/NodeDB.hsrc/mesh/PhoneAPI.cppsrc/mesh/Router.cppsrc/mesh/Router.hsrc/mesh/Throttle.cppsrc/mesh/Throttle.hsrc/mesh/eth/ethClient.cppsrc/modules/DropzoneModule.cppsrc/modules/ExternalNotificationModule.cppsrc/modules/NodeInfoModule.cppsrc/modules/StatusLEDModule.cppsrc/modules/Telemetry/DeviceTelemetry.cppsrc/modules/Telemetry/DeviceTelemetry.hsrc/modules/Telemetry/HostMetrics.hsrc/modules/Telemetry/Sensor/BME680Sensor.cppsrc/modules/Telemetry/Sensor/BME680Sensor.hsrc/motion/MotionSensor.cppsrc/platform/extra_variants/t5s3_epaper/variant.cppsrc/platform/nrf52/NRF52Bluetooth.cppsrc/platform/nrf52/main-nrf52.cpptest/native-suite-counttest/test_airtime/test_main.cpptest/test_meshpacket_serializer/ports/test_timestamp.cpptest/test_meshpacket_serializer/test_helpers.htest/test_packet_signing/test_main.cpptest/test_stream_api/test_main.cpptest/test_throttle/test_main.cpptest/test_uptime_clock/test_main.cpp
💤 Files with no reviewable changes (2)
- src/modules/Telemetry/HostMetrics.h
- src/modules/Telemetry/DeviceTelemetry.h
🚧 Files skipped from review as they are similar to previous changes (4)
- src/motion/MotionSensor.cpp
- .github/copilot-instructions.md
- src/modules/Telemetry/Sensor/BME680Sensor.h
- src/airtime.h
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
src/UptimeClock.cpp (1)
40-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the shared extended-timestamp formula to avoid divergence.
getMillisMonotonic()(Line 46) andserviceMonotonic()(Line 58) both inline the identical((((uint64_t)high << 32) | low) + (uint32_t)(getMillis() - low))expression. This is precisely the rollover-safety math this PR is built around; keeping two independent copies risks a future edit updating one and not the other, silently reintroducing a wrap bug.♻️ Proposed refactor to de-duplicate the formula
namespace { // The wrap carry, published by Time::serviceMonotonic() and read by everyone else. Split into two // 32-bit atomics behind a sequence counter: a 64-bit store is not atomic on a 32-bit MCU and the // halves must be read as a matched pair. Odd sequence = publish in progress. std::atomic<uint32_t> publishSeq{0}; std::atomic<uint32_t> publishedHigh{0}; // wraps counted as of the last publish std::atomic<uint32_t> publishedLow{0}; // getMillis() at the last publish +// Shared math: extend a published (high, low) snapshot by the elapsed time to `now`. +uint64_t extendTimestamp(uint32_t high, uint32_t low, uint32_t now) +{ + return (((uint64_t)high << 32) | low) + (uint32_t)(now - low); +} + // Seqlock read. Single writer, so this only ever retries against a publish in flight. void readPublished(uint32_t &high, uint32_t &low) { ... } } // namespace uint64_t Time::getMillisMonotonic() { uint32_t high, low; readPublished(high, low); - return ((((uint64_t)high << 32) | low) + (uint32_t)(getMillis() - low)); + return extendTimestamp(high, low, getMillis()); } ... void Time::serviceMonotonic() { const uint32_t low = publishedLow.load(std::memory_order_relaxed); const uint32_t high = publishedHigh.load(std::memory_order_relaxed); - const uint64_t next = ((((uint64_t)high << 32) | low) + (uint32_t)(getMillis() - low)); + const uint64_t next = extendTimestamp(high, low, getMillis()); ... }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/UptimeClock.cpp` around lines 40 - 66, Extract the duplicated extended-timestamp calculation into a shared helper near getMillisMonotonic and reuse it from both getMillisMonotonic() and serviceMonotonic(). Preserve the existing uint32_t wraparound subtraction and uint64_t composition exactly so both paths retain identical rollover behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/gps/GPS.cpp`:
- Around line 1432-1446: Condense the explanatory comments surrounding the
post-lock ephemeris hold helper and its re-arm logic to one or two lines each.
Retain only the non-obvious rationale that fixHoldEnds == 0 means no hold is
active and must be checked explicitly because deadlinePassed() can misinterpret
the sentinel after the unsigned half-range; remove the extended narrative while
preserving the implementation.
- Around line 1447-1450: Update fixHoldInForce and the hold-deadline arming
logic so deadlines use Time::getMillis() and rollover-safe Throttle helpers
consistently. When calculating the deadline from the hold duration, remap only a
wrapped result of zero to a nonzero value, while checking the inactive zero
sentinel separately. Preserve correct active-hold behavior across clock
rollover.
In `@src/modules/NodeInfoModule.cpp`:
- Around line 38-40: Shorten the rollover explanation in the nearby
packet-timestamp comment to one or two lines, retaining only the non-obvious
reason for using seconds rather than milliseconds and avoiding the 32-bit
millisecond rollover issue. Remove secondary detail about entry lifetime and
reply suppression.
In `@test/test_gps_fix_hold/test_main.cpp`:
- Around line 150-152: Update the hold-expiry assertion near fixHoldInForce to
use a variable for the sentinel comparison instead of the tautological 0 != 0
expression, ensuring fixHoldInForce is evaluated while preserving the expected
false result for the non-expired case.
---
Nitpick comments:
In `@src/UptimeClock.cpp`:
- Around line 40-66: Extract the duplicated extended-timestamp calculation into
a shared helper near getMillisMonotonic and reuse it from both
getMillisMonotonic() and serviceMonotonic(). Preserve the existing uint32_t
wraparound subtraction and uint64_t composition exactly so both paths retain
identical rollover behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: bd924aa2-5ac9-4da4-8082-c4f223512fbb
📒 Files selected for processing (14)
.github/copilot-instructions.mdAGENTS.mdsrc/UptimeClock.cppsrc/UptimeClock.hsrc/airtime.cppsrc/gps/GPS.cppsrc/main.cppsrc/modules/NodeInfoModule.cppsrc/modules/NodeInfoModule.htest/native-suite-counttest/test_airtime/test_main.cpptest/test_gps_fix_hold/test_main.cpptest/test_packet_signing/test_main.cpptest/test_uptime_clock/test_main.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
- .github/copilot-instructions.md
- src/airtime.cpp
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/test_gps_fix_hold/test_main.cpp`:
- Around line 153-155: Shorten the comment inside holdJustExpired() to no more
than two lines while preserving the essential rationale: the sentinel guard must
run every cycle because negating fixHoldInForce() alone treats an unarmed hold
as expired and may call down() incorrectly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d317a369-f986-42fe-8c1a-6644b1657dbe
📒 Files selected for processing (4)
src/UptimeClock.cppsrc/gps/GPS.cppsrc/modules/NodeInfoModule.cpptest/test_gps_fix_hold/test_main.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
- src/modules/NodeInfoModule.cpp
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/mesh/NodeDB.cpp:4111
- NodeDB::evictionRecency() ranks RAM-only "heard while clock untrusted" stamps by adding a 0x80000000 bias. That only guarantees the stamp is above pre-2038 epochs; but the RTC path explicitly accepts times up to BUILD_EPOCH + 40y, which can exceed this bias. In that case, a newly-heard (RAM-stamped) node can still compare as older than nodes dated with a post-2038 epoch and be evicted first, defeating the purpose of the sidecar.
Consider ranking RAM-stamped nodes as newer than any epoch-based stamp without relying on the epoch range, e.g. map them into the UINT32_MAX range based on elapsed uptime seconds.
uint32_t NodeDB::evictionRecency(const meshtastic_NodeInfoLite *n) const
{
const uint32_t stamp = heardAtUptimeSecs(n->num);
// A RAM stamp means heard this boot but not yet datable: more recent than anything dated
// before this boot. The 2^31 bias keeps stamps above every pre-2038 epoch while preserving
// their order among themselves.
return stamp ? 0x80000000u + stamp : n->last_heard;
src/modules/Telemetry/Sensor/BME680Sensor.cpp:192
- BME680Sensor::updateState() only updates lastStateSaveMs on a successful write. Once STATE_SAVE_PERIOD has elapsed since the last success (or if there has never been a success), a persistent FS failure (open/remove failing) will cause updateState() to attempt the write on every call, potentially spamming logs and holding spiLock frequently.
If the intent is "retry sooner than the full period, but not in a tight loop", add a small retry backoff in the failure path so the next attempt is delayed by (e.g.) 60s.
|
@ianmcorvidae would you mind casting an eye over this if you get time (pun intended)? I've tried to keep it focused on solving a few outstanding issues, and not drift off into refactor for the sake of it. The thread-safety in particular is something I'm not confident I know what it should look like. |
|
out of curiousity, libs that use millis() as a timeout are also effected by this right ? eg: Adafruit used from https://github.com/meshtastic/firmware/blob/develop/src/modules/Telemetry/Sensor/BME280Sensor.cpp#L38 |
Possbily - I'm not intending to delve that deep into the hinterland. Your issue with the 280 might be related - the 680 was definitely affected. |
…handling # Conflicts: # src/Power.cpp # src/graphics/Screen.cpp # src/mesh/NextHopRouter.cpp # test/native-suite-count
test_throttle and test_uptime_clock pin dense clusters of hex boundary constants (0xFFFFFF00u and neighbors) to exercise 32-bit millis() rollover. trufflehog's Lob detector stitches nearby hex literals into one candidate string, and the result happens to match a Lob API key shape - not a secret, just test fixtures. Same pattern already used for the gitleaks/nodedb-fixture false positive in this file.
|
@thebentern putting this one back a bit - it was tripping CI. Now ignored. |
Comment-only: with comments stripped, all five files are byte-identical to the previous commit. Removed the references to the planning notes. Those documents are working material and will go stale; the code should not depend on them. The five CHARACTERISATION tags now describe the defect they pin and stop there, and the accuracy TODO names the four defects and points at the tests instead of a plan file. Also removed, as noise rather than information: - comparisons against pre-meshtastic#11291 behaviour, which nobody reading this needs - a comment describing the lock restructure as future work, written before it landed - speculation ("plausible", "worth pinning so a future...") - an aside arguing with an arithmetic slip made while writing the test Kept the mechanical facts that are slow to re-derive: the two storage orderings and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and the addSpanned() constraint that protects it, why the re-entry assert is test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp contention-window steps. Net 16 comment lines out of src/, 33 out of test/.
The only conflict is test/native-suite-count, which is not a text merge: develop registered test_banner_font_tags (44 -> 45) while this branch registered test_airtime, test_gps_fix_hold, test_throttle and test_uptime_clock (44 -> 48). Both sets survive the merge, so the file is recounted against the merged tree rather than taking either side: 49. NotificationRenderer.cpp auto-merged and was checked by hand - develop reworked font-tag resolution while this branch converted the alertBannerUntil comparisons to Throttle::deadlinePassed(); the two touch different regions and both are present in the result.
|
I have a better fix for this, but it won't be possible until Wednesday night |
Three conflicts: test/native-suite-count - develop deleted the file in af56a11, replacing the manual register with dynamic discovery plus a git-aware shrinkage check. Took the deletion; this branch's recount to 49 is moot. NextHopRouter.cpp - this branch moved the retransmit stamp to Time::getMillis(), develop lowered the neighbouring log to LOG_TRACE. Kept both. GPS.cpp - the same shape twice, with a trap. develop added src/gps/GPSLog.h, which defines GPS_DEBUG to 0 unconditionally and adds LOG_DEBUG_GPS. Because GPS_DEBUG is now always defined, this branch's `#ifdef GPS_DEBUG` guards would have compiled in permanently rather than never. Converted both to develop's `#if GPS_DEBUG` / LOG_DEBUG_GPS idiom while keeping the monotonic timebase changes: the start-stamp-plus-interval wait, and the fix-hold expiry with its non-zero sentinel.
Comment-only: with comments stripped, all five files are byte-identical to the previous commit. Removed the references to the planning notes. Those documents are working material and will go stale; the code should not depend on them. The five CHARACTERISATION tags now describe the defect they pin and stop there, and the accuracy TODO names the four defects and points at the tests instead of a plan file. Also removed, as noise rather than information: - comparisons against pre-meshtastic#11291 behaviour, which nobody reading this needs - a comment describing the lock restructure as future work, written before it landed - speculation ("plausible", "worth pinning so a future...") - an aside arguing with an arithmetic slip made while writing the test Kept the mechanical facts that are slow to re-derive: the two storage orderings and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and the addSpanned() constraint that protects it, why the re-entry assert is test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp contention-window steps. Net 16 comment lines out of src/, 33 out of test/.
Comment-only: with comments stripped, all five files are byte-identical to the previous commit. Removed the references to the planning notes. Those documents are working material and will go stale; the code should not depend on them. The five CHARACTERISATION tags now describe the defect they pin and stop there, and the accuracy TODO names the four defects and points at the tests instead of a plan file. Also removed, as noise rather than information: - comparisons against pre-meshtastic#11291 behaviour, which nobody reading this needs - a comment describing the lock restructure as future work, written before it landed - speculation ("plausible", "worth pinning so a future...") - an aside arguing with an arithmetic slip made while writing the test Kept the mechanical facts that are slow to re-derive: the two storage orderings and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and the addSpanned() constraint that protects it, why the re-entry assert is test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp contention-window steps. Net 16 comment lines out of src/, 33 out of test/.
…1362) * Copy airtime reports into a caller buffer instead of exposing the array airtimeReport() returned a pointer into the rotating bucket arrays, so the caller held a handle to state that logAirtime() and every accessor mutate underneath it. Copy into a caller-supplied buffer instead, and report failure for a null buffer, a count past the log depth, or an unknown report type. ContentHandler owns its buffer and hoists getPeriodsToLog() out of the three calls that repeated it. * Cover the AirTime report API and log-dispatch contract Half of AirTime's surface had no tests: which store each report type feeds, what airtimeReport() does when misused, how the first sync seeds itself, and whether calling several entry points in one interval compounds the rotation. Eighteen tests, asserted through the public API rather than the public bucket arrays - those arrays are meant to become private, and a test that reads them would have to be rewritten rather than pinning a contract. Two of them state a convention that was never written down: the report arrays are shift-ordered with slot 0 newest, and slot 0 covers only the time since the last rotation. channelUtilization and utilizationTX use the opposite convention - a modular ring indexed by uptime phase - and reading one as if it were the other is a defect that has already happened once. * Characterise AirTime window decay, TX gates, and sleep behaviour Thirty-three tests in three kinds. Invariants must hold forever; boundaries pin off-by-ones a refactor would move; five characterisations encode today's wrong numbers, each tagged with the phase that will flip it. Readings are asserted against an event-log oracle - airtime physically on air inside (now - window, now], computed from a list of completed packets - rather than against hand-worked constants, so a test states "this matches the definition" instead of "this looked right when I wrote it". The characterisations, all measured rather than assumed: - the window covers (N-1)p + phase but divides by Np, so a steady 10% load reads 8.33% right after a bucket boundary -> phase 5 - the same load sweeps across bucket phase instead of holding -> phase 5 - the hour window carries the same defect, 10x smaller -> phase 5 - a packet longer than its bucket is credited whole to the bucket it completed in, so a saturated LONG_SLOW channel reads >100% -> phase 4b - getSilentMinutes() reads a modular ring as if the index were an age, so identical airtime gives different answers by phase -> phase 6 Two tests needed correcting during the write, both my expectations rather than the code: a six-bucket ring sheds whole buckets, so a 30s gap drops three of five survivors and not "half"; and the oracle sees 59 completions in a 60s window, not 60, because the one on the lower edge is outside it. Not written: the planned RX_LOG/RX_ALL_LOG disjointness test. That is a property of the two radio drivers, which choose one or the other per packet - it is not observable from AirTime, which records what it is told. The AirTime-side half is already covered by the routing tests. * Drop write-only and undefined AirTime members None of this was reachable: air_period_tx / air_period_rx file-scope mirrors of airtimes.periodTX/RX, accumulated, rotated and memset in lockstep with them but never read out or serialised. Orphaned when #2552 re-pointed the writes at bare globals instead of deleting them. lastUtilPeriod, lastUtilPeriodTX written on every sync, read nowhere airtimes.lastPeriodIndex written on every rotation, read nowhere currentPeriodIndex() computes (secs / 3600) % 8 - a modular-ring index for the one array that is shift-ordered rather than a ring. Its only two uses were the dead field above and a log line. It is the fossil of the same confusion that makes getSilentMinutes() wrong. UtilizationPercentTX() declared, never defined free logAirtime()/airtimeReport() declared, never defined; the latter still carried the array-returning signature the previous commit removed, so it actively misled Also fixes the rotation log line, which read currentPeriodIndex() from inside the loop although the index is advanced before it - on a multi-hour wake it printed the same final value once per rotation. It now reports which of the crossed hours is being rotated. airtimeRotatePeriod() is kept: it has no caller in the tree either, but unlike the above it is a defined public method, so out-of-tree callers are plausible. Measured, not estimated: sizeof(AirTime) 464 -> 456 B, plus 64 B of globals, so -72 B of static RAM. Padding accounts for the difference from the 66 B the plan predicted by counting declared bytes. The whole point of writing the tests first: the suite is green here with zero test changes. * Document what the AirTime figures measure and how they are stored Comments only, but four of the things they replace were false. The header's example analytics claimed RX_ALL_LOG was "all received lora packets" and offered "RX_ALL_LOG - RX_LOG = other lora radios". Both radio drivers pick exactly one of the two per packet, so they are disjoint: RX_ALL_LOG is airtime we could not parse, the subtraction can go negative, and the total is TX + RX + RX_ALL. Replaced with the actual contract - four inputs, eight outputs, the window each spans, and the fact that the three thresholds are hard-coded members rather than the settings they look like. Names the two storage conventions on their declarations, because mixing them up is what makes getSilentMinutes() wrong: channelUtilization and utilizationTX are modular rings indexed by uptime phase, where the oldest bucket is (current + 1) % N; airtimes.period* is shift-ordered with slot 0 newest, where the index IS an age and slot 0 is a partial hour. Defines the measurement as wall time rather than awake time, and says why: a sleeping node still hears traffic, and per-node redefinition would make two broadcast readings incomparable. Records that the 60s figure is published to the mesh at >= 1h cadence, so what other nodes see is a snapshot - at LONG_FAST and 1% occupancy it reads exactly 0 in about 44% of reports - and that the contention window it feeds moves in 20-percentage-point steps, so small errors never reach the backoff. Finally, states that rotation happens on access rather than on the scheduler tick, names the test that enforces it, and leaves a TODO pointing at the plan phases that fix the characterised accuracy defects. * Serialise AirTime behind a lock proven by a private token Two mechanisms solving different halves. A lock-free inner core (Windows) holds all state and all logic; it has no lock and no way to reach one, so nesting is impossible by construction. A private Held token takes the lock in its own constructor and is the only thing that can be passed where a core method demands one, so the lock cannot be forgotten either. The rule is now uniform with no exceptions to remember: every public method takes the lock once and delegates. In particular isTxAllowed*() lock like everything else - before the split they could not, because they called the public accessors and the lock is not recursive. That asymmetry was the foot-gun the previous design documented in prose and hoped nobody would trip. getPeriodsToLog()/getSecondsPerPeriod() still take no lock; they return compile-time constants and touch no state. channelUtilization[] and utilizationTX[] were public, so the lock was bypassable at compile time. They move into the private core. Four test sites reached in; all four now use logAirtime() plus the virtual clock, and no new test seam was needed. Nothing in src/ was affected. The re-entry assert is guarded on PIO_UNIT_TESTING, so it exists in test builds only. The design sketched #ifdef DEBUG, but nothing in this tree defines DEBUG or NDEBUG, so either spelling ships the assert to every board - and nrf52_promicro_diy_tcxo has ~128 bytes of headroom under its 0xEA000 warm-store cap, which the assert's strings and abort path overrun. It would have worked on hardware, since the check runs in Held's owner initialiser and so precedes the blocking take; the objection is that abort()ing a live mesh node is a poor trade for a bug never seen in the field. Native tests are where it earns its keep anyway: Portduino compiles Lock::lock() to an empty body, so a nested take there succeeds silently and nothing else would notice. Also comments out ScopedBusyAirTime in test_traffic_management. It is inert twice over: the module holds no reference to airTime at all since hop exhaustion was shelved, and the fixture never worked anyway - writing the buckets on a fresh AirTime is undone by the first accessor call, which takes the firstTime branch and memsets them. It reported 0%, not the 100% it claimed. Left in place, commented, with both reasons recorded. Cost on the tightest board in the tree, nrf52_promicro_diy_tcxo: the six phases together add 96 bytes of flash, leaving it 32 bytes clear of the warm-store guard. RAM is 72 bytes lower from the dead-state removal. Suite green at 47/47, with test_airtime unedited apart from the added nesting test. * Count rotations with the loop variable, not a separate tally LOG_DEBUG compiles to nothing under DEBUG_MUTE, so the counter's only read disappeared with it and the tally became write-only. It does not warn today - this build has -Wunused-but-set-variable on, and it fires for other locals, but not for one that is only initialised and never read - so it was latent rather than broken: a stricter flag or -Werror would have failed muted builds only. Using the loop variable removes the class of problem, since the loop condition reads it, and drops the elapsedAirtimePeriods-- mutation as a side benefit. Same iteration count, same output. Found by compiling nrf52_promicro_diy_tcxo with -D DEBUG_MUTE, which is worth recording for its own sake: muting logs takes that image from 802 784 to 673 416 bytes, 98.5% to 82.6% of flash. Logging is 16% of the largest nrf52 image, and its 32 bytes of warm-store headroom are a logging-verbosity question rather than a code-size one. * Tighten the comments added by this branch Comment-only: with comments stripped, all five files are byte-identical to the previous commit. Removed the references to the planning notes. Those documents are working material and will go stale; the code should not depend on them. The five CHARACTERISATION tags now describe the defect they pin and stop there, and the accuracy TODO names the four defects and points at the tests instead of a plan file. Also removed, as noise rather than information: - comparisons against pre-#11291 behaviour, which nobody reading this needs - a comment describing the lock restructure as future work, written before it landed - speculation ("plausible", "worth pinning so a future...") - an aside arguing with an arithmetic slip made while writing the test Kept the mechanical facts that are slow to re-derive: the two storage orderings and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and the addSpanned() constraint that protects it, why the re-entry assert is test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp contention-window steps. Net 16 comment lines out of src/, 33 out of test/. * Gate the AirTime re-entry check on the host, not on testing PIO_UNIT_TESTING is injected by PlatformIO purely on BUILD_TYPE, with no platform check, so it is defined on an on-target `pio test` run too. The check arms before the lock is taken - a nested take blocks forever, so a later check would never run - which under preemption false-positives on legitimate contention and races on its own write. Derive AIRTIME_REENTRY_CHECK once from PIO_UNIT_TESTING && !HAS_FREE_RTOS and use it at all three sites. Had the three conditions ever diverged, an on-target test build would fail to compile on a member the header no longer declares. * Log AirTime outside the lock it serialises DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary semaphore with no priority inheritance, so holding it across a log call lets the main thread stall the radio thread in getTxDelayMsec(). Move logAirtime()'s LOG_DEBUG into the shell, after the Held scope closes; the shell already has both arguments, so nothing has to be passed back out of the core. isTxAllowed{ChannelUtil,AirUtil} read into a local under the lock and warn after it. The log bodies are braced because LOG_DEBUG compiles away under DEBUG_MUTE and a bare `if (x) ;` trips -Wempty-body. Fold the two doubled index calls into `+=` while touching the lines. * Give each airtime report its own buffer handleReport() reused one array across the three airtimeReport() calls and ignored the bool. A failed report would have left the previous type's data in place and emitted it under the next type's key. Build each through a lambda whose buffer is zeroed per call, so a failure emits zeros. Unreachable today - the count is always PERIODS_TO_LOG and the type is always valid - but the old shape only read as correct by accident. * Drop a stray semicolon from the inert-guard comment * Address external review: name the race, tighten the claims and the tests The header sold the lock as mechanism without naming a second thread, which invites the reasonable objection that this is a cooperative OSThread codebase. There is a real race and it is nRF52-only: NRF52Bluetooth registers its ToRadio write callback with defer == false, so a phone's packet runs handleToRadio -> sendToMesh -> Router::send on the Bluefruit BLE task, reading utilizationTXPercent() and getSilentMinutes() while loopTask may be inside logAirtime(). ESP32 hands BLE work to the main task and does not have it. Three claims in the header were wrong or overstated: - "nesting is impossible by construction" - Windows is a nested class with an enclosing class's access rights, and `extern AirTime *airTime` is in the same header, so airTime->anyPublicMethod() from inside it is well-formed and would hang. Nothing does it; the assert is the backstop. Say that instead, because the comment below instructs contributors to add helpers to Windows on the strength of the guarantee. - "every public method takes the lock exactly once" - two constant accessors take none and isTxAllowedAirUtil() takes it zero or one times. State the exceptions where the invariant is stated, not only at the definitions. - "both radio drivers pick exactly one per packet" - five drop paths log neither. At most one. Recorded against plan4 rather than fixed here: it changes a telemetry value. getPeriodsToLog()/getSecondsPerPeriod() become static constexpr, which removes them from the locking claim structurally and lets ContentHandler size its buffer and its count from one constant. Tests: - C14's saturated AirTime is installed by a helper and restored in tearDown. Unity's TEST_ABORT() is longjmp and does not run destructors of automatic objects, so the scoped guard it replaces would leave airTime dangling into an abandoned frame on any assertion failure - and the same commit that added it removed the tearDown reset that did cover that. - test_getSilentMinutes_counts_minutes_until_enough_ages_out asserted only `mins <= 60`, which neither return path can violate. The answer is 59. - test_backwards_uptime_degrades_safely stepped 600s -> 60s, which leaves elapsedAirtimePeriods at 0, so it never reached the hourly-report branch its own comment describes. Step by the wrap instead and assert the exact figures. - test_airtime leaked EU_868 out of the duty-cycle case into every later one, and the reentry test's isTxAllowedAirUtil() coverage depended on it. Restore the region in tearDown and set it explicitly where it is wanted. - Rename that test to what it can actually check: no single method takes the lock twice. The calls are sequential, so it cannot catch two methods nesting. * trunk: suppress trufflehog/Lob false positives in test_airtime * Address CodeRabbit review: the rotate trace, the cap warn, the backoff Four findings from the CodeRabbit pass. Two were introduced by this branch, one is a real inconsistency it inherited, one is a naming slip. The rotate trace was the one that mattered. "Log AirTime outside the lock it serialises" moved the per-packet lines and the two TX-gate warnings out to the shell, but missed LOG_DEBUG("Rotate airtimes, crossed hour %u") because it does not sit in the shell at all: it is inside Windows::syncNow(), the lock-free core, which by construction only ever runs under Held. Nothing at that line looks like a lock, which is why it survived. The exposure is smaller than the review suggests - runOnce() syncs at 1 Hz, so in steady state this is one line an hour, and the PERIODS_TO_LOG - 1 burst needs an hour of light sleep with no intervening sync - but a UART write under a plain binary semaphore with no priority inheritance is exactly what the comment above logAirtime() says this code does not do. syncNow() now accumulates crossings in rotationsPendingLog and runOnce() drains it inside the Held scope, then logs after release. Any caller can cross an hour; only that thread reports it, so a crossing raised elsewhere is traced at most one tick late. The `if (rotations > 0)` guard keeps the drained value read under DEBUG_MUTE, where LOG_DEBUG expands to nothing - the write-only tally that "Count rotations with the loop variable" removed. addFromContact()'s favorite fallback stamped silently when the protected cap refused it. The stamp is new on this branch; the two sibling refusals (ignore, verify) both emit PROTECTED_CAP_WARN_FMT, so the operator lost the only signal that the cap was hit on the one path that has a fallback. lfs_assert() mixed clocks: Throttle read Time::getMillis(), the remainder was computed from a second, bare millis(). The review's stated failure mode - a native test overriding the clock - cannot happen, since the hook is behind PIO_UNIT_TESTING and this file is nRF52-only. The real defect is the second read: a tick landing on the 20-minute boundary between the check and the subtraction underflows the remainder into delay(~50 days), on a device that has just found its flash corrupt. One read, clamped, and preFSBegin() stores from the same clock. The eviction test is renamed to test_eviction_prefersCurrentBootStampOverPost2038Epoch. The finding is right that it was snake_case, but the suggested testEvictionPrefers... does not match this file either, which is test_<area>_<camelCase> throughout. Not taken, both pre-existing and out of scope for a rollover branch: - t5s3_epaper's touchResumeAtMs/suppressFromMs read an active suppression as inactive if the wake lands in the 1 ms where millis() is 0. Consequence is one skipped 150 ms touch-settle window per 49.7-day wrap. - NRF52Bluetooth::onPairingPasskey() busy-waits 30 s in a BLE callback. Worth saying plainly that this branch makes it more visible: the old `millis() < start_time + 30000` overflowed at the wrap and cut the wait short, so the correct Throttle form is what lets it run the full 30 s. Reworking it into an OSThread is its own change. Native suite GREEN, 48/48, 672 cases.
…ot see (#11483) * MeshPacketQueue: fix millis() rollover in the late-packet drop test replaceLowerPriorityPacket() read `backPacket->tx_after < now`, with `now` taken from millis() on the line above. tx_after is an absolute deadline, so that comparison inverts while the deadline sits on the far side of the 32-bit wrap: a queued late packet reads as not-yet-due for the rest of the wrap window, or every late packet reads as droppable at once. The same statement ordered two deadlines against each other with `backPacket->tx_after > p->tx_after`, which has the same problem. #11291 swept every site where millis() sits next to the comparison operator, and its CI guard matches that shape. Stashing the clock in a local first is the same bug written so the guard cannot see it. Both tests now subtract before comparing: the due test through Throttle::deadlinePassedAt(), and the ordering through the elapsed-since-now form already used in AdminModule's oldest-slot scan. The snapshot comes from Time::getMillis() so the deadlines and the test read one clock, per the convention deadlinePassedAt() documents. The `dt` the log line reports is now derived from the same elapsed value rather than recomputed. Behaviour is otherwise unchanged, save the boundary: deadlinePassedAt() is inclusive, so a deadline landing exactly on `now` reads as due rather than one millisecond early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * RadioLibInterface: don't widen a uint32_t deadline delta into a 64-bit long TRANSMIT_DELAY_COMPLETED tested whether the front packet was still waiting with long delay_remaining = txp->tx_after ? txp->tx_after - millis() : 0; if (delay_remaining > 0) ... The subtraction is uint32_t. Where long is 32-bit - every embedded target - an already-due deadline lands negative and the packet transmits, which is why this has never been visible on device. Where long is 64-bit (portduino, and the native test build) the same value zero-extends to ~4.29e9, reads as positive, and the packet is rescheduled 49.7 days out. It stays parked until some later notifyLater() with overwrite happens to reset the timer. That is not an edge case. notifyLater() schedules through setIntervalFromNow(), so the thread wakes at or after the deadline; being a millisecond past due is the ordinary path through this branch. Ask Throttle instead. deadlinePassedAt() is the unsigned half-range test, so there is no signed conversion to get wrong at any width, and the remaining delay handed to notifyLater() is computed from the same snapshot. On 32-bit the behaviour is identical, including at the boundary: a deadline equal to now transmitted before and still does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ExpressLRSFiveWay: convert the two remaining raw window checks to Throttle runOnce() dismissed the alert frame with `now > alertingSinceMs + 2000` and chose its poll rate with `now < keyDownStart + 20000`, both against a millis() snapshot in a local. Same rollover inversion as any other naive compare, and invisible to the millis-deadline-check guard because millis() is not adjacent to the operator. update() in the same file was already on Throttle. hasElapsed()/isWithinTimespanMs() with the stored event give the full ~49.7 day range and need no snapshot. Sentinels are unchanged in meaning: `alerting` is the armed flag for alertingSinceMs and is tested first, and keyDownStart == 0 reads as "recent" for the first 20s of uptime exactly as `now < 0 + 20000` did - a poll rate either way. The arm sites move to Time::getMillis() so the writes land on the clock Throttle reads, which also puts them within reach of Time::setTestMillis(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * GPSUpdateScheduling: record whether a search is running, don't infer it elapsedSearchMs() answered "am I searching?" by ordering two raw millis() stamps: searchStartedMs > searchEndedMs. Whichever stamp lands on the far side of the 32-bit wrap reads as the larger one, so the answer inverts once per wrap cycle, in both directions: - a search that started before the wrap and ended after it keeps reading as "searching". elapsedSearchMs() then grows without bound and searchedTooLong() aborts a search that is not running. - a search that started after the wrap, following one that ended before it, reads as "idle". elapsedSearchMs() returns 0, so an unproductive search is never aborted and the receiver stays powered until it locks. Both self-heal at the next informSearching(), which bounds the damage to one GPS cycle - but the ordering test cannot be made wrap-correct, because the two stamps carry no information about which wrap they belong to. It does not need to be. Whether a search is in progress is a fact the three inform*() calls already have in hand; the ordering was only ever standing in for it. Add the flag and set it there. elapsedSearchMs() keeps its unsigned subtraction, which was always the correct part. The file's clock reads move to Time::getMillis() so the suite can drive them across the wrap. Behaviour-preserving in production - Time::getMillis() is millis() unless a test injects a clock. test_gps_update_scheduling/ gains seven cases: the idle/searching/ended states, elapsed exactness across the wrap, both inversion directions above, and reset(). The two wrap cases fail on the old predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * MessageStore: date boot-relative messages in uptime seconds A message received before the wall clock is trustworthy is stamped boot-relative and healed by upgradeBootRelativeTimestamps() once the RTC arrives. Both the stamp and the "same boot?" test were millis() / 1000, which wraps every 49.7 days: a stamp taken before the wrap reads as newer than `bootNow` afterwards, so `m.timestamp <= bootNow` declines to heal it and the message shows "???" until it ages out. MessageRenderer's own copy of the test falls the same way and prints invalidTime. Neither produces a wrong time - the guard is what fails safe - but Time::getUptimeSecs() landed in #11291 for exactly this, and does not wrap for 136 years. Both sites take it, which makes the comparison exact rather than merely fail-safe. While here, the autosave tick had its own hand-rolled deadline helper - `reachedMs(now, target)` as `(int32_t)(now - target) >= 0`. Wrap-correct, but a competing idiom for what Throttle::isWithinTimespanMs() already answers, and the signed cast is the form #11291 replaced everywhere else. Deleted; the stamps read Time::getMillis() so the whole path is on one clock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * WebServer: drop the hand-rolled millis() wrap branch getAdaptiveInterval() special-cased the wrap by hand: if (currentTime >= lastActivityTime) timeSinceActivity = currentTime - lastActivityTime; else timeSinceActivity = (UINT32_MAX - lastActivityTime) + currentTime + 1; Those two expressions are the same number - unsigned subtraction already computes the difference modulo 2^32 - so this is not a bug, just eight lines reimplementing what Throttle does. It also reads like a site that has thought about the wrap and settled it, which makes it a bad example to copy. Two isWithinTimespanMs() calls against the stored activity stamp, matching ethApiServer's shape for the same adaptive-interval decision. The stamps move to Time::getMillis() so the writes and the reads share a clock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * MeshPacketQueue: only order elapsed times once both deadlines have passed The late-packet eviction I rewrote compared how long ago each deadline passed: backElapsed < (uint32_t)(now - p->tx_after) That is only an ordering when both deadlines are in the past. An incoming packet whose tx_after is still in the future subtracts to a near-2^32 elapsed, which reads as the most overdue packet in the queue rather than the least - so a full queue would drop the overdue packet it was about to transmit in favour of one that is not ready yet. The comparison it replaced, `backPacket->tx_after > p->tx_after`, got this right away from the wrap; I lost it in the conversion. Classify before ordering: p->tx_after must be unset, or passed, before its elapsed time means anything. Two expired deadlines still order by which is further overdue, which is what the branch is for. Caught by CodeRabbit on #11483. test/test_meshpacket_queue/ pins the branch: the future-dated arrival that started this, both directions of the both-expired ordering, the undelayed arrival, and all of it again with the deadlines and `now` on opposite sides of the wrap. maxLen is 1 so the suite reaches the branch without dragging in CompareMeshPacketFunc and a NodeDB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ExpressLRSFiveWay: treat "no key pressed yet" as no activity keyDownStart is 0 until the first press of a boot, and the fast-poll window read that as a press at time zero: 100ms polling for the first 20s of uptime with no activity at all, re-triggering once per millis() wrap. The arithmetic this replaced (`now < keyDownStart + 20000`) did the same, so it is not a regression - but the sentinel is exactly what the conventions say to test before the elapsed comparison, and "has there been recent key activity" has an honest answer here. 250ms is the documented floor for not missing presses, so an idle node simply starts there and moves to 100ms on the first press. Also trims the wrap-cases comment in test_gps_update_scheduling to the two-line house limit. Both from CodeRabbit review on #11483. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Every 49.7 days the 32-bit
millis()counter wraps, and this firmware trusted it in four load-bearing ways. Comparisons made directly against it (millis() > deadline,deadline < millis()) invert while the deadline sits across the wrap, so one-shot actions fire immediately or stall for days — the nRF52 flash-corruption backoff among them.getTime()measured elapsed-since-time-set as a 32-bit delta, so a node up longer than one wrap reported a wall clock 49.7 days in the past, and everylast_heard,rx_time, message and position stamp inherited it. The queued-packetrx_timereconciliation aliased past one wrap, backdating old placeholders to plausible-but-wrong recent epochs. And a node first heard before the clock was trusted never received alast_heardat all: the phone showed "Last heard: unknown" for a node it had just announced.Deadline and interval checks now go through
Throttle—deadlinePassed()/deadlinePassedAt()where a site stores an absolute deadline,hasElapsed()(the complement ofisWithinTimespanMs()) where it stores the last event — with disarmed-sentinel values (0,UINT32_MAX) tested before the arithmetic, and amillis-deadline-checkCI job plus allowlist keeping naive compares out ofsrc/. Timestamps ride onTime::getMillisMonotonic(), a 64-bit wrap-counting read whose carry is advanced by exactly one writer —Time::serviceMonotonic(), called at the top of the mainloop()— while every reader derives its answer from that published snapshot plus the unsigned elapsed time since it, which is exact across the wrap; readers never inspect the boundary and never write back, so no number of concurrent callers can miscount a wrap. It replaces three duplicate private wrap counters (AirTime,DeviceTelemetry, and a dead pair inHostMetrics) and is documented as not ISR-safe —getMillis()remains the ISR-safe read. An earlier commit here removesgetMillis64(), which was the same shape read lazily by a single caller and so could miss a wrap outright; the restored clock differs precisely in that its publish is guaranteed. On that base,getTime()anchors in 64-bit monotonic milliseconds, so the wall clock is exact at any uptime; the untrusted-clockrx_timeplaceholder is monotonic uptime seconds, so reconciliation is exact at any age and a leaked placeholder needs ~50 years of uptime to pass for an epoch instead of 18.3 days; and nodes heard before time arrives keep their arrival instant in a RAM-only sidecar that is backfilled intolast_heardas a real epoch once the clock becomes trusted —last_heardnever persists anything but a real epoch or 0, and PhoneAPI re-reads it at nodeinfo send time so handshake ordering doesn't decide what the phone sees.Two prior PRs are adopted as the baseline with authorship preserved: #10227 (NextHopRouter retransmission rollover; its half-range compare now lives in
Throttle::deadlinePassedAt(), credited at the call site) and #10582 (AirTime monotonic windows, which now read the shared clock).Throttleand both clocks are injectable through theUptimeClocktest seam; the native suites drive the wrap boundary, the wall clock across it, and thelast_heardbackfill directly. The conventions are documented in.github/copilot-instructions.mdand mirrored toAGENTS.mdandCLAUDE.md.Summary by CodeRabbit
Bug Fixes
Tests