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.
This commit is contained in:
mikecarper committed 2026-09-15 08:27:05 -07:00
1 parent dde87f1d15
commit e720132a8b
8 files changed
+274 -29

No files matched your search

+17 -4
View File
@@ -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(),
+10
View File
@@ -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.
+12 -17
View File
@@ -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;
}
+2 -2
View File
@@ -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;
+8 -5
View File
@@ -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);
+45
View File
@@ -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");
+176
View File
@@ -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;
+4 -1
View File
@@ -27,12 +27,15 @@ class MemoryFS {
public:
std::map<std::string, std::vector<uint8_t>> files;
bool fail_write = false;
bool fail_remove = false;
int fail_rename = 0;
std::vector<std::string> 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;
}