From e720132a8b20430bc47947902ee3b33aaf71e008 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 15 Sep 2026 08:27:05 -0700 Subject: [PATCH] Keep retry cancellation and radio recovery scoped to their owners Cancel packets retained by the radio driver when their mesh retry is retired. Echo-confirmed delivery completes application replies; explicit cancellation releases failure references. Let active transmissions finish and prevent a cancelled packet from returning after a driver timeout. Preserve final direct-echo waits when only the other radio profile or crossover policy changes. Recover verified radio-profile settings from backup even when filesystem repair fails, and protect the backup after a failed save rollback. Add regressions for direct and flood cancellation, confirmed command replies, active TX completion and timeouts, subsequent packet cleanup, independent profile echo ownership, and storage rename/removal failures. --- src/Dispatcher.cpp | 21 ++- src/Dispatcher.h | 10 + src/Mesh.cpp | 29 ++- src/Mesh.h | 4 +- src/helpers/RadioProfileCLI.cpp | 13 +- test/fixtures/radio_profiles/cli_test.cpp | 45 +++++ test/fixtures/radio_profiles/mesh_tests.h | 176 ++++++++++++++++++ .../mocks/helpers/IdentityStore.h | 5 +- 8 files changed, 274 insertions(+), 29 deletions(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 3f131d2e3..1ab03742c 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -67,6 +67,7 @@ void Dispatcher::begin() { outbound_radio_retry_at = 0; outbound_radio_retry_pending = false; outbound_radio_retry_used = false; + outbound_cancellation = OutboundCancellation::None; ota_tx_airtime = 0; radio_nonrx_start = _ms->getMillis(); @@ -187,7 +188,7 @@ bool Dispatcher::startOutboundTransmit() { } bool Dispatcher::scheduleOutboundRadioRetry() { - if (outbound == NULL || outbound_radio_retry_used) return false; + if (outbound == NULL || outbound_radio_retry_used || outbound_cancellation != OutboundCancellation::None) return false; outbound_radio_retry_used = true; outbound_radio_retry_pending = true; @@ -201,12 +202,21 @@ void Dispatcher::failOutboundTransmit() { if (outbound == NULL) return; restoreOutboundTxOverrides(); - logTxFail(outbound, outbound->getRawLength()); - onSendFail(outbound); + if (outbound_cancellation == OutboundCancellation::Delivered) { + // A downstream echo confirms delivery even if the driver's completion + // failed. Pending command replies must complete their application barrier. + onSendComplete(outbound); + } else { + if (outbound_cancellation == OutboundCancellation::None) logTxFail(outbound, outbound->getRawLength()); + // Explicit cancellation still releases application references, including + // pending replies and battery alerts, through the ordinary failure hook. + onSendFail(outbound); + } releasePacket(outbound); outbound = NULL; outbound_radio_retry_pending = false; outbound_radio_retry_used = false; + outbound_cancellation = OutboundCancellation::None; } int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { @@ -358,7 +368,8 @@ void Dispatcher::loop() { checkRecv(); // A cancelled or expired packet must retire even if the chip never // becomes available for another retune (including an armed OTA reboot). - if (!isPacketRadioCurrent(outbound) || !allowPacketTransmit(outbound)) { + if (outbound_cancellation != OutboundCancellation::None + || !isPacketRadioCurrent(outbound) || !allowPacketTransmit(outbound)) { failOutboundTransmit(); return; } @@ -429,6 +440,7 @@ void Dispatcher::loop() { outbound = NULL; outbound_radio_retry_pending = false; outbound_radio_retry_used = false; + outbound_cancellation = OutboundCancellation::None; } else if (millisHasNowPassed(outbound_expiry)) { MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packed send timed out!", getLogDateTime()); @@ -840,6 +852,7 @@ void Dispatcher::checkSend() { if (outbound) { outbound_radio_retry_pending = false; outbound_radio_retry_used = false; + outbound_cancellation = OutboundCancellation::None; if (!allowPacketTransmit(outbound)) { MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): packet no longer allowed, type=%u", getLogDateTime(), diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 84a628903..80587f2b7 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -277,6 +277,7 @@ typedef uint32_t DispatcherAction; * and scheduling of outbound Packets. */ class Dispatcher { + enum class OutboundCancellation : uint8_t { None, Cancelled, Delivered }; Packet* outbound; // current outbound packet unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time; unsigned long outbound_radio_retry_at; @@ -299,6 +300,7 @@ class Dispatcher { bool dispatcher_started; bool outbound_radio_retry_pending; bool outbound_radio_retry_used; + OutboundCancellation outbound_cancellation = OutboundCancellation::None; #ifndef RADIO_LIVENESS_SOFT_ONLY bool nonrx_soft_recovery_attempted; #endif @@ -425,6 +427,14 @@ protected: virtual uint32_t getRadioWatchdogMillis() const; // observer-only radio recovery #endif const Packet* getOutboundInFlight() const { return outbound; } + // A retry may be retained outside the manager during radio recovery. Mark + // it here and let loop() release it after RX callbacks finish. An on-air + // transmission completes normally, but cannot retry after a later timeout. + void cancelOutboundRadioRetry(const Packet* packet, bool delivered = false) { + if (packet != NULL && packet == outbound) { + outbound_cancellation = delivered ? OutboundCancellation::Delivered : OutboundCancellation::Cancelled; + } + } // Milliseconds until Dispatcher can next make progress on a queued packet. // This includes queue schedules, delayed inbound processing, airtime-budget // waits, and short channel-busy deferrals. diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6835cdad4..1df88f62e 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -306,7 +306,12 @@ void Mesh::loop() { for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; ++i) { if (!_direct_retries[i].active) continue; const auto* packet = _direct_retries[i].queued ? _direct_retries[i].packet : _direct_retries[i].trigger_packet; - if (_direct_retries[i].waiting_final_echo || (packet && !isPacketRadioCurrent(packet))) retireDirectRetrySlot(i); + // Final direct echoes retain metadata only. Their slot still identifies + // the profile, so changing the other channel must not cancel the wait. + const uint8_t profile = i / MAX_DIRECT_RETRY_SLOTS; + if ((_direct_retries[i].waiting_final_echo + && _retry_radio_generations[profile] != p->generation[profile]) + || (packet && !isPacketRadioCurrent(packet))) retireDirectRetrySlot(i); } for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; ++i) { if (!_flood_retries[i].active) continue; @@ -1443,12 +1448,13 @@ void Mesh::clearDirectRetrySlot(int idx) { if (rebuild_timeout) rebuildNextDirectRetryTimeout(); } -void Mesh::retireDirectRetrySlot(int idx) { +void Mesh::retireDirectRetrySlot(int idx, bool delivered) { if (idx < 0 || idx >= TOTAL_DIRECT_RETRY_SLOTS || !_direct_retries[idx].active) { return; } Packet* retry = _direct_retries[idx].queued ? _direct_retries[idx].packet : NULL; + cancelOutboundRadioRetry(retry ? retry : _direct_retries[idx].trigger_packet, delivered); if (retry != NULL && retry != getOutboundInFlight()) { for (int j = 0; j < _mgr->getOutboundTotal(); j++) { if (_mgr->getOutboundByIdx(j) != retry) continue; @@ -1627,18 +1633,6 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { onDirectRetryEvent("good", _direct_retries[i].packet, echo_millis, retry_attempt, _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, _direct_retries[i].payload_type); - if (_direct_retries[i].queued) { - for (int j = 0; j < _mgr->getOutboundTotal(); j++) { - if (_mgr->getOutboundByIdx(j) == _direct_retries[i].packet) { - Packet* pending = _mgr->removeOutboundByIdx(j); - if (pending) { - releasePacket(pending); - } - break; - } - } - } - clearDirectRetrySlot(i); } else { if (_direct_retries[i].trigger_packet != NULL) { _direct_retries[i].trigger_packet->_snr = echo_snr_x4; @@ -1648,8 +1642,8 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { : (uint32_t)(_ms->getMillis() - _direct_retries[i].echo_wait_started_at); onDirectRetryEvent("good", _direct_retries[i].trigger_packet, echo_millis, _direct_retries[i].retry_attempts_sent + 1, _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); - clearDirectRetrySlot(i); } + retireDirectRetrySlot(i, true); cleared = true; } @@ -2045,12 +2039,13 @@ void Mesh::clearFloodRetrySlot(int idx) { if (rebuild_timeout) rebuildNextFloodRetryTimeout(); } -void Mesh::retireFloodRetrySlot(int idx) { +void Mesh::retireFloodRetrySlot(int idx, bool delivered) { if (idx < 0 || idx >= TOTAL_FLOOD_RETRY_SLOTS || !_flood_retries[idx].active) { return; } Packet* retry = _flood_retries[idx].queued ? _flood_retries[idx].packet : NULL; + cancelOutboundRadioRetry(retry ? retry : _flood_retries[idx].trigger_packet, delivered); if (retry != NULL && retry != getOutboundInFlight()) { for (int j = 0; j < _mgr->getOutboundTotal(); j++) { if (_mgr->getOutboundByIdx(j) != retry) continue; @@ -2479,7 +2474,7 @@ bool Mesh::cancelFloodRetryOnEcho(const Packet* packet) { : _flood_retries[i].retry_attempts_sent + 1; onFloodRetryEvent("good", packet, echo_millis, retry_attempt); - retireFloodRetrySlot(i); + retireFloodRetrySlot(i, true); cleared = true; } diff --git a/src/Mesh.h b/src/Mesh.h index 112467f6c..4b0a26d2e 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -169,7 +169,7 @@ class Mesh : public Dispatcher { void rebuildNextDirectRetryTimeout(); void rebuildNextFloodRetryTimeout(); void clearDirectRetrySlot(int idx); - void retireDirectRetrySlot(int idx); + void retireDirectRetrySlot(int idx, bool delivered = false); void calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const; bool calculateTraceReplacementKey(const Packet* packet, uint8_t* dest_key) const; void replaceQueuedTraceRetries(const Packet* packet); @@ -181,7 +181,7 @@ class Mesh : public Dispatcher { bool canDecodeDirectPayloadForSelf(const Packet* packet); void maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool final_hop_retry = false); void clearFloodRetrySlot(int idx); - void retireFloodRetrySlot(int idx); + void retireFloodRetrySlot(int idx, bool delivered = false); void replaceQueuedSelfAdvertRetries(const Packet* packet); bool cancelFloodRetryOnEcho(const Packet* packet); bool getRecentAdvertTimestamp(const Packet* packet, uint32_t& timestamp) const; diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 2248bbfc8..1dafd023d 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -69,7 +69,7 @@ bool RadioProfileCLI::readImage(const char* path, uint8_t* bytes, size_t size) { } bool RadioProfileCLI::writeImage(const char* path, const uint8_t* bytes, size_t size) { - if (fs_->exists(path)) fs_->remove(path); + if (fs_->exists(path) && !fs_->remove(path)) return false; #if defined(NRF52_PLATFORM) File file(*fs_); if (!file.open(path, FILE_O_WRITE)) return false; @@ -100,11 +100,11 @@ bool RadioProfileCLI::save(const RadioProfileConfig& config, uint16_t preamble, memcpy(image + ImageSize - 4, &crc, 4); if (!writeImage(TempPath, image, sizeof(image))) return false; if (fs_->exists(ImagePath)) { - if (fs_->exists(BackupPath)) fs_->remove(BackupPath); + if (fs_->exists(BackupPath) && !fs_->remove(BackupPath)) return false; if (!fs_->rename(ImagePath, BackupPath)) return false; } if (!fs_->rename(TempPath, ImagePath)) { - if (fs_->exists(BackupPath)) fs_->rename(BackupPath, ImagePath); + if (fs_->exists(BackupPath) && !fs_->rename(BackupPath, ImagePath)) hold_ = true; return false; } if (fs_->exists(BackupPath)) fs_->remove(BackupPath); @@ -122,8 +122,11 @@ void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool in uint8_t bytes[ImageSize]; bool loaded = readImage(ImagePath, bytes, sizeof(bytes)); if (!loaded && readImage(BackupPath, bytes, sizeof(bytes))) { - if (fs_->exists(ImagePath)) fs_->remove(ImagePath); - loaded = fs_->rename(BackupPath, ImagePath); + // Use the verified backup even when repairing the interrupted save is + // impossible. Keep writes held so the sole committed image stays intact. + loaded = true; + hold_ = (fs_->exists(ImagePath) && !fs_->remove(ImagePath)) + || !fs_->rename(BackupPath, ImagePath); } if (!loaded) { hold_ = fs_->exists(ImagePath) || fs_->exists(BackupPath); diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 10ef9b2dd..d3cf5cb33 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -43,6 +43,51 @@ struct Fixture { void advance(uint32_t ms, bool epoch=true) { g_mock_millis+=ms; if(epoch)clock.epoch+=ms/1000; cli.loop(); } }; int main() { + for (bool damaged_primary : {false, true}) { + Fixture f(true); + f.cmd("set radio2 910.5,500,8,5,rxtx,80"); + f.cmd("set radio2.cross off"); + f.cmd("set tx.reply radio2 force"); + assert(f.cli.savePrimaryPreamble(120)); + assert(f.fs.rename("/radio_profiles", "/radio_profiles.bak")); + const auto backup = f.fs.files["/radio_profiles.bak"]; + if (damaged_primary) { + f.fs.files["/radio_profiles"] = {0}; + f.fs.fail_remove = true; + } else { + f.fs.fail_rename = 1; + } + Radio radio; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &radio, &f.clock, true); + // The verified backup stays usable even when storage cannot repair it. + assert(radio.p.enabled()); + assert(radio.p.secondary.params.freq == 910.5f); + assert(radio.p.secondary.params.preamble == 80); + assert(restored.primaryPreamble() == 120); + assert(radio.p.cross == mesh::RadioCrossMode::Off); + assert(radio.p.reply_tx == mesh::RADIO_TX_SECONDARY && radio.p.reply_force); + f.fs.fail_remove = false; + assert(restored.handle("set radio2 off", f.reply)); + assert(strstr(f.reply, "Error")); + assert(f.fs.files["/radio_profiles.bak"] == backup); + } + { + Fixture f; + f.cmd("set radio2 910.5,500,8,5,rxtx,80"); f.advance(2000); + const auto committed = f.fs.files["/radio_profiles"]; + f.fs.fail_rename_from = {"/radio_profiles.tmp", "/radio_profiles.bak"}; + f.cmd("set radio2 off", false); // both publication and rollback fail + assert(f.radio.p.enabled()); + assert(f.fs.files["/radio_profiles.bak"] == committed); + f.fs.fail_rename_from.clear(); + f.cmd("set radio2.cross on", false); // protect the sole committed copy + assert(f.fs.files["/radio_profiles.bak"] == committed); + Radio radio; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &radio, &f.clock); + assert(radio.p.enabled() && radio.p.secondary.params.preamble == 80); + assert(restored.handle("set radio2 off", f.reply)); + assert(!strncmp(f.reply, "OK", 2)); // reboot repairs storage and releases the hold + } { Fixture f(true); f.cmd("set tx.reply off"); diff --git a/test/fixtures/radio_profiles/mesh_tests.h b/test/fixtures/radio_profiles/mesh_tests.h index 6a06e0603..3b0e7cc5b 100644 --- a/test/fixtures/radio_profiles/mesh_tests.h +++ b/test/fixtures/radio_profiles/mesh_tests.h @@ -13,6 +13,8 @@ class DualProfileTestRadio : public RetryCodingRateRadio { void loop() override { ++carrier_services; } bool recoverRadio(bool) override { ++recoveries; return true; } uint32_t estimated_airtime = 10; + float receive_score = 0; + float packetScore(float, int) override { return receive_score; } TraceTestClock* sensing_clock = nullptr; uint32_t sensing_delay = 0; bool distinguish_airtime = false; @@ -72,9 +74,26 @@ class DualProfileTestMesh : public RetryCodingRateMesh { using RetryCodingRateMesh::getRetransmitDelay; using RetryCodingRateMesh::getOtaPacketAirtime; using RetryCodingRateMesh::tryParsePacket; + using RetryCodingRateMesh::cancelAllDirectRetries; + using RetryCodingRateMesh::cancelAllFloodRetries; bool cross_filter_allows = true; float ota_speed = 1.0f; bool suppress_tx = false; + unsigned send_failures = 0; + unsigned send_completions = 0; + void onSendComplete(mesh::Packet* packet) override { + ++send_completions; + RetryCodingRateMesh::onSendComplete(packet); + } + unsigned tx_failure_logs = 0; + void logTxFail(mesh::Packet*, int) override { ++tx_failure_logs; } + unsigned direct_successes = 0, direct_failures = 0; + void onDirectRetrySucceeded(const uint8_t*, uint8_t, int8_t) override { ++direct_successes; } + void onDirectRetryFailed(const uint8_t*, uint8_t) override { ++direct_failures; } + void onSendFail(mesh::Packet* packet) override { + ++send_failures; + RetryCodingRateMesh::onSendFail(packet); + } bool allowPacketTransmit(const mesh::Packet* packet) const override { return !suppress_tx && RetryCodingRateMesh::allowPacketTransmit(packet); } @@ -249,6 +268,121 @@ TEST_F(DualProfileTest, RadioFaultRetryRetainsBoundedBusyEscapeAndSingleRetryLim EXPECT_TRUE(radio.transmissions.empty()); } +TEST_F(DualProfileTest, EchoDuringRadioFaultBackoffCancelsDirectAndFloodRetry) { + radio.config.cross = mesh::RadioCrossMode::Off; + radio.receive_score = 1; // strong echo is processed without a score delay + for (bool direct : {false, true}) { + const uint8_t route[] = {0x11, 0x22}; + ASSERT_NE(nullptr, direct ? makeDirectText(node, 0x55, route, sizeof(route)) : queue()); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 1); + auto* retry = manager.getOutboundByIdx(0); + mesh::Packet echo = *retry; + echo.setPathHashCount(retry->getPathHashCount() + (direct ? -1 : 1)); + radio.fail_next_send = true; + tick(10000); ASSERT_TRUE(node.hasOutbound()); + ASSERT_FALSE(radio.sending); + const auto sent = radio.transmissions.size(); + // Deliver the real downstream echo through Dispatcher::checkRecv while + // the acknowledged retry is retained outside the outbound queue. + radio.incoming = {echo.header, echo.path_len}; + radio.incoming.insert(radio.incoming.end(), echo.path, echo.path + echo.getPathByteLen()); + radio.incoming.insert(radio.incoming.end(), echo.payload, echo.payload + echo.payload_len); + tick(500); + EXPECT_TRUE(radio.incoming.empty()); + EXPECT_EQ(radio.transmissions.size(), sent); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(manager.getFreeCount(), 40); + EXPECT_EQ(node.tx_failure_logs, 0u); // cancellation is not a TX failure + // Keep failures independent so both packet types run in the regression. + radio.complete = true; tick(); + node.cancelAllDirectRetries(); node.cancelAllFloodRetries(); + } +} + +TEST_F(DualProfileTest, ExplicitRetryCancellationRetiresRetainedPacketEvenWhenRadioBusy) { + radio.config.cross = mesh::RadioCrossMode::Off; + for (bool direct : {false, true}) { + const uint8_t route[] = {0x11, 0x22}; + ASSERT_NE(nullptr, direct ? makeDirectText(node, 0x55, route, sizeof(route)) : queue()); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 1); + radio.fail_next_send = true; + tick(10000); ASSERT_TRUE(node.hasOutbound()); + const auto sent = radio.transmissions.size(); + if (direct) node.cancelAllDirectRetries(); + else node.cancelAllFloodRetries(); + radio.hold_prepare_busy = true; + tick(500); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(manager.getFreeCount(), 40); + radio.hold_prepare_busy = false; + tick(500); + EXPECT_EQ(radio.transmissions.size(), sent); + radio.complete = true; tick(); + } +} + +TEST_F(DualProfileTest, EchoCancelsRadioRecoveryOfInitialTransmission) { + radio.config.cross = mesh::RadioCrossMode::Off; + radio.receive_score = 1; + for (bool direct : {false, true}) { + const uint8_t route[] = {0x11, 0x22}; + auto* original = direct ? makeDirectText(node, 0x55, route, sizeof(route)) : queue(); + ASSERT_NE(nullptr, original); + mesh::Packet echo = *original; + echo.setPathHashCount(original->getPathHashCount() + (direct ? -1 : 1)); + radio.fail_next_send = true; + tick(); ASSERT_TRUE(node.hasOutbound()); + const auto completions = node.send_completions; + radio.incoming = {echo.header, echo.path_len}; + radio.incoming.insert(radio.incoming.end(), echo.path, echo.path + echo.getPathByteLen()); + radio.incoming.insert(radio.incoming.end(), echo.payload, echo.payload + echo.payload_len); + tick(500); + EXPECT_TRUE(radio.transmissions.empty()); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(manager.getFreeCount(), 40); + EXPECT_EQ(node.tx_failure_logs, 0u); + EXPECT_EQ(node.send_failures, 0u); + // A downstream echo proves the command reply arrived. Complete its + // application barrier rather than cancelling the pending radio handoff. + EXPECT_EQ(node.send_completions, completions + 1); + } +} + +TEST_F(DualProfileTest, RetryCancellationLetsActiveTransmitFinishButPreventsTimeoutRetry) { + radio.config.cross = mesh::RadioCrossMode::Off; + for (bool direct : {false, true}) for (bool timeout : {false, true}) { + SCOPED_TRACE(testing::Message() << "direct=" << direct << " timeout=" << timeout); + const uint8_t route[] = {0x11, 0x22}; + ASSERT_NE(nullptr, direct ? makeDirectText(node, 0x55, route, sizeof(route)) : queue()); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + tick(10000); ASSERT_TRUE(radio.sending); // retry is already on air + if (direct) node.cancelAllDirectRetries(); + else node.cancelAllFloodRetries(); + const auto sent = radio.transmissions.size(); + const auto failed = node.send_failures; + tick(); EXPECT_TRUE(radio.sending); // do not cut an active TX short + radio.complete = !timeout; + tick(timeout ? 10000 : 1); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(manager.getFreeCount(), 40); + tick(500); + EXPECT_EQ(radio.transmissions.size(), sent); + EXPECT_EQ(node.send_failures, failed + (timeout ? 1 : 0)); + // Cancellation must not suppress failure cleanup for the next stale + // packet, which is rejected before the ordinary TX initialization path. + auto* stale = queue(); ASSERT_NE(nullptr, stale); + ++stale->radio_generation; + tick(); + EXPECT_EQ(node.send_failures, failed + (timeout ? 1 : 0) + 1); + EXPECT_EQ(manager.getFreeCount(), 40); + } +} + TEST_F(DualProfileTest, PacketBecomingReadyDuringCadCannotSkipItsOwnChannelCheck) { node.flood_attempts = 0; node.tempRadioActive = true; @@ -812,6 +946,48 @@ TEST_F(DualProfileTest, DirectRetriesKeepSeparateCodingRatesAndEchoOwnership) { EXPECT_EQ(b, manager.getOutboundByIdx(0)); } +TEST_F(DualProfileTest, FinalDirectEchoWaitOnlyExpiresWhenItsOwnProfileChanges) { + node.direct_attempts = 1; + for (uint8_t origin : {0, 1}) for (unsigned change = 0; change < 3; ++change) { + SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin) << " change=" << change); + radio.config.cross = mesh::RadioCrossMode::Off; + tick(); + auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet); + packet->header = PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT; + packet->payload_len = 3; + packet->payload[0] = 0xa1; packet->payload[1] = 0xb2; packet->payload[2] = 0x55; + packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY; + const uint8_t route[] = {0x11, 0x22}; + ASSERT_TRUE(node.sendDirect(packet, route, sizeof(route))); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 1); + mesh::Packet echo = *manager.getOutboundByIdx(0); + echo.radio_bound = false; echo.radio_local = false; + echo.setPathHashCount(echo.getPathHashCount() - 1); + tick(10000); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 0); + ASSERT_EQ(manager.getFreeCount(), 40); // final echo waits own metadata only + + const auto successes = node.direct_successes; + if (change == 1) { + radio.config.cross = mesh::RadioCrossMode::On; + } else if ((change == 2 ? origin : origin ^ 1) == 0) { + auto primary = radio.config.primary; primary.freq += 1; + radio.config.setPrimary(primary, false); + } else { + auto secondary = radio.config.secondary; secondary.params.freq += 1; + radio.config.setSecondary(secondary, false); + } + tick(); + node.receivePacket(&echo); + EXPECT_EQ(node.direct_successes, successes + (change == 2 ? 0 : 1)); + EXPECT_EQ(node.direct_failures, 0u); // profile changes are not link failures + EXPECT_EQ(manager.getFreeCount(), 40); + } +} + TEST_F(DualProfileTest, ExpiredSecondaryPacketsAreDiscarded) { node.flood_attempts = 0; radio.config.secondary_temporary = true; diff --git a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h index 021de165c..4df9a57de 100644 --- a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h +++ b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h @@ -27,12 +27,15 @@ class MemoryFS { public: std::map> files; bool fail_write = false; + bool fail_remove = false; int fail_rename = 0; + std::vector fail_rename_from; bool mkdir(const char*) { return true; } bool exists(const char* path) const { return files.count(path); } - bool remove(const char* path) { return files.erase(path); } + bool remove(const char* path) { return !fail_remove && files.erase(path); } bool rename(const char* from, const char* to) { if (fail_rename > 0 && --fail_rename == 0) return false; + if (std::find(fail_rename_from.begin(), fail_rename_from.end(), from) != fail_rename_from.end()) return false; if (!exists(from) || exists(to)) return false; files[to] = files[from]; files.erase(from); return true; }