Preserve OTA block progress and channel checks during radio recovery

Drain received packets in single-profile radio fault backoff as well as
dual-profile mode. Share the normal channel-clear and bounded busy policy
with driver retries so a failed send cannot retry blindly or bypass the
existing single-retry limit.

Retain an unadmitted OTA request flight and count v2 fallback evidence only
after a sparse retry enters the transmit queue. Local queue congestion no
longer disables v2 before a request was sent or discards received fragments.

Include primary airtime when temporary RX-only radio2 crosses requests onto
that channel, keeping response allowances conservative on slower profiles.

Add six native regressions, including five failing-before cases, and document
the queue-admission and crossover timing behavior.

Validation: 1,502 native tests, 29 integration checks, and five firmware
builds passed across ESP32-S3, nRF52, and STM32.
This commit is contained in:
mikecarper committed 2026-09-15 07:54:16 -07:00
1 parent 0582366527
commit dde87f1d15
9 files changed
+216 -44

No files matched your search

+5 -2
View File
@@ -178,8 +178,11 @@ does not reflect the GoogleTest count -- run the built binary directly
ordinary traffic during OTA pacing; `test_packet_manager` checks that intentional
pacing survives stale expiry without erasing prior congestion age.
- OTA delivery: `test_ota` keeps rejected queue admissions out of metadata retry
limits; `test_trace_retry` advances the clock during CAD to catch selection and
pacing bypasses and cancels retries while the chip stays BUSY.
limits and preserves v2 negotiation/received fragments during blocked block
requests; `test_trace_retry` advances the clock during CAD to catch selection
and pacing bypasses, cancels retries while the chip stays BUSY, and checks
channel clearance, RX draining, and bounded recovery for radio fault retries.
It also accounts for the primary's airtime when an RX-only radio2 crosses TX.
`python3 -B test/test_ota_apply_delivery.py -v` executes the
production apply/TX guards with a reply on air, a busy queue, failed persistence,
and clock rollover. It checks application and bootloader reboot paths.
+67
View File
@@ -182,6 +182,73 @@ TEST_F(DualProfileTest, OtaAirtimeFollowsParticipatingProfilesInsteadOfScannerVi
EXPECT_EQ(node.getOtaPacketAirtime(), 900u);
}
TEST_F(DualProfileTest, OtaAirtimeIncludesPrimaryWhenCrossingFromReceiveOnlySecondary) {
radio.distinguish_airtime = true;
radio.config.secondary_temporary = true;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
radio.config.cross = mesh::RadioCrossMode::On;
auto packet = makeFloodPacket(PAYLOAD_TYPE_OTA);
EXPECT_EQ(node.getTransmitProfileMask(&packet), 1u);
// The secondary receives the update, but outgoing requests cross onto the
// much slower primary. Both contribute to the response/retry allowance.
EXPECT_EQ(node.getOtaPacketAirtime(), 970u);
}
TEST_F(DualProfileTest, RadioFaultRetryWaitsForChannelClearInSingleAndDualMode) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
for (bool dual : {false, true}) {
radio.config.secondary.mode = dual ? mesh::RadioProfileMode::RxTx : mesh::RadioProfileMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_FALSE(radio.sending);
const auto before = radio.transmissions.size();
radio.busy_profile = 0;
tick(500);
EXPECT_FALSE(radio.sending);
EXPECT_EQ(radio.transmissions.size(), before);
radio.busy_profile = -1;
tick(500);
EXPECT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
}
TEST_F(DualProfileTest, SingleProfileRadioRetryDrainsReceivedPacketBeforeTransmitting) {
node.flood_attempts = 0;
radio.config.secondary.mode = mesh::RadioProfileMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_FALSE(radio.sending);
radio.incoming = {uint8_t(ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT)), 0, 0x42};
tick(500);
EXPECT_TRUE(radio.incoming.empty());
EXPECT_EQ(radio.config.rx_packets[0], 1u);
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, RadioFaultRetryRetainsBoundedBusyEscapeAndSingleRetryLimit) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_FALSE(radio.sending);
radio.busy_profile = 0;
tick(500); EXPECT_FALSE(radio.sending);
// The same bounded CAD policy used for first sends still permits recovery
// from a permanently busy detector. It does not grant another radio retry.
radio.fail_next_send = true;
tick(10000);
EXPECT_FALSE(radio.fail_next_send);
EXPECT_FALSE(radio.sending);
EXPECT_EQ(manager.getFreeCount(), 40);
tick(10000);
EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, PacketBecomingReadyDuringCadCannotSkipItsOwnChannelCheck) {
node.flood_attempts = 0;
node.tempRadioActive = true;
+69
View File
@@ -2963,6 +2963,75 @@ TEST(OtaTransfer, NewClientRequestsV2ThenRetriesLegacyWhenNoV2DataArrives) {
EXPECT_EQ(fallback.want_mask, 0x007Fu);
}
TEST(OtaTransfer, RejectedFirstFlightDoesNotDisableV2BeforeAnyRequestWasSent) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
OtaManager client;
OtaStoreRam<4096> store;
GatedCapture sent;
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
client.set_fetch_store(&store);
client.set_clock(100);
client.pull(manifest.merkle_root, manifest.target_id);
deliver_manifest_fragment(client, manifest.merkle_root, 0, manifest.manifest_start, OTA_MF_FRAG);
deliver_manifest_fragment(client, manifest.merkle_root, 1, manifest.manifest_start + OTA_MF_FRAG,
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
ASSERT_EQ(client.fetchState(), OtaManager::FETCHING);
for (uint32_t i = 1; i <= 10; ++i) {
client.set_clock(100 + i * client.fetchRetryTimeoutMs());
client.loop();
}
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
ReqMsg first{};
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), first));
EXPECT_TRUE(ota_req_is_v2(first.want_mask));
}
TEST(OtaTransfer, RejectedSparseRetryKeepsReceivedV2Fragments) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
OtaManager client;
OtaStoreRam<4096> store;
GatedCapture sent;
sent.accept = true;
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
client.set_fetch_store(&store);
client.set_clock(100);
client.pull(manifest.merkle_root, manifest.target_id);
deliver_manifest_fragment(client, manifest.merkle_root, 0, manifest.manifest_start, OTA_MF_FRAG);
deliver_manifest_fragment(client, manifest.merkle_root, 1, manifest.manifest_start + OTA_MF_FRAG,
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
mh4(body, manifest.payload, manifest.block_size());
memcpy(body + OTA_DATA_V2_STREAM_ID_BYTES, manifest.payload, OTA_FRAG_DATA_V2);
DataMsg partial{};
memcpy(partial.manifest_id, manifest.merkle_root, 4);
ASSERT_TRUE(ota_data_v2_pack(0, (uint16_t)manifest.block_size(), false, partial.frag_off));
partial.data = body;
partial.data_len = sizeof body;
uint8_t wire[MAX_PACKET_PAYLOAD];
ASSERT_TRUE(client.on_message(wire, encode_data(wire, sizeof wire, partial)));
sent.items.clear();
sent.accept = false;
uint32_t now = 100;
for (unsigned i = 0; i < 10; ++i) {
now += client.fetchRetryTimeoutMs();
client.set_clock(now);
client.loop();
}
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
client.loop();
ASSERT_EQ(sent.items.size(), 1u);
ReqMsg retry{};
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), retry));
EXPECT_TRUE(ota_req_is_v2(retry.want_mask));
EXPECT_EQ(ota_req_v2_fragments(retry.want_mask) & 1u, 0u); // keep fragment zero
}
TEST(OtaTransfer, TwoKilobyteClientAcceptsCanonicalLegacyFallbackThroughBitTwelve) {
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));