fix: improve acknowledged unicast retry reliability (#11320)

* Improve acknowledged unicast retry reliability

* Fix merged next-hop routing tests

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Benjamin Faershtein
2026-08-13 13:55:53 -04:00
committed by GitHub
co-authored by Ben Meadors
parent faa2c8fc52
commit a400143090
5 changed files with 196 additions and 11 deletions
+142
View File
@@ -106,6 +106,44 @@ class NextHopRouterTestShim : public NextHopRouter
using NextHopRouter::relayOpaquePacket;
using Router::shouldDecrementHopLimit; // protected in Router
PendingPacket *trackForTest(const meshtastic_MeshPacket &packet, uint8_t totalAttempts)
{
auto *copy = packetPool.allocCopy(packet);
TEST_ASSERT_NOT_NULL(copy);
return startRetransmission(copy, totalAttempts);
}
PendingPacket *trackWithDefaultBudgetForTest(const meshtastic_MeshPacket &packet)
{
auto *copy = packetPool.allocCopy(packet);
TEST_ASSERT_NOT_NULL(copy);
return startRetransmission(copy);
}
bool stopForTest(NodeNum from, PacketId id) { return stopRetransmission(from, id); }
meshtastic_MeshPacket *pendingPacketForTest(NodeNum from, PacketId id)
{
PendingPacket *entry = findPendingPacket(from, id);
return entry ? entry->packet : nullptr;
}
void fireNextRetryForTest(NodeNum from, PacketId id)
{
PendingPacket *entry = findPendingPacket(from, id);
TEST_ASSERT_NOT_NULL(entry);
entry->nextTxMsec = 0;
doRetransmissions();
}
void markOneRetryFiredForTest(NodeNum from, PacketId id)
{
PendingPacket *entry = findPendingPacket(from, id);
TEST_ASSERT_NOT_NULL(entry);
TEST_ASSERT_GREATER_THAN_UINT8(0, entry->numRetransmissions);
--entry->numRetransmissions;
}
bool filterViaFlooding(const meshtastic_MeshPacket *p) { return FloodingRouter::shouldFilterReceived(p); }
bool filterViaNextHop(const meshtastic_MeshPacket *p) { return NextHopRouter::shouldFilterReceived(p); }
@@ -132,6 +170,7 @@ class MockRadioInterface : public RadioInterface
sendCount++;
lastHopLimit = p->hop_limit;
lastHopStart = p->hop_start;
sentNextHops.push_back(p->next_hop);
if (declineAll || p->to == NODENUM_BROADCAST_NO_LORA)
return ERRNO_SHOULD_RELEASE;
@@ -146,10 +185,18 @@ class MockRadioInterface : public RadioInterface
return 0;
}
bool cancelSending(NodeNum, PacketId) override
{
cancelCount++;
return true;
}
int sendCount = 0;
uint32_t cancelCount = 0;
bool declineAll = false;
uint8_t lastHopLimit = 0;
uint8_t lastHopStart = 0;
std::vector<uint8_t> sentNextHops;
};
class CaptureRadioInterface : public RadioInterface
@@ -773,6 +820,92 @@ void test_reliableAckStopsNormalPendingTransmission(void)
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
}
void test_pending_does_not_cancel_radio_queue_before_first_retry(void)
{
MockRadioInterface *mockIface = installMockIface();
meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333);
p.from = kLocalNode;
p.id = 0x51000001;
shim->trackForTest(p, 5);
TEST_ASSERT_TRUE(shim->stopForTest(kLocalNode, p.id));
TEST_ASSERT_EQUAL_UINT32(0, mockIface->cancelCount);
}
void test_pending_cancels_radio_queue_after_first_retry_for_any_budget(void)
{
MockRadioInterface *mockIface = installMockIface();
meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333);
p.from = kLocalNode;
p.id = 0x51000002;
shim->trackForTest(p, 5);
shim->markOneRetryFiredForTest(kLocalNode, p.id);
TEST_ASSERT_TRUE(shim->stopForTest(kLocalNode, p.id));
TEST_ASSERT_EQUAL_UINT32(1, mockIface->cancelCount);
}
void test_directed_hop_tracks_three_total_attempts(void)
{
installMockIface();
meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333);
p.id = 0x51530003;
PendingPacket *entry = shim->trackWithDefaultBudgetForTest(p);
TEST_ASSERT_NOT_NULL(entry);
TEST_ASSERT_EQUAL_UINT8(3, entry->initialNumRetransmissions + 1);
TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id));
}
void test_intermediate_three_attempts_preserve_record_and_flood_last(void)
{
MockRadioInterface *mockIface = installMockIface();
constexpr NodeNum dest = 0x33333333;
mockNodeDB->addNode(dest, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, 0xAB);
mockNodeDB->addNode(0x000007AB, 0, true, 60);
meshtastic_MeshPacket p = makeRebroadcastCandidate(dest);
p.id = 0x51530004;
p.next_hop = 0xAB;
PendingPacket *entry = shim->trackWithDefaultBudgetForTest(p);
TEST_ASSERT_NOT_NULL(entry);
meshtastic_MeshPacket *trackedPacket = entry->packet;
shim->fireNextRetryForTest(p.from, p.id);
TEST_ASSERT_EQUAL_UINT32(1, mockIface->sentNextHops.size());
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockIface->sentNextHops[0]);
#else
TEST_ASSERT_EQUAL_HEX8(0xAB, mockIface->sentNextHops[0]);
#endif
TEST_ASSERT_EQUAL_PTR(trackedPacket, shim->pendingPacketForTest(p.from, p.id));
shim->fireNextRetryForTest(p.from, p.id);
TEST_ASSERT_EQUAL_UINT32(2, mockIface->sentNextHops.size());
TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockIface->sentNextHops[1]);
TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id));
}
void test_early_flood_preserves_fresh_verified_route(void)
{
MockRadioInterface *mockIface = installMockIface();
constexpr NodeNum dest = 0x33333333;
mockNodeDB->addNode(dest, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, 0xAB);
mockNodeDB->addNode(0x000007AB, 0, true, 60);
shim->noteRouteLearned(dest, 0xAB, millis());
meshtastic_MeshPacket p = makeRebroadcastCandidate(dest);
p.id = 0x51530005;
p.next_hop = 0xAB;
TEST_ASSERT_NOT_NULL(shim->trackWithDefaultBudgetForTest(p));
shim->fireNextRetryForTest(p.from, p.id);
TEST_ASSERT_EQUAL_UINT32(1, mockIface->sentNextHops.size());
TEST_ASSERT_EQUAL_HEX8(0xAB, mockIface->sentNextHops[0]);
TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id));
}
// Control: proves the NO_LORA case below turns on the `to` field alone.
void test_rebroadcast_normal_broadcast_is_relayed(void)
{
MockRadioInterface *mockIface = installMockIface();
@@ -846,6 +979,8 @@ void test_event_mode_hop_behavior(void)
void setup()
{
initializeTestEnvironment();
AirTime testAirTime;
airTime = &testAirTime;
UNITY_BEGIN();
airTimeFixture = std::make_unique<ScopedAirTimeFixture>();
@@ -913,6 +1048,13 @@ void setup()
RUN_TEST(test_eventPolicy_seededRetrySuppressesTxUntilGateOff);
RUN_TEST(test_reliableAckStopsNormalPendingTransmission);
printf("\n=== pending retransmission bookkeeping ===\n");
RUN_TEST(test_pending_does_not_cancel_radio_queue_before_first_retry);
RUN_TEST(test_pending_cancels_radio_queue_after_first_retry_for_any_budget);
RUN_TEST(test_directed_hop_tracks_three_total_attempts);
RUN_TEST(test_intermediate_three_attempts_preserve_record_and_flood_last);
RUN_TEST(test_early_flood_preserves_fresh_verified_route);
printf("\n=== rebroadcast of NODENUM_BROADCAST_NO_LORA ===\n");
RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed);
RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed);
+27
View File
@@ -174,6 +174,11 @@ class AuthPipelineRouter : public ReliableRouter
PendingPacket *entry = findPendingPacket(from, id);
return entry ? entry->nextTxMsec : 0;
}
uint8_t pendingTotalAttempts(NodeNum from, PacketId id)
{
PendingPacket *entry = findPendingPacket(from, id);
return entry ? entry->initialNumRetransmissions + 1 : 0;
}
size_t pendingCount() const { return pending.size(); }
void clearPending()
{
@@ -1548,6 +1553,26 @@ void test_C14_duty_cycle_limited_reliable_send_remains_pending(void)
initRegion();
}
void test_C15_reliable_unicast_tracks_five_total_attempts(void)
{
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
p.id = 0x51530001;
p.want_ack = true;
TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p)));
TEST_ASSERT_EQUAL_UINT8(5, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id));
}
void test_C16_reliable_broadcast_keeps_three_total_attempts(void)
{
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
p.id = 0x51530002;
p.want_ack = true;
TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p)));
TEST_ASSERT_EQUAL_UINT8(3, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id));
}
// C5: the packet survives (C4) but the identity claim inside it must not land - the pubkey guard
// can't tell a signer from an impersonator replaying its (public) key. Only the write is refused.
void test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name(void)
@@ -2123,6 +2148,8 @@ void setup()
RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass);
RUN_TEST(test_C13_failed_initial_reliable_send_does_not_retry);
RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending);
RUN_TEST(test_C15_reliable_unicast_tracks_five_total_attempts);
RUN_TEST(test_C16_reliable_broadcast_keeps_three_total_attempts);
printf("\n=== Group N: NodeInfoModule authentication ===\n");
RUN_TEST(test_N1_unsigned_nodeinfo_from_signer_dropped);
RUN_TEST(test_N2_signed_nodeinfo_from_signer_not_dropped);