From 8e13fccdd1bc7395faf65c4df41d0c085fe9c42b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 18:37:24 -0700 Subject: [PATCH] Retire expired tracker awake leases before foreground deferrals --- examples/simple_sensor/SensorMesh.cpp | 5 + test/fixtures/sensor_tracker_client.cpp | 116 ++++++++++++++++++- test/fixtures/sensor_tracker_client.h | 25 +++- test/fixtures/sensor_tracker_radio_lease.cpp | 2 +- test/test_sensor_tracker_client.py | 47 +++++++- test/test_sensor_tracker_sleep.py | 40 +++++++ 6 files changed, 228 insertions(+), 7 deletions(-) diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index f0694f6a0..e2402693c 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -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(); diff --git a/test/fixtures/sensor_tracker_client.cpp b/test/fixtures/sensor_tracker_client.cpp index ac13bcd42..47fc03e1a 100644 --- a/test/fixtures/sensor_tracker_client.cpp +++ b/test/fixtures/sensor_tracker_client.cpp @@ -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(); diff --git a/test/fixtures/sensor_tracker_client.h b/test/fixtures/sensor_tracker_client.h index df4080d27..f0933b758 100644 --- a/test/fixtures/sensor_tracker_client.h +++ b/test/fixtures/sensor_tracker_client.h @@ -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(&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; + } +} diff --git a/test/fixtures/sensor_tracker_radio_lease.cpp b/test/fixtures/sensor_tracker_radio_lease.cpp index 0d25a58fd..a5f1093e4 100644 --- a/test/fixtures/sensor_tracker_radio_lease.cpp +++ b/test/fixtures/sensor_tracker_radio_lease.cpp @@ -119,7 +119,7 @@ public: : mesh::Mesh(radio, time, manager), clock(time), radio_driver(radio) { begin(); } RTC* getRTCClock() const { return const_cast(&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; } diff --git a/test/test_sensor_tracker_client.py b/test/test_sensor_tracker_client.py index 9bebae22a..664628feb 100644 --- a/test/test_sensor_tracker_client.py +++ b/test/test_sensor_tracker_client.py @@ -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") diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py index f400301e3..f9985fbd8 100644 --- a/test/test_sensor_tracker_sleep.py +++ b/test/test_sensor_tracker_sleep.py @@ -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 +#include +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")