Files
firmware/test/test_meshpacket_queue/test_main.cpp
T
Ben MeadorsandClaude Opus 5 230da77642 fix(time): convert the millis() rollover sites #11291's CI guard cannot 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>
2026-08-13 13:15:21 -04:00

165 lines
5.7 KiB
C++

// Unit tests for MeshPacketQueue::replaceLowerPriorityPacket()'s late-packet branch - the one that
// evicts an overdue packet from a full queue to make room for a new arrival.
//
// tx_after is an absolute millis() deadline, so every decision here has to subtract before comparing
// or it inverts across the 32-bit wrap. The subtlety the cases below pin is that an *elapsed* time
// only orders two deadlines that have both passed: a deadline 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.
//
// maxLen is 1 throughout. That is enough to reach the branch (any enqueue into a full queue goes
// through it) and it keeps CompareMeshPacketFunc out of the picture - std::upper_bound over an
// empty range never invokes the comparator, so the suite needs no NodeDB.
#include "Arduino.h"
#include "TestUtil.h"
#include "UptimeClock.h"
#include "configuration.h"
#include "mesh/MeshPacketQueue.h"
#include "mesh/MeshTypes.h"
#include <cstdlib>
#include <unity.h>
namespace
{
// A packet that is only ever a queue occupant: id and tx_after are all the branch reads.
meshtastic_MeshPacket *makePacket(uint32_t id, uint32_t txAfter)
{
meshtastic_MeshPacket *p = packetPool.allocZeroed();
TEST_ASSERT_NOT_NULL(p);
p->id = id;
p->tx_after = txAfter;
p->priority = meshtastic_MeshPacket_Priority_DEFAULT;
return p;
}
// Drains whatever is still queued back to the pool, so a failing case cannot starve a later one.
void drain(MeshPacketQueue &q)
{
while (meshtastic_MeshPacket *p = q.dequeue())
packetPool.release(p);
}
} // namespace
void setUp(void)
{
Time::setTestMillis(0);
}
void tearDown(void)
{
Time::useRealClock();
}
// The regression: the incoming packet is not due yet, so it must not displace an overdue one.
// `now - p->tx_after` underflows to ~49.7 days of "elapsed", which an unguarded comparison reads as
// the more urgent packet.
static void test_future_incoming_deadline_does_not_evict_an_overdue_packet(void)
{
Time::setTestMillis(1000);
MeshPacketQueue q(1);
meshtastic_MeshPacket *back = makePacket(0x1001, 900); // 100ms overdue
meshtastic_MeshPacket *fresh = makePacket(0x1002, 1100); // 100ms in the future
TEST_ASSERT_TRUE(q.enqueue(back));
TEST_ASSERT_FALSE(q.enqueue(fresh));
TEST_ASSERT_EQUAL_HEX32(0x1001, q.getFront()->id);
packetPool.release(fresh);
drain(q);
}
// The ordering the branch does want: both deadlines have passed and the arrival is the more overdue
// of the two, so the queued packet gives up its slot.
static void test_more_overdue_incoming_packet_evicts_the_late_back_packet(void)
{
Time::setTestMillis(1000);
MeshPacketQueue q(1);
meshtastic_MeshPacket *back = makePacket(0x2001, 900); // 100ms overdue
meshtastic_MeshPacket *fresh = makePacket(0x2002, 800); // 200ms overdue
TEST_ASSERT_TRUE(q.enqueue(back));
TEST_ASSERT_TRUE(q.enqueue(fresh)); // back is released by the queue
TEST_ASSERT_EQUAL_HEX32(0x2002, q.getFront()->id);
drain(q);
}
// The other half of that ordering: a less overdue arrival leaves the queue alone.
static void test_less_overdue_incoming_packet_is_rejected(void)
{
Time::setTestMillis(1000);
MeshPacketQueue q(1);
meshtastic_MeshPacket *back = makePacket(0x3001, 800); // 200ms overdue
meshtastic_MeshPacket *fresh = makePacket(0x3002, 900); // 100ms overdue
TEST_ASSERT_TRUE(q.enqueue(back));
TEST_ASSERT_FALSE(q.enqueue(fresh));
TEST_ASSERT_EQUAL_HEX32(0x3001, q.getFront()->id);
packetPool.release(fresh);
drain(q);
}
// An arrival with no TX delay at all always wins the slot from an overdue packet.
static void test_undelayed_incoming_packet_evicts_the_late_back_packet(void)
{
Time::setTestMillis(1000);
MeshPacketQueue q(1);
meshtastic_MeshPacket *back = makePacket(0x4001, 900);
meshtastic_MeshPacket *fresh = makePacket(0x4002, 0); // no tx_after
TEST_ASSERT_TRUE(q.enqueue(back));
TEST_ASSERT_TRUE(q.enqueue(fresh));
TEST_ASSERT_EQUAL_HEX32(0x4002, q.getFront()->id);
drain(q);
}
// Both deadlines were set before the wrap and `now` is after it, so every raw comparison in the
// branch inverts. The decisions must come out the same as they do away from the boundary.
static void test_decisions_survive_the_millis_wrap(void)
{
// 0xFFFFFF00 and 0xFFFFFE00 are 256ms and 512ms before the wrap; now is 256ms after it.
Time::setTestMillis(0x00000100);
MeshPacketQueue q(1);
meshtastic_MeshPacket *back = makePacket(0x5001, 0xFFFFFF00); // 512ms overdue
meshtastic_MeshPacket *older = makePacket(0x5002, 0xFFFFFE00); // 768ms overdue
TEST_ASSERT_TRUE(q.enqueue(back));
TEST_ASSERT_TRUE(q.enqueue(older));
TEST_ASSERT_EQUAL_HEX32(0x5002, q.getFront()->id);
drain(q);
// ...and a not-yet-due arrival still loses, with the deadline on the far side of the wrap.
MeshPacketQueue q2(1);
meshtastic_MeshPacket *back2 = makePacket(0x5003, 0xFFFFFF00); // 512ms overdue
meshtastic_MeshPacket *fresh = makePacket(0x5004, 0x00000300); // 512ms in the future
TEST_ASSERT_TRUE(q2.enqueue(back2));
TEST_ASSERT_FALSE(q2.enqueue(fresh));
TEST_ASSERT_EQUAL_HEX32(0x5003, q2.getFront()->id);
packetPool.release(fresh);
drain(q2);
}
void setup()
{
delay(10);
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_future_incoming_deadline_does_not_evict_an_overdue_packet);
RUN_TEST(test_more_overdue_incoming_packet_evicts_the_late_back_packet);
RUN_TEST(test_less_overdue_incoming_packet_is_rejected);
RUN_TEST(test_undelayed_incoming_packet_evicts_the_late_back_packet);
RUN_TEST(test_decisions_survive_the_millis_wrap);
exit(UNITY_END());
}
void loop() {}