Skip to content

Commit 332f5c7

Browse files
committed
Address review feedback on the rollover fixes
- BME680Sensor: checkpoint lastStateSaveMs after a successful write instead of at the interval test. The first save (IAQ accuracy >= 2) left it at 0, timing the next save from boot, and stamping before the write deferred the retry a full period when the write failed. Reads Time::getMillis(), the same clock Throttle compares against. - Throttle: add deadlinePassedAt(now, deadline) for loops that snapshot the clock once and test many deadlines; deadlinePassed() now delegates to it. NextHopRouter::doRetransmissions() uses it, replacing the inline half-range compare adopted from #10227 (nightjoker7) - same arithmetic, credited at the call site - and takes its snapshot from Time::getMillis() so setNextTx() deadlines and the due test cannot diverge under an injected test clock. - test_native.yml: set -euo pipefail in the millis-deadline guard, matching the sibling suite-count job. Without -e a partially failed scan could report "no violations" from truncated output. - test_packet_signing: build the not-due deadline from Time::getMillis() rather than millis(), so the test and the router read one clock. - test_throttle: cover deadlinePassedAt(), and correct a wrapped-value comment (0xFFFFFF00 + 400 is 0x00000090, not 0x00000094). Two review comments were declined: the AirTime mutex (every airTime-> caller runs in the single cooperative loop, WebServerThread included) and the MotionSensor 0-sentinel countdown (the calibration frame is only installed while a window is open). clod helped out here
1 parent d84b960 commit 332f5c7

9 files changed

Lines changed: 48 additions & 21 deletions

File tree

.github/copilot-instructions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,9 @@ firmware/
341341
- `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`.
342342
- `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire.
343343
- `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event". Uses an unsigned half-range compare; reads deadlines more than ~24.8 days out as already passed, which no interval in this firmware approaches (the longest is 24 h).
344+
- `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and then tests many deadlines (`NextHopRouter::doRetransmissions()`). Take the snapshot from `Time::getMillis()`, not `millis()`.
344345

345-
Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: for ~24 days after the 32-bit wrap it either stalls the action or fires it immediately. All four helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), which means every one of its ~94 call sites is time-injectable - a native test can drive `Time::setTestMillis(0xFFFFFF00)` across the wrap. There is deliberately no 64-bit millis; see the note in `UptimeClock.h`.
346+
Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: for ~24 days after the 32-bit wrap it either stalls the action or fires it immediately. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), which means every one of its ~94 call sites is time-injectable - a native test can drive `Time::setTestMillis(0xFFFFFF00)` across the wrap. There is deliberately no 64-bit millis; see the note in `UptimeClock.h`.
346347

347348
**Sentinel hazard.** If a deadline variable also encodes "inactive" - `0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff` - test that sentinel _before_ the elapsed comparison. Every such value is arithmetically far in the past, so a correct comparison reads it as "expired" and fires immediately: `rebootAtMsec = -1` meaning "never" is what would have become a reboot loop. Write `if (deadline && Throttle::deadlinePassed(deadline))`, and never fold the sentinel into the helper.
348349

.github/workflows/test_native.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
- name: Reject millis() used directly in a deadline comparison
7373
shell: bash
7474
run: |
75-
set -uo pipefail
75+
set -euo pipefail
7676
allowlist=".github/millis-deadline-allowlist.txt"
7777
7878
# Flag millis() directly adjacent to a comparison operator, in either order. The correct

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
8686
- `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`.
8787
- `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire.
8888
- `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event".
89+
- `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and tests many deadlines. Snapshot from `Time::getMillis()`.
8990

90-
Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: for ~24 days after the 32-bit wrap it either stalls the action or fires it immediately. All four helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), so all ~94 of its call sites are time-injectable and a native test can drive the wrap with `Time::setTestMillis()`.
91+
Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: for ~24 days after the 32-bit wrap it either stalls the action or fires it immediately. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), so all ~94 of its call sites are time-injectable and a native test can drive the wrap with `Time::setTestMillis()`.
9192

9293
**Sentinel hazard.** If a deadline variable also encodes "inactive" (`0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff`), test that sentinel _before_ the elapsed comparison - every such value is arithmetically far in the past, so a correct comparison fires on it immediately. Write `if (deadline && Throttle::deadlinePassed(deadline))`.
9394

src/mesh/NextHopRouter.cpp

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#include "NextHopRouter.h"
22
#include "Default.h"
33
#include "MeshTypes.h"
4+
#include "Throttle.h"
5+
#include "UptimeClock.h"
46
#include "meshUtils.h"
57
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
68
#include "modules/TraceRouteModule.h"
@@ -394,7 +396,9 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint
394396
*/
395397
int32_t NextHopRouter::doRetransmissions()
396398
{
397-
uint32_t now = millis();
399+
// Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an
400+
// injected test clock.
401+
uint32_t now = Time::getMillis();
398402
int32_t d = INT32_MAX;
399403

400404
// FIXME, we should use a better datastructure rather than walking through this map.
@@ -405,16 +409,9 @@ int32_t NextHopRouter::doRetransmissions()
405409

406410
bool stillValid = true; // assume we'll keep this record around
407411

408-
// Use unsigned half-range comparison so retransmission timing stays correct across the
409-
// ~49.7 day millis() wraparound (previously this FIXME would stall all retx for the
410-
// duration of the wrap or fire them all at once immediately after).
411-
//
412-
// Casting an unsigned difference to int32_t for a "time passed" test is
413-
// implementation-defined in C++ when the value exceeds INT32_MAX. The unsigned
414-
// half-range form below is fully well-defined: nextTxMsec is in the past (or is now)
415-
// iff (now - nextTxMsec) has not wrapped past 2^31 ms. Anything further in the
416-
// future wraps into the top half and reads as "not yet."
417-
if ((uint32_t)(now - p.nextTxMsec) < 0x80000000u) {
412+
// Half-range compare from #10227 (nightjoker7), now via Throttle: judged against the
413+
// snapshot above, so one pass sees one instant and the 49.7 day wrap can't stall retx.
414+
if (Throttle::deadlinePassedAt(now, p.nextTxMsec)) {
418415
if (p.numRetransmissions == 0) {
419416
if (isFromUs(p.packet)) {
420417
LOG_DEBUG("Reliable send failed, returning a nak for fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from,
@@ -510,7 +507,7 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
510507
{
511508
assert(iface);
512509
auto d = iface->getRetransmissionMsec(pending->packet);
513-
pending->nextTxMsec = millis() + d;
510+
pending->nextTxMsec = Time::getMillis() + d;
514511
LOG_DEBUG("Setting next retransmission in %u msecs: ", d);
515512
printPacket("", pending->packet);
516513
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time

src/mesh/Throttle.cpp

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,5 @@ bool Throttle::isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t timeSpanMs)
4141
/// See the header for the range limit and the sentinel requirement.
4242
bool Throttle::deadlinePassed(uint32_t deadlineMs)
4343
{
44-
// Unsigned half-range rather than a cast to int32_t, which is implementation-defined once the
45-
// difference exceeds INT32_MAX. Deadlines further ahead than 2^31 ms land in the top half.
46-
return (uint32_t)(Time::getMillis() - deadlineMs) < 0x80000000u;
44+
return deadlinePassedAt(Time::getMillis(), deadlineMs);
4745
}

src/mesh/Throttle.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,13 @@ class Throttle
2828
/// for that separately, first: every such value is arithmetically far in the past, so it reads
2929
/// as passed.
3030
static bool deadlinePassed(uint32_t deadlineMs);
31+
32+
/// deadlinePassed() against a caller-supplied "now", for a loop that snapshots the time once and
33+
/// tests many deadlines against it. Same range limit and sentinel rules as above.
34+
static bool deadlinePassedAt(uint32_t nowMs, uint32_t deadlineMs)
35+
{
36+
// Passed iff now - deadline has not wrapped past 2^31 ms; further-ahead deadlines land in
37+
// the top half. Not an int32_t cast, which is implementation-defined beyond INT32_MAX.
38+
return (uint32_t)(nowMs - deadlineMs) < 0x80000000u;
39+
}
3140
};

src/modules/Telemetry/Sensor/BME680Sensor.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "FSCommon.h"
88
#include "SPILock.h"
99
#include "TelemetrySensor.h"
10+
#include "UptimeClock.h"
1011
#include "mesh/Throttle.h"
1112

1213
#if __has_include(<Adafruit_BME680.h>)
@@ -169,7 +170,6 @@ void BME680Sensor::updateState()
169170
LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000);
170171
update = true;
171172
stateUpdateCounter++;
172-
lastStateSaveMs = millis();
173173
}
174174
}
175175

@@ -184,6 +184,9 @@ void BME680Sensor::updateState()
184184
file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE);
185185
file.flush();
186186
file.close();
187+
// Checkpoint on success only: stamping at the interval test left the first save at 0
188+
// (next save timed from boot) and deferred the retry a full period after a failed write.
189+
lastStateSaveMs = Time::getMillis();
187190
} else {
188191
LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName);
189192
}

test/test_packet_signing/test_main.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
// compiled out unless both PKI and XEdDSA are enabled (e.g. stm32 sets MESHTASTIC_EXCLUDE_XEDDSA).
2323
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
2424

25+
#include "UptimeClock.h"
2526
#include "mesh/Channels.h"
2627
#include "mesh/CryptoEngine.h"
2728
#include "mesh/MeshRadio.h"
@@ -1217,7 +1218,7 @@ void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void)
12171218
// "Far future, so no retransmission is due." Must be a representable future time, not
12181219
// UINT32_MAX: doRetransmissions() compares with an unsigned half-range test, under which
12191220
// UINT32_MAX is ~1ms in the *past* and would fire a retransmit and rewrite nextTxMsec.
1220-
const uint32_t notDueTxMsec = millis() + 3600000UL;
1221+
const uint32_t notDueTxMsec = Time::getMillis() + 3600000UL;
12211222
pipelineRouter->addPending(prior, notDueTxMsec);
12221223
const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard;
12231224

test/test_throttle/test_main.cpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ void test_deadlinePassed_survives_millis_wrap()
107107
const uint32_t deadline = 0xFFFFFF00u + 500;
108108

109109
TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); // not yet
110-
Time::advanceTestMillis(400); // 0x00000094 - wrapped, still not due
110+
Time::advanceTestMillis(400); // 0x00000090 - wrapped, still not due
111111
TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline));
112112
Time::advanceTestMillis(100); // exactly due, past the wrap
113113
TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline));
@@ -126,6 +126,22 @@ void test_deadlinePassed_does_not_fire_early_when_deadline_wraps()
126126
TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline));
127127
}
128128

129+
// deadlinePassedAt() judges against a caller-supplied now, so a loop that snapshots the clock once
130+
// gets one instant for every entry - including across the wrap, where the clock has moved on.
131+
void test_deadlinePassedAt_uses_the_supplied_now()
132+
{
133+
Time::setTestMillis(0xFFFFFF00u);
134+
const uint32_t now = Time::getMillis();
135+
const uint32_t deadline = 0xFFFFFF00u + 500; // wraps to 0x000000F4
136+
137+
TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline));
138+
TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline, deadline)); // inclusive boundary
139+
TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline + 1, deadline)); // past the wrap
140+
Time::advanceTestMillis(60000); // clock moved, snapshot did not
141+
TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline));
142+
TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline));
143+
}
144+
129145
// deadlinePassed() cannot know about sentinels, so it reports them as passed. This pins that
130146
// contract, since callers relying on it must test armed-ness first.
131147
void test_deadlinePassed_reads_disarmed_sentinels_as_passed()
@@ -211,6 +227,7 @@ void setup()
211227
RUN_TEST(test_deadlinePassed_basic);
212228
RUN_TEST(test_deadlinePassed_survives_millis_wrap);
213229
RUN_TEST(test_deadlinePassed_does_not_fire_early_when_deadline_wraps);
230+
RUN_TEST(test_deadlinePassedAt_uses_the_supplied_now);
214231
RUN_TEST(test_deadlinePassed_reads_disarmed_sentinels_as_passed);
215232
RUN_TEST(test_execute_runs_first_time_then_throttles);
216233
RUN_TEST(test_execute_survives_millis_wrap);

0 commit comments

Comments
 (0)