Retire expired tracker awake leases before foreground deferrals

This commit is contained in:
mikecarper committed 2026-10-16 18:37:24 -07:00
1 parent eda70c832b
commit 8e13fccdd1
6 files changed
+228 -7

No files matched your search

+5
View File
@@ -1492,6 +1492,11 @@ bool SensorMesh::getGPS(uint8_t channel, float& lat, float& lon, float& alt) {
void SensorMesh::loop() {
#if MESH_ENABLE_SENSOR_TRACKER
// Retire observed expiry even during Morse deferral. A retained deadline
// otherwise looks future again after half the 32-bit millis range.
if (tracker_awake_until && millisHasNowPassed(tracker_awake_until)) {
tracker_awake_until = 0;
}
if (!mesh::morseTimingActive(_radio) && tracker.enabled && trackerNeedsRadio()) wakeTrackerRadio();
#endif
_cli.loop();
+113 -3
View File
@@ -29,11 +29,12 @@ static void configure(SensorMesh& mesh) {
assert(mesh.alert1.text[0] == 0 && mesh.alert2.text[0] == 0);
assert(!mesh.next_local_advert && !mesh.next_flood_advert);
}
static Packet* queueCheck(SensorMesh& mesh, bool gps = true) {
mesh.serviceTracker();
static Packet* queueCheck(SensorMesh& mesh, bool gps = true, bool foreground = false) {
const auto service = [&]() { if (foreground) mesh.loop(); else mesh.serviceTracker(); };
service();
assert(mesh.tracker_acquiring && !mesh.tracker_pending && sensors.pending);
if (gps) sensors.fix(); else { sensors.pending = false; sensors.fresh = false; }
mesh.serviceTracker();
service();
assert(!mesh.tracker_acquiring && mesh.tracker_pending && !mesh.tracker_sent);
assert(mesh.pool.queued.size() == 1);
assert(mesh.last_payload.size() == 4 + RequestLength);
@@ -164,6 +165,112 @@ static void cadence() {
++scenarios;
}
static void awakeExpiry() {
for (uint32_t started : {100U, UINT32_MAX - 60000U}) {
for (unsigned path = 0; path < 4; ++path) {
SensorMesh mesh; mesh.now_ms = started; configure(mesh);
assert(command(mesh, "set tracker.lost.interval 86400") == "OK");
queueCheck(mesh, true, true); transmitted(mesh); assert(response(mesh, Lost, 120));
const auto before = mesh.tracker;
const auto writes = mesh.fs.write_opens;
const uint32_t deadline = uint32_t(started + 120000U);
mesh.now_ms = deadline; mesh.rtc.now += 120;
mesh.loop();
assert(mesh.tracker_awake_until == deadline && mesh.trackerNeedsRadio());
assert(!mesh.tracker_radio_asleep); // Production expiry is strictly > 0.
if (path == 1) {
mesh.tracker_radio_asleep = mesh.radio.sleeping = true; mesh.available = false;
} else if (path == 2) mesh.radio.morse_tx = true;
else if (path == 3) mesh.radio.morse_rx = true;
const auto wakes = mesh.radio.wakes, loops = mesh._clock_sync.loops;
const auto cli_loops = mesh._cli.loops, base_loops = mesh.base_loops;
++mesh.now_ms; mesh.loop();
assert(mesh._cli.loops == cli_loops + 1 && mesh.base_loops == base_loops + 1);
assert(!mesh.tracker_awake_until && "observed expiry must retire the tracker awake lease");
assert(mesh.trackerNeedsRadio() == (path >= 2) && mesh.radio.wakes == wakes);
assert(mesh.tracker.last_lost == Lost && mesh.tracker.generation == before.generation);
assert(mesh.tracker_next_check == mesh.rtc.now - 120 + 86400);
assert(mesh.fs.write_opens == writes && !mesh.tracker_pending && !mesh.tracker_acquiring);
if (path < 2) assert(mesh.tracker_radio_asleep && !mesh.available);
else assert(mesh._clock_sync.loops == loops); // Still defers optional Morse work.
// Observation of expiry, not a half-cycle without servicing, is the
// supported guarantee. A retained deadline used to become future again.
mesh.now_ms += 0x80000000U; mesh.loop();
assert(mesh.trackerNeedsRadio() == (path >= 2) && !mesh.tracker_awake_until);
assert(mesh.radio.wakes == wakes && mesh.tracker.last_lost == Lost);
mesh.radio.morse_tx = mesh.radio.morse_rx = false; mesh.loop();
assert(!mesh.trackerNeedsRadio() && mesh.tracker_radio_asleep);
++scenarios;
}
}
}
static void awakeOwners() {
SensorMesh mesh; configure(mesh);
assert(command(mesh, "set tracker.lost.interval 86400") == "OK");
queueCheck(mesh, true, true); transmitted(mesh); assert(response(mesh, Lost, 120));
mesh.now_ms += 120001; mesh.rtc.now += 120; mesh.loop();
assert(!mesh.tracker_awake_until && mesh.tracker_radio_asleep);
const auto next = mesh.tracker_next_check;
// The actual local-command lease runs before the following foreground pass.
mesh.localConsoleLease(1); assert(!mesh.tracker_awake_until && mesh.tracker_radio_asleep);
mesh.localConsoleLease(0); mesh.loop();
assert(mesh.tracker_awake_until == uint32_t(mesh.now_ms + 120000));
assert(mesh.trackerNeedsRadio() && !mesh.tracker_radio_asleep && mesh.tracker_next_check == next);
// Renewal during CLI service must survive retirement of the preceding lease.
mesh.now_ms += 120001; mesh.rtc.now += 120; mesh._cli.local_command = true; mesh.loop();
assert(mesh.tracker_awake_until == uint32_t(mesh.now_ms + 120000) && mesh.trackerNeedsRadio());
bool* owners[] = {&mesh.board.usb, &mesh.board.ota, &mesh.board.test, &mesh.temporary,
&mesh.pending_ota, &mesh.dual, &mesh.saved_radio_apply_pending, &mesh._cli.active,
&mesh.queue_wake, &mesh.retry_wake, &mesh::test_logging_watchdog};
for (bool* owner : owners) {
*owner = true; mesh.tracker_awake_until = mesh.now_ms - 1; mesh.loop();
assert(!mesh.tracker_awake_until && mesh.trackerNeedsRadio() && !mesh.tracker_radio_asleep);
assert(mesh.tracker.last_lost == Lost && mesh.tracker_next_check == next);
*owner = false;
}
for (bool pending : {false, true}) {
mesh.tracker_next_check = mesh.rtc.now; mesh.loop();
assert(mesh.tracker_acquiring && sensors.pending);
if (pending) { sensors.fix(); mesh.loop(); transmitted(mesh); }
const auto tag = mesh.tracker_pending_tag;
mesh.tracker_awake_until = mesh.now_ms - 1; mesh.loop();
assert(!mesh.tracker_awake_until && mesh.trackerNeedsRadio() && !mesh.tracker_radio_asleep);
assert(mesh.tracker_pending == pending && mesh.tracker_acquiring == !pending);
assert(mesh.tracker_pending_tag == tag && mesh.tracker.last_lost == Lost);
if (pending) assert(response(mesh, Lost, 300));
else { mesh.tracker_acquiring = false; sensors.pending = false; }
}
mesh.loop(); assert(mesh.tracker_awake_until == uint32_t(mesh.now_ms + 300000));
++scenarios;
}
static void awakeLongCadence() {
for (unsigned stayawake : {0U, 120U}) for (bool unknown : {false, true}) {
SensorMesh mesh; configure(mesh);
assert(command(mesh, "set tracker.lost.interval 86400") == "OK");
const uint32_t origin = mesh.rtc.now; uint64_t elapsed_ms = 0;
const auto advance = [&](uint64_t delta) {
elapsed_ms += delta; mesh.now_ms = uint32_t(100 + elapsed_ms);
mesh.rtc.now = origin + uint32_t(elapsed_ms / 1000);
};
queueCheck(mesh, true, true); transmitted(mesh); assert(response(mesh, Lost, stayawake));
advance(120001); mesh.loop();
assert(!mesh.trackerNeedsRadio() && mesh.tracker_radio_asleep);
for (unsigned day = 1; day <= 26; ++day) {
const uint64_t due_ms = uint64_t(mesh.tracker_next_check - origin) * 1000;
advance(due_ms - elapsed_ms); queueCheck(mesh, true, true); transmitted(mesh);
if (unknown && day == 1) assert(response(mesh, Unknown));
else { advance(20001); mesh.loop(); }
assert(!mesh.tracker_pending && !mesh.tracker_acquiring && mesh.tracker.last_lost == Lost);
mesh.loop();
assert(!mesh.trackerNeedsRadio() && mesh.tracker_radio_asleep
&& "expired tracker awake lease must not revive after signed half-wrap");
}
++scenarios;
}
}
static void timing() {
{ SensorMesh mesh; configure(mesh); queueCheck(mesh);
assert(!response(mesh, Lost, 120)); // A reply cannot start before the send callback.
@@ -551,6 +658,9 @@ int main(int argc, char** argv) {
assert(argc == 2); const std::string name = argv[1];
if (name == "commands") commands();
else if (name == "cadence") cadence();
else if (name == "awake-expiry") awakeExpiry();
else if (name == "awake-owners") awakeOwners();
else if (name == "awake-long") awakeLongCadence();
else if (name == "timing") timing();
else if (name == "identity") identityAndNonce();
else if (name == "paths") paths();
+23 -2
View File
@@ -32,6 +32,8 @@ struct LocalIdentity : Identity {
class Mesh {
public:
unsigned base_send_complete = 0, base_send_fail = 0;
unsigned base_loops = 0;
void loop() { ++base_loops; }
void onSendComplete(Packet*) { ++base_send_complete; }
void onSendFail(Packet*) { ++base_send_fail; }
};
@@ -51,6 +53,9 @@ struct FakeBoard {
};
struct FakeRadio {
bool permit_sleep = true, permit_wake = true, permit_recovery = true, sleeping = false;
bool morse_tx = false, morse_rx = false;
bool isMorseTxActive() const { return morse_tx; }
bool isMorseRxActive() const { return morse_rx; }
unsigned sleeps = 0, wakes = 0, wake_attempts = 0, hard_recoveries = 0;
bool setTrackerSleep(bool value) {
if (!value) ++wake_attempts;
@@ -167,9 +172,14 @@ public:
struct NoMorseReply { void complete(const void*) {} void fail(const void*) {} } morse;
NoMorseReply& morseReply() { return morse; }
bool active = false;
bool local_command = false;
unsigned loops = 0;
SensorMesh* owner = nullptr;
void loop();
bool hasActiveUserGpioTimer() const { return active; }
} _cli;
struct ClockSync { unsigned observed = 0;
struct ClockSync { unsigned observed = 0, loops = 0;
void loop() { ++loops; }
void observeAcceptedFlood(const mesh::Packet*) { ++observed; } } _clock_sync;
uint32_t set_radio_at = 0, revert_radio_at = 0;
bool saved_radio_apply_pending = false, temporary = false, pending_ota = false, dual = false;
@@ -190,6 +200,7 @@ public:
sensors.clock = &now_ms;
metadata_filesystem = &fs;
self_id.pub_key[0] = 0xf1;
_cli.owner = this;
}
FakeRTC* getRTCClock() const { return const_cast<FakeRTC*>(&rtc); }
unsigned long futureMillis(int delta) const { return uint32_t(now_ms + uint32_t(delta)); }
@@ -199,7 +210,7 @@ public:
bool hasPendingOtaApply() const { return pending_ota; }
bool isDualRadioActive() const { return dual; }
bool getNextQueueWakeDelay(uint32_t& value) const { value = 1; return queue_wake; }
bool hasPendingRadioWork() const { return queue_wake; }
bool hasPendingRadioWork() const { return queue_wake || radio.morse_tx || radio.morse_rx; }
bool getNextBeaconWakeDelay(uint32_t&) const { return false; }
bool getNextRetryWakeDelay(uint32_t& value) const { value = 1; return retry_wake; }
void setRadioAvailable(bool value) { available = value; }
@@ -248,6 +259,8 @@ public:
void sendZeroHop(mesh::Packet* p, int) { ++adverts; ++zero_hops; releasePacket(p); }
bool isTrackerModeEnabled() const { return tracker.enabled; }
bool handleTrackerCommand(uint32_t, const char*, char*);
void localConsoleLease(uint32_t);
void loop();
void configureTrackerRuntime();
bool saveTrackerRecord(mesh::tracker::TrackerRecord&);
bool trackerNeedsRadio() const;
@@ -266,3 +279,11 @@ public:
bool allowPacketForward(const mesh::Packet*);
void sendSelfAdvertisement(int, bool);
};
inline void SensorMesh::CLI::loop() {
++loops;
if (local_command) {
owner->localConsoleLease(0);
local_command = false;
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ public:
: mesh::Mesh(radio, time, manager), clock(time), radio_driver(radio) { begin(); }
RTC* getRTCClock() const { return const_cast<RTC*>(&rtc); }
bool millisHasNowPassed(unsigned long timestamp) const {
return int32_t(clock.now - uint32_t(timestamp)) >= 0;
return int32_t(clock.now - uint32_t(timestamp)) > 0;
}
bool isAnyTempRadioActive() const { return false; }
bool hasPendingOtaApply() const { return false; }
+46 -1
View File
@@ -19,7 +19,21 @@ SANITIZERS = (["-fsanitize=address,undefined,float-cast-overflow", "-fno-sanitiz
def peer_methods(source):
methods = ""
policy = (ROOT / "src/helpers/MorseLoopPolicy.h").read_text(encoding="ascii")
methods = "namespace mesh {\n" + extract_braced(
policy, "inline bool morseTimingActive(").replace(
"const Radio* radio", "const ::FakeRadio* radio") + "\n}\n"
# Execute the actual foreground ordering through tracker service, including
# the Morse early return. Later sensor/advert/persistence work is unrelated.
loop = extract_braced(source, "void SensorMesh::loop()")
boundary = "\n if (next_flood_advert &&"
if loop.count(boundary) != 1:
raise AssertionError("tracker foreground service boundary changed")
methods += loop[:loop.index(boundary)] + "}\n"
# Preserve the existing local-console lease renewal, including its sender
# check. Command parsing and its timestamp normalization remain separate.
methods += "void SensorMesh::localConsoleLease(uint32_t sender_timestamp) {\n"
methods += extract_braced(source, "if (sender_timestamp == 0 && tracker.enabled)") + "\n}\n"
for signature in ("int SensorMesh::searchPeersByHash(", "void SensorMesh::getPeerSharedSecret(",
"bool SensorMesh::allowPacketForward(", "void SensorMesh::sendSelfAdvertisement("):
methods += extract_braced(source, signature) + "\n"
@@ -93,6 +107,37 @@ class SensorTrackerClientTests(unittest.TestCase):
def test_safe_lost_unknown_intervals_and_awake_lease(self):
self.run_group("cadence")
def test_awake_lease_strict_expiry_wrap_parked_and_morse_paths(self):
self.run_group("awake-expiry")
def test_expiry_preserves_other_owners_and_console_renewal(self):
self.run_group("awake-owners")
def test_expired_awake_lease_stays_retired_during_26_days_of_checks(self):
self.run_group("awake-long")
def test_negative_control_expired_awake_lease_resurrects(self):
guard = extract_braced(self.sensor,
"if (tracker_awake_until && millisHasNowPassed(tracker_awake_until))")
mutated = self.sensor.replace(guard, "", 1)
self.run_group("awake-long", self.build("retained-awake-lease", self.tracker, mutated), success=False)
def test_negative_control_parked_radio_must_retire_expired_lease(self):
guard = "if (tracker_awake_until && millisHasNowPassed(tracker_awake_until))"
self.assertEqual(self.sensor.count(guard), 1)
mutated = self.sensor.replace(guard,
"if (!tracker_radio_asleep && tracker_awake_until && millisHasNowPassed(tracker_awake_until))", 1)
self.run_group("awake-expiry", self.build("parked-awake-lease", self.tracker, mutated), success=False)
def test_negative_control_morse_cannot_defer_lease_retirement(self):
guard = extract_braced(self.sensor,
"if (tracker_awake_until && millisHasNowPassed(tracker_awake_until))")
mutated = self.sensor.replace(guard, "", 1)
after_morse = " _clock_sync.loop();"
self.assertEqual(mutated.count(after_morse), 1)
mutated = mutated.replace(after_morse, guard + "\n" + after_morse, 1)
self.run_group("awake-expiry", self.build("morse-awake-lease", self.tracker, mutated), success=False)
def test_reply_window_starts_after_transmission_and_wraps(self):
self.run_group("timing")
+40
View File
@@ -311,6 +311,46 @@ class SensorTrackerSleepTest(unittest.TestCase):
with self.subTest(defines=defines):
self.compile_run(source, defines)
def test_actual_foreground_loop_without_tracker_has_no_tracker_dependencies(self):
sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text(encoding="ascii")
loop = extract_braced(sensor, "void SensorMesh::loop()")
boundary = "\n if (next_flood_advert &&"
self.assertEqual(loop.count(boundary), 1)
loop = loop[:loop.index(boundary)] + "}\n"
policy = (ROOT / "src/helpers/MorseLoopPolicy.h").read_text(encoding="ascii")
source = r'''
#include <Arduino.h>
#include <cassert>
namespace mesh {
struct Radio {
bool tx=false,rx=false;
bool isMorseTxActive()const{return tx;}
bool isMorseRxActive()const{return rx;}
};
struct Mesh {unsigned base_loops=0;void loop(){++base_loops;}};
@MORSE@
}
struct SensorMesh:mesh::Mesh {
mesh::Radio radio;mesh::Radio* _radio=&radio;
struct Service {unsigned loops=0;void loop(){++loops;}} _cli,_clock_sync;
void loop();
};
@LOOP@
int main(){
SensorMesh mesh;
mesh.loop();assert(mesh._cli.loops==1&&mesh.base_loops==1&&mesh._clock_sync.loops==1);
mesh.radio.tx=true;mesh.loop();
assert(mesh._cli.loops==2&&mesh.base_loops==2&&mesh._clock_sync.loops==1);
mesh.radio.tx=false;mesh.radio.rx=true;mesh.loop();
assert(mesh._cli.loops==3&&mesh.base_loops==3&&mesh._clock_sync.loops==1);
mesh.radio.rx=false;mesh.loop();
assert(mesh._cli.loops==4&&mesh.base_loops==4&&mesh._clock_sync.loops==2);
}
'''
source = source.replace("@MORSE@", extract_braced(
policy, "inline bool morseTimingActive(")).replace("@LOOP@", loop)
self.compile_run(source, ("MESH_ENABLE_SENSOR_TRACKER=0",))
def cross_layer_source(self, mutation=None):
sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text(encoding="ascii")
tracker = (ROOT / "examples/simple_sensor/Tracker.cpp").read_text(encoding="ascii")