mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-16 00:10:11 -04:00
* fix(extnotif): make isNagging the only armed flag for the nag cycle
ExternalNotificationModule kept the nag cycle's armed state in two places that
could disagree: the isNagging bool, and nagCycleCutoff reserving UINT32_MAX for
"not armed". handleInputEvent() read only the second one:
if (nagCycleCutoff != UINT32_MAX) { stopNow(); return 1; }
The field is declared `= 1`, while isNagging starts false, so at boot that test
said "armed" when nothing was nagging. The first input event of every boot was
therefore answered with stopNow() and a non-zero return - and a non-zero return
ends the observer chain (Observable::notifyObservers in src/Observer.h returns on
the first one), so that event was swallowed from every later observer. The handler
is registered whenever external_notification.enabled, and InputBroker only
short-circuits while nagging() is true, so the event does reach it.
The same read had a second failure mode once per ~49.7-day wrap: armNagCycle()
computes `millis() + durationMs`, which can land exactly on UINT32_MAX. When it
does, a real nag is running with isNagging true, but this read says "not armed" and
the module's own handler never stops it. Time::skipZero() cannot help here - it
lifts 0 to 1 and leaves UINT32_MAX alone, which src/UptimeClock.h static_asserts.
So the fix is not a zero guard, it is removing the second opinion. isNagging is
the armed flag - which is what the comment above the expiry check already claimed,
and what the other four reads already use - and nagCycleCutoff is now only ever a
deadline, read after isNagging has been checked. Nothing reserves a value, which
matters because an arm site spelled `millis() + interval` can produce any value
there is, so no value is safe to reserve. That is the shape the TODO(deadline-type)
note in src/mesh/Throttle.h is aiming at, and that note is updated to match rather
than keep describing the sentinel this removes.
Worth knowing for review, though not changed here: InputBroker::handleInputEvent
already calls stopNow() itself when nagging() is true, and returns without
notifying observers. Every path that starts a notification calls armNagCycle()
first, so isNagging is true for the whole life of any real nag. That makes this
handler reachable only when there is nothing to stop - its stopNow() was never
doing useful work. Gated rather than deleted, because removing a public handler
and its observer registration is a bigger call than fixing the defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(extnotif): trim comments to the house limit
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nomdetom <nomdetom@protonmail.com>
242 lines
9.0 KiB
C++
242 lines
9.0 KiB
C++
// Unit tests for src/mesh/Throttle.{h,cpp} - the firmware's elapsed-time and deadline helpers.
|
|
//
|
|
// These drive the injected clock across the 32-bit millis() wrap, which is not otherwise reachable
|
|
// in a test, and which every caller of these helpers depends on being handled correctly.
|
|
#include "Arduino.h"
|
|
#include "TestUtil.h"
|
|
#include "UptimeClock.h"
|
|
#include "mesh/Throttle.h"
|
|
#include <cstdint>
|
|
#include <unity.h>
|
|
|
|
void setUp(void) {}
|
|
void tearDown(void)
|
|
{
|
|
Time::useRealClock(); // don't leak the fake clock into other suites
|
|
}
|
|
|
|
// --- basic window semantics ---
|
|
|
|
void test_isWithinTimespan_true_inside_window()
|
|
{
|
|
Time::setTestMillis(10000);
|
|
TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(9500, 1000)); // 500ms elapsed of a 1000ms window
|
|
}
|
|
|
|
void test_isWithinTimespan_false_outside_window()
|
|
{
|
|
Time::setTestMillis(10000);
|
|
TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(8000, 1000)); // 2000ms elapsed
|
|
}
|
|
|
|
// The boundary is exclusive: elapsed == interval is NOT "within".
|
|
void test_isWithinTimespan_boundary_is_exclusive()
|
|
{
|
|
Time::setTestMillis(10000);
|
|
TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(9000, 1000)); // exactly 1000ms elapsed
|
|
TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(9001, 1000)); // 999ms elapsed
|
|
}
|
|
|
|
// --- hasElapsed is the exact complement ---
|
|
|
|
void test_hasElapsed_is_complement_of_isWithinTimespan()
|
|
{
|
|
Time::setTestMillis(10000);
|
|
const uint32_t cases[][2] = {{9500, 1000}, {8000, 1000}, {9000, 1000}, {10000, 1}, {0, 5000}};
|
|
for (auto &c : cases) {
|
|
TEST_ASSERT_EQUAL(!Throttle::isWithinTimespanMs(c[0], c[1]), Throttle::hasElapsed(c[0], c[1]));
|
|
}
|
|
}
|
|
|
|
void test_hasElapsed_boundary_is_inclusive()
|
|
{
|
|
Time::setTestMillis(10000);
|
|
TEST_ASSERT_TRUE(Throttle::hasElapsed(9000, 1000)); // exactly 1000ms elapsed
|
|
TEST_ASSERT_FALSE(Throttle::hasElapsed(9001, 1000)); // 999ms elapsed
|
|
}
|
|
|
|
// --- rollover: the headline property ---
|
|
|
|
// A window opened just before the 32-bit wrap must still close correctly after it.
|
|
void test_isWithinTimespan_survives_millis_wrap()
|
|
{
|
|
const uint32_t lastRun = 0xFFFFFF00u; // 256ms before the wrap
|
|
Time::setTestMillis(lastRun);
|
|
|
|
Time::advanceTestMillis(100); // 0xFFFFFF64 - still before the wrap
|
|
TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, 1000));
|
|
|
|
Time::advanceTestMillis(200); // wraps to 0x0000002C - 300ms elapsed in total
|
|
TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, 1000));
|
|
TEST_ASSERT_FALSE(Throttle::hasElapsed(lastRun, 1000));
|
|
|
|
Time::advanceTestMillis(800); // 1100ms elapsed in total, well past the wrap
|
|
TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(lastRun, 1000));
|
|
TEST_ASSERT_TRUE(Throttle::hasElapsed(lastRun, 1000));
|
|
}
|
|
|
|
// The long-interval end of the range: a 24h window (the longest in the tree) across the wrap.
|
|
void test_long_interval_survives_wrap()
|
|
{
|
|
const uint32_t dayMs = 24u * 60u * 60u * 1000u; // 86,400,000
|
|
const uint32_t lastRun = 0xFFFFFF00u;
|
|
Time::setTestMillis(lastRun);
|
|
|
|
Time::advanceTestMillis(dayMs - 1);
|
|
TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, dayMs));
|
|
|
|
Time::advanceTestMillis(1); // exactly one day elapsed
|
|
TEST_ASSERT_TRUE(Throttle::hasElapsed(lastRun, dayMs));
|
|
}
|
|
|
|
// --- deadlinePassed() ---
|
|
|
|
void test_deadlinePassed_basic()
|
|
{
|
|
Time::setTestMillis(10000);
|
|
TEST_ASSERT_FALSE(Throttle::deadlinePassed(10001)); // 1ms in the future
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(10000)); // exactly now counts as passed
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(9999)); // 1ms in the past
|
|
}
|
|
|
|
// The property the naive `millis() > deadline` compare fails: a deadline set before the wrap must
|
|
// fire once, and only once, after the wrap.
|
|
void test_deadlinePassed_survives_millis_wrap()
|
|
{
|
|
Time::setTestMillis(0xFFFFFF00u); // 256ms before the wrap
|
|
const uint32_t deadline = 0xFFFFFF00u + 500;
|
|
|
|
TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); // not yet
|
|
Time::advanceTestMillis(400); // 0x00000090 - wrapped, still not due
|
|
TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline));
|
|
Time::advanceTestMillis(100); // exactly due, past the wrap
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline));
|
|
Time::advanceTestMillis(60000); // stays passed
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline));
|
|
}
|
|
|
|
// The naive compare's actual failure mode, pinned so a regression is unmistakable: before the wrap
|
|
// the deadline is numerically smaller than now, so `millis() > deadline` would fire it early.
|
|
void test_deadlinePassed_does_not_fire_early_when_deadline_wraps()
|
|
{
|
|
Time::setTestMillis(0xFFFFFF00u);
|
|
const uint32_t deadline = 0xFFFFFF00u + 1000; // wraps to 0x000002E8
|
|
|
|
TEST_ASSERT_TRUE(deadline < Time::getMillis()); // the naive compare would fire here
|
|
TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline));
|
|
}
|
|
|
|
// deadlinePassedAt() judges against a caller-supplied now, so a loop that snapshots the clock once
|
|
// gets one instant for every entry - including across the wrap, where the clock has moved on.
|
|
void test_deadlinePassedAt_uses_the_supplied_now()
|
|
{
|
|
Time::setTestMillis(0xFFFFFF00u);
|
|
const uint32_t now = Time::getMillis();
|
|
const uint32_t deadline = 0xFFFFFF00u + 500; // wraps to 0x000000F4
|
|
|
|
TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline));
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline, deadline)); // inclusive boundary
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline + 1, deadline)); // past the wrap
|
|
Time::advanceTestMillis(60000); // clock moved, snapshot did not
|
|
TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline));
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline));
|
|
}
|
|
|
|
// deadlinePassed() cannot know about sentinels, so it reports them as passed. This pins that
|
|
// contract, since callers relying on it must test armed-ness first.
|
|
void test_deadlinePassed_reads_disarmed_sentinels_as_passed()
|
|
{
|
|
Time::setTestMillis(6247);
|
|
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al
|
|
|
|
// UINT32_MAX is not a usable "far future" either - at a low uptime it is a hair BEHIND now, so
|
|
// it reads as passed like any other past value. ExternalNotificationModule used to reserve it
|
|
// for "unarmed" and now keeps that state in its isNagging flag instead.
|
|
TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX));
|
|
|
|
// The guarded form every caller must use.
|
|
const uint32_t disarmed = 0;
|
|
TEST_ASSERT_FALSE(disarmed && Throttle::deadlinePassed(disarmed));
|
|
|
|
// And it still holds after a wrap.
|
|
Time::setTestMillis(0xFFFFFF00u);
|
|
Time::advanceTestMillis(1000);
|
|
TEST_ASSERT_FALSE(disarmed && Throttle::deadlinePassed(disarmed));
|
|
}
|
|
|
|
// --- execute() ---
|
|
|
|
static int executeCount = 0;
|
|
static int deferCount = 0;
|
|
static void countExecute()
|
|
{
|
|
executeCount++;
|
|
}
|
|
static void countDefer()
|
|
{
|
|
deferCount++;
|
|
}
|
|
|
|
void test_execute_runs_first_time_then_throttles()
|
|
{
|
|
executeCount = 0;
|
|
deferCount = 0;
|
|
Time::setTestMillis(5000);
|
|
|
|
uint32_t last = 0; // 0 means "never run" to execute()
|
|
TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute, countDefer));
|
|
TEST_ASSERT_EQUAL(1, executeCount);
|
|
|
|
// Immediately again: deferred.
|
|
TEST_ASSERT_FALSE(Throttle::execute(&last, 1000, countExecute, countDefer));
|
|
TEST_ASSERT_EQUAL(1, executeCount);
|
|
TEST_ASSERT_EQUAL(1, deferCount);
|
|
|
|
// After the interval: runs again.
|
|
Time::advanceTestMillis(1000);
|
|
TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute, countDefer));
|
|
TEST_ASSERT_EQUAL(2, executeCount);
|
|
}
|
|
|
|
void test_execute_survives_millis_wrap()
|
|
{
|
|
executeCount = 0;
|
|
Time::setTestMillis(0xFFFFFF00u);
|
|
|
|
uint32_t last = 0;
|
|
TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute)); // arms at 0xFFFFFF00
|
|
TEST_ASSERT_EQUAL(1, executeCount);
|
|
|
|
Time::advanceTestMillis(500); // wraps past 0
|
|
TEST_ASSERT_FALSE(Throttle::execute(&last, 1000, countExecute)); // not due yet
|
|
TEST_ASSERT_EQUAL(1, executeCount);
|
|
|
|
Time::advanceTestMillis(600); // 1100ms total
|
|
TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute));
|
|
TEST_ASSERT_EQUAL(2, executeCount);
|
|
}
|
|
|
|
void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_isWithinTimespan_true_inside_window);
|
|
RUN_TEST(test_isWithinTimespan_false_outside_window);
|
|
RUN_TEST(test_isWithinTimespan_boundary_is_exclusive);
|
|
RUN_TEST(test_hasElapsed_is_complement_of_isWithinTimespan);
|
|
RUN_TEST(test_hasElapsed_boundary_is_inclusive);
|
|
RUN_TEST(test_isWithinTimespan_survives_millis_wrap);
|
|
RUN_TEST(test_long_interval_survives_wrap);
|
|
RUN_TEST(test_deadlinePassed_basic);
|
|
RUN_TEST(test_deadlinePassed_survives_millis_wrap);
|
|
RUN_TEST(test_deadlinePassed_does_not_fire_early_when_deadline_wraps);
|
|
RUN_TEST(test_deadlinePassedAt_uses_the_supplied_now);
|
|
RUN_TEST(test_deadlinePassed_reads_disarmed_sentinels_as_passed);
|
|
RUN_TEST(test_execute_runs_first_time_then_throttles);
|
|
RUN_TEST(test_execute_survives_millis_wrap);
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|