Skip to content

Fix millis() rollover in deadline, interval, and timestamp handling - #11291

Merged
thebentern merged 49 commits into
meshtastic:developfrom
NomDeTom:time-handling
Aug 12, 2026
Merged

Fix millis() rollover in deadline, interval, and timestamp handling#11291
thebentern merged 49 commits into
meshtastic:developfrom
NomDeTom:time-handling

Conversation

@NomDeTom

@NomDeTom NomDeTom commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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 every last_heard, rx_time, message and position stamp inherited it. The queued-packet rx_time reconciliation 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 a last_heard at all: the phone showed "Last heard: unknown" for a node it had just announced.

Deadline and interval checks now go through ThrottledeadlinePassed()/deadlinePassedAt() where a site stores an absolute deadline, hasElapsed() (the complement of isWithinTimespanMs()) where it stores the last event — with disarmed-sentinel values (0, UINT32_MAX) tested before the arithmetic, and a millis-deadline-check CI job plus allowlist keeping naive compares out of src/. Timestamps ride on Time::getMillisMonotonic(), a 64-bit wrap-counting read whose carry is advanced by exactly one writer — Time::serviceMonotonic(), called at the top of the main loop() — 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 in HostMetrics) and is documented as not ISR-safe — getMillis() remains the ISR-safe read. An earlier commit here removes getMillis64(), 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-clock rx_time placeholder 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 into last_heard as a real epoch once the clock becomes trusted — last_heard never 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). Throttle and both clocks are injectable through the UptimeClock test seam; the native suites drive the wrap boundary, the wall clock across it, and the last_heard backfill directly. The conventions are documented in .github/copilot-instructions.md and mirrored to AGENTS.md and CLAUDE.md.

Summary by CodeRabbit

  • Bug Fixes

    • Improved timer and deadline handling across millisecond counter rollover, reducing premature or delayed actions.
    • Improved power scheduling, screen timeouts, notifications, pairing windows, LED behavior, and network retries.
    • Improved airtime and utilization reporting after sleep or delayed processing.
    • Improved timestamp handling before and after the device clock becomes trusted.
    • Corrected uptime tracking and GPS fix-hold behavior across clock rollover.
  • Tests

    • Added coverage for timer rollover, uptime tracking, airtime windows, deadlines, GPS fix holds, and timestamp reconciliation.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

This PR adds wrap-safe time helpers, monotonic uptime publication, updated airtime and timestamp handling, migrated deadline checks, native tests, CI enforcement, and timing guidance.

Changes

Rollover-safe time handling

Layer / File(s) Summary
Time and Throttle contracts
src/UptimeClock.*, src/mesh/Throttle.*
Adds monotonic snapshot publication and wrap-safe elapsed/deadline helpers.
Monotonic airtime synchronization
src/airtime.*, test/test_airtime/*
Synchronizes rolling airtime windows from monotonic uptime and tests rotation, decay, sleep gaps, and wrap behavior.
Deadline and elapsed-time migration
src/Power*, src/gps/GPS.cpp, src/graphics/*, src/mesh/Throttle.*, src/modules/*, src/platform/*
Replaces direct timing arithmetic with Throttle helpers and explicit sentinel guards.
RTC and untrusted-clock timestamps
src/gps/RTC.cpp, src/mesh/MeshService.*, src/mesh/NodeDB.*, src/mesh/PhoneAPI.cpp, src/mesh/Router.*
Uses monotonic anchors and uptime placeholders, then backfills epoch timestamps after clock trust.
Tests and CI guardrails
test/*, .github/workflows/test_native.yml, .github/millis-deadline-allowlist.txt, AGENTS.md, .github/copilot-instructions.md
Adds coverage for wraps, sentinels, concurrency, fix holds, timestamp handling, and unsafe millis() comparisons.

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
Loading
sequenceDiagram
  participant Radio
  participant NodeDB
  participant RTC
  Radio->>NodeDB: record uptime placeholder
  RTC->>NodeDB: signal trusted clock
  NodeDB->>NodeDB: backfill last_heard epoch
Loading

Possibly related issues

Possibly related PRs

Suggested labels: requires-protos, enhancement

Suggested reviewers: rcgv1

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary fix for rollover-safe deadline, interval, and timestamp handling.
Description check ✅ Passed The description is detailed and on-topic, but it omits the template's Attestations section and explicit hardware-testing status.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@NomDeTom

Copy link
Copy Markdown
Collaborator Author

@RCGV1 here's my time PR

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Flash this PR in the Web Flasher

firmware commit boards expires

Warning

This is an automated, unreviewed CI test build. Back up your device configuration
before flashing, and only flash devices you are able to recover.

Supported boards built by this PR (14)
Device Board Platform
Heltec HT62 heltec-ht62-esp32c3-sx1262 esp32-c3
Heltec Mesh Node T114 heltec-mesh-node-t114 nrf52840
Heltec V3 heltec-v3 esp32-s3
Raspberry Pi Pico W picow rp2040
RAK WisMesh Pocket V3 rak_wismesh_pocket nrf52840
RAK WisMesh Repeater Mini V2 rak_wismesh_repeater_mini nrf52840
RAK WisBlock 4631 rak4631 nrf52840
Seeed SenseCAP Mesh-Tracker-X1 seeed_mesh_tracker_X1 nrf52840
Seeed Wio Tracker L1 seeed_wio_tracker_L1 nrf52840
Seeed SenseCAP Indicator seeed-sensecap-indicator-tft esp32-s3
LILYGO T-Deck t-deck-tft esp32-s3
LILYGO T-Echo Plus t-echo-plus nrf52840
LilyGo T3-C6 tlora-c6 esp32-c6
Seeed SenseCAP T1000-E tracker-t1000-e nrf52840

Build artifacts expire on 2026-08-30. Updated for 44a53ed.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 7

🧹 Nitpick comments (2)
src/airtime.cpp (1)

78-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the unused lastUtilPeriod markers.

lastUtilPeriod and lastUtilPeriodTX are only assigned in AirTime::syncNow() and never read elsewhere. Remove the declarations from src/airtime.h and the assignments at src/airtime.cpp:78-79 and 134, 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 win

Use the injected clock for lastStateSaveMs.

millis() bypasses the injectable timing path, making this checkpoint uncontrolled by Time::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

📥 Commits

Reviewing files that changed from the base of the PR and between 9c260ad and d84b960.

📒 Files selected for processing (35)
  • .github/copilot-instructions.md
  • .github/millis-deadline-allowlist.txt
  • .github/workflows/test_native.yml
  • AGENTS.md
  • CLAUDE.md
  • src/Power.cpp
  • src/PowerFSMThread.h
  • src/UptimeClock.cpp
  • src/UptimeClock.h
  • src/airtime.cpp
  • src/airtime.h
  • src/gps/GPS.cpp
  • src/graphics/EInkDynamicDisplay.cpp
  • src/graphics/Screen.cpp
  • src/graphics/draw/NotificationRenderer.cpp
  • src/input/RotaryEncoderImpl.cpp
  • src/mesh/NextHopRouter.cpp
  • src/mesh/Throttle.cpp
  • src/mesh/Throttle.h
  • src/mesh/eth/ethClient.cpp
  • src/modules/DropzoneModule.cpp
  • src/modules/ExternalNotificationModule.cpp
  • src/modules/NodeInfoModule.cpp
  • src/modules/StatusLEDModule.cpp
  • src/modules/Telemetry/Sensor/BME680Sensor.cpp
  • src/modules/Telemetry/Sensor/BME680Sensor.h
  • src/motion/MotionSensor.cpp
  • src/platform/extra_variants/t5s3_epaper/variant.cpp
  • src/platform/nrf52/NRF52Bluetooth.cpp
  • src/platform/nrf52/main-nrf52.cpp
  • test/native-suite-count
  • test/test_airtime/test_main.cpp
  • test/test_packet_signing/test_main.cpp
  • test/test_throttle/test_main.cpp
  • test/test_uptime_clock/test_main.cpp
💤 Files with no reviewable changes (1)
  • src/UptimeClock.cpp

Comment thread .github/workflows/test_native.yml
Comment thread src/airtime.cpp
Comment thread src/mesh/NextHopRouter.cpp Outdated
Comment thread src/modules/Telemetry/Sensor/BME680Sensor.cpp Outdated
Comment thread src/motion/MotionSensor.cpp Outdated
Comment thread test/test_packet_signing/test_main.cpp Outdated
Comment thread test/test_throttle/test_main.cpp

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.

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) and Throttle::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-deadline millis() 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.

Comment thread src/modules/Telemetry/Sensor/BME680Sensor.cpp
Comment thread src/motion/MotionSensor.cpp Outdated
@NomDeTom NomDeTom added bugfix Pull request that fixes bugs cleanup Code cleanup or refactor labels Jul 30, 2026
@NomDeTom

Copy link
Copy Markdown
Collaborator Author

@coderabbitai can you check if I've resolved your comments?

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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.cpp

Length of output: 51409


@NomDeTom: Yes — I verified the current PR head, and all five remaining findings are resolved:

  • CI deadline guard now uses set -euo pipefail.
  • Retransmission due checks use the snapshot-aware Throttle::deadlinePassedAt().
  • BME680 save checkpoints are recorded only after a successful write path.
  • The packet-signing test uses Time::getMillis().
  • The rollover test comment correctly states 0x00000090.

The two other findings were already withdrawn as inapplicable. Resolving my review comments now.

[resolve]

You are interacting with an AI system.

@NomDeTom NomDeTom changed the title Fix millis() rollover in deadline and interval comparisons Fix millis() rollover in deadline, interval, and timestamp handling Jul 30, 2026
@NomDeTom
NomDeTom marked this pull request as ready for review July 30, 2026 20:57

@coderabbitai coderabbitai Bot 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.

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 win

Restore test-global duty-cycle state in teardown

test_C14 leaves config.lora.region at EU_868 and airTime->utilizationTX[0] at MS_IN_HOUR when a body assertion fails, because tearDown() 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 win

Rotation copies air_period_* from the airtimes.* arrays.

Lines 107-108 source air_period_tx[i+1]/air_period_rx[i+1] from airtimes.periodTX[i]/periodRX[i] rather than from air_period_tx[i]/air_period_rx[i]. Today the two arrays hold identical values (both incremented in logAirtime), 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

📥 Commits

Reviewing files that changed from the base of the PR and between d84b960 and 105aea6.

📒 Files selected for processing (49)
  • .github/copilot-instructions.md
  • .github/millis-deadline-allowlist.txt
  • .github/workflows/test_native.yml
  • AGENTS.md
  • CLAUDE.md
  • src/Power.cpp
  • src/PowerFSMThread.h
  • src/UptimeClock.cpp
  • src/UptimeClock.h
  • src/airtime.cpp
  • src/airtime.h
  • src/gps/GPS.cpp
  • src/gps/RTC.cpp
  • src/graphics/EInkDynamicDisplay.cpp
  • src/graphics/Screen.cpp
  • src/graphics/draw/NotificationRenderer.cpp
  • src/input/RotaryEncoderImpl.cpp
  • src/mesh/MeshService.cpp
  • src/mesh/MeshService.h
  • src/mesh/NextHopRouter.cpp
  • src/mesh/NodeDB.cpp
  • src/mesh/NodeDB.h
  • src/mesh/PhoneAPI.cpp
  • src/mesh/Router.cpp
  • src/mesh/Router.h
  • src/mesh/Throttle.cpp
  • src/mesh/Throttle.h
  • src/mesh/eth/ethClient.cpp
  • src/modules/DropzoneModule.cpp
  • src/modules/ExternalNotificationModule.cpp
  • src/modules/NodeInfoModule.cpp
  • src/modules/StatusLEDModule.cpp
  • src/modules/Telemetry/DeviceTelemetry.cpp
  • src/modules/Telemetry/DeviceTelemetry.h
  • src/modules/Telemetry/HostMetrics.h
  • src/modules/Telemetry/Sensor/BME680Sensor.cpp
  • src/modules/Telemetry/Sensor/BME680Sensor.h
  • src/motion/MotionSensor.cpp
  • src/platform/extra_variants/t5s3_epaper/variant.cpp
  • src/platform/nrf52/NRF52Bluetooth.cpp
  • src/platform/nrf52/main-nrf52.cpp
  • test/native-suite-count
  • test/test_airtime/test_main.cpp
  • test/test_meshpacket_serializer/ports/test_timestamp.cpp
  • test/test_meshpacket_serializer/test_helpers.h
  • test/test_packet_signing/test_main.cpp
  • test/test_stream_api/test_main.cpp
  • test/test_throttle/test_main.cpp
  • test/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

Comment thread AGENTS.md Outdated
Comment thread src/modules/NodeInfoModule.cpp Outdated
Comment thread src/modules/Telemetry/Sensor/BME680Sensor.cpp

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/UptimeClock.cpp (1)

40-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared extended-timestamp formula to avoid divergence.

getMillisMonotonic() (Line 46) and serviceMonotonic() (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

📥 Commits

Reviewing files that changed from the base of the PR and between 105aea6 and 274e598.

📒 Files selected for processing (14)
  • .github/copilot-instructions.md
  • AGENTS.md
  • src/UptimeClock.cpp
  • src/UptimeClock.h
  • src/airtime.cpp
  • src/gps/GPS.cpp
  • src/main.cpp
  • src/modules/NodeInfoModule.cpp
  • src/modules/NodeInfoModule.h
  • test/native-suite-count
  • test/test_airtime/test_main.cpp
  • test/test_gps_fix_hold/test_main.cpp
  • test/test_packet_signing/test_main.cpp
  • test/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

Comment thread src/gps/GPS.cpp Outdated
Comment thread src/gps/GPS.cpp
Comment thread src/modules/NodeInfoModule.cpp Outdated
Comment thread test/test_gps_fix_hold/test_main.cpp Outdated

@coderabbitai coderabbitai Bot 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 274e598 and 4be26bc.

📒 Files selected for processing (4)
  • src/UptimeClock.cpp
  • src/gps/GPS.cpp
  • src/modules/NodeInfoModule.cpp
  • test/test_gps_fix_hold/test_main.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/modules/NodeInfoModule.cpp

Comment thread test/test_gps_fix_hold/test_main.cpp Outdated

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.

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.

@NomDeTom

Copy link
Copy Markdown
Collaborator Author

@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.

@mcenderdragon

Copy link
Copy Markdown

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

@NomDeTom

Copy link
Copy Markdown
Collaborator Author

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
thebentern enabled auto-merge August 10, 2026 11:06
@NomDeTom
NomDeTom disabled auto-merge August 10, 2026 12:10
@NomDeTom
NomDeTom enabled auto-merge August 10, 2026 12:11
@NomDeTom

Copy link
Copy Markdown
Collaborator Author

@thebentern putting this one back a bit - it was tripping CI. Now ignored.

NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 10, 2026
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/.
@NomDeTom
NomDeTom added this pull request to the merge queue Aug 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 11, 2026
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.
@NomDeTom

Copy link
Copy Markdown
Collaborator Author

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.
@thebentern
thebentern merged commit fdb644e into meshtastic:develop Aug 12, 2026
55 of 62 checks passed
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 13, 2026
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/.
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 13, 2026
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/.
vidplace7 pushed a commit that referenced this pull request Aug 13, 2026
…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.
vidplace7 pushed a commit that referenced this pull request Aug 13, 2026
…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>
@coderabbitai coderabbitai Bot mentioned this pull request Aug 13, 2026
9 tasks
@NomDeTom
NomDeTom deleted the time-handling branch August 23, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Pull request that fixes bugs cleanup Code cleanup or refactor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants