Files
Ben MeadorsandClaude Opus 5 83fd62b756 test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515)
* test(native): add 14 suites for routing, persistence, parsing and identity gaps

Coverage audit of the native test tree; adds the highest-value untested
logic as 11 new suites and extends 3 existing ones (200 test functions).

New: test_stream_framing, test_nodedb_boot_recovery,
test_nodedb_legacy_migration, test_nodedb_v25_roundtrip,
test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix,
test_hop_start_policy, test_routing_response_hops,
test_phone_api_config_dump, test_observer.
Extended: test_rtc, test_mqtt, test_xmodem.

Two source changes the audit produced:

- StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check
  into the buffer-fed path, where there is no EOF sentinel; with signed
  char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte
  as uint8_t directly. Latent on develop (no callers), pinned by
  test_stream_framing.
- Extract the post-decode pre-hop predicate from Router::handleReceived
  into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so
  test_hop_start_policy drives the exact expression the router calls.
  No behavior change.

test/state-manifest.tsv declares the suites that construct a NodeDB.
Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): address review - harden observer dispatch, trim comments

Review follow-ups on the coverage-audit suites:

- Observable::notifyObservers() erased list nodes while holding an iterator
  into them, so an observer that unobserves itself from onNotify corrupted the
  dispatch. Today the only self-detacher (PhoneAPI::onNotify ->
  checkConnectionTimeout -> close -> unobserve) survives solely because it
  returns -1 and aborts the chain before the increment; that unwritten contract
  is now gone. Removal during a dispatch nulls the entry and the outermost
  notify sweeps afterwards, which keeps self-detach, next-detach and
  destruction-during-notify all safe without an allocation. Hoisting the next
  iterator instead would have inverted the hazard and broken the existing
  next-detach case. Two regression tests added.

- Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call
  is in Router::dispatchReceived, not handleReceived.

- Cast hop fields to unsigned at the %u call site in test_hop_start_policy.

- Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md.

- Rename eight test functions whose names were exactly `test_` + 35 chars:
  that is the shape of a Lob API key, so trufflehog flagged them as secrets
  and failed the Trunk CI check.

Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): revert the observer dispatch change, keep the contract test

Backs out the notifyObservers() deferred-removal hardening from the previous
commit. It was reviewer-driven scope creep: nothing in the coverage audit
needed it, no test required it, and it changes dispatch semantics in a header
with ~76 observe() call sites on native verification alone.

The hazard it addressed is not reachable today. The only observer that
unobserves itself from onNotify is PhoneAPI (onNotify ->
checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts
the chain before the iterator is advanced past the erased node.

test_self_detach_with_abort_during_notify stays: it passes against the
unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup
that "simplifies" it away goes red. The unsafe variant (self-detach returning
0) is documented in a comment rather than tested, since asserting it would be
asserting UB.

* fix(serial): recover the frame behind a stray framing marker

A byte that failed the START2 check was discarded rather than re-tested as
a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted
byte on a noisy UART silently dropped the frame behind it. Re-test the byte
in place instead.

Applied to both copies of the receive state machine. readStream() is the one
that matters in the field - it is the serial path every phone client uses -
while handleRecStream() still has no callers on develop.

Strictly widens what the parser accepts; no frame that parsed before parses
differently. test_stream_framing covers it on both receive paths, plus a run
of stray markers and a START1-then-unrelated-byte resync.

This was originally documented as a known gap in the framing suite. Fixing it
instead was NomDeTom's call on review: a passing test asserting the bad
behavior is what makes it hard to change later, and it is the same defect
shape as the signedness fix three functions away.

Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than
a bare millis() compare, matching the house deadline rule.

* test(native): cover the stray-marker resync on the buffer path too

The stray-marker fix went into both copies of the receive state machine, but
only test_stray_start1_before_frame_still_delivers drove both. The repeated-
marker and unrelated-byte cases drove readStream() alone, so a regression in
handleRecStream() would have gone unnoticed by two of the three.

Verified load-bearing: reverting only the handleRecStream() half of the fix
turns test_repeated_stray_start1_before_frame_still_delivers red on the new
assertion. test_start1_then_unrelated_byte_resyncs stays green under that
mutation by design - its failing byte is 0x00, where both branches reset to 0 -
and covers the other half of the ternary.

Also drops the stale header on test_stray_start1_before_frame_still_delivers,
which still described the gap as pinned-as-is after the fix landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(native): make the hop-start truth table assert the rows it prints

test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it
reported as a case that could not fail - the anti-pattern #11517 names in its
unfinished assertion-presence lint, and the one exception to NomDeTom's "no
RUN_TEST without an assertion" pass over this PR.

The printed row and the checked expectation now come from one struct, so the
summary cannot narrate a table the predicates no longer implement. It also
covers the consequence columns the per-row tests do not assert together:
classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop
for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:41:08 +00:00

854 lines
35 KiB
C++

// ReliableRouter ACK/NAK decision matrix: which ACK or NAK sniffReceived() emits per inbound
// shape, retransmission bookkeeping, the #11502 implicit ACK for our own overheard opaque DM
// (Group 5b drives the real OPAQUE_RELAY_ONLY ingress path), and the pending-timer extensions.
// Harness copied from test_nexthop_routing (ReliableRouterTestShim + MockRoutingModule).
#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc.
#include "TestUtil.h"
#include <unity.h>
#include "airtime.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "mesh/Channels.h"
#include "mesh/NodeDB.h"
#include "mesh/RadioInterface.h"
#include "mesh/ReliableRouter.h"
#include "mesh/Throttle.h"
#include "modules/RoutingModule.h"
#include <cstdio>
#include <cstring>
#include <list>
#include <memory>
#include <tuple>
#include <vector>
static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11
static constexpr NodeNum kRemoteNode = 0x22222222;
static constexpr NodeNum kThirdNode = 0x33333333;
// ---------------------------------------------------------------------------
// MockNodeDB - inject sender records with a controlled public-key size, so the PKI_UNKNOWN_PUBKEY
// vs NO_CHANNEL discrimination in sniffReceived() can be driven per test.
// ---------------------------------------------------------------------------
class MockNodeDB : public NodeDB
{
public:
void clearTestNodes()
{
testNodes.clear();
meshNodes = &testNodes;
numMeshNodes = 0;
}
void addNode(NodeNum num, uint8_t publicKeySize = 0)
{
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
node.num = num;
node.last_heard = getTime();
node.public_key.size = publicKeySize;
if (publicKeySize)
memset(node.public_key.bytes, 0x5C, publicKeySize);
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true);
testNodes.push_back(node);
meshNodes = &testNodes;
numMeshNodes = testNodes.size();
}
std::vector<meshtastic_NodeInfoLite> testNodes;
};
// ---------------------------------------------------------------------------
// Test shim - expose the protected sniff/filter entry points and the pending/route-health state.
// ---------------------------------------------------------------------------
class ReliableRouterTestShim : public ReliableRouter
{
public:
ReliableRouterTestShim() : ReliableRouter() {}
using NextHopRouter::findRouteHealth;
using NextHopRouter::noteRouteFailure;
using NextHopRouter::noteRouteLearned;
size_t pendingCount() const { return pending.size(); }
void seedRetry(const meshtastic_MeshPacket &p, uint8_t attempts)
{
auto *copy = packetPool.allocCopy(p);
TEST_ASSERT_NOT_NULL(copy);
startRetransmission(copy, attempts);
}
void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing)
{
ReliableRouter::sniffReceived(p, routing);
}
bool filterForTest(const meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); }
bool hasPending(NodeNum from, PacketId id) { return findPendingPacket(from, id) != nullptr; }
uint32_t pendingNextTx(NodeNum from, PacketId id)
{
PendingPacket *entry = findPendingPacket(from, id);
TEST_ASSERT_NOT_NULL(entry);
return entry->nextTxMsec;
}
const meshtastic_MeshPacket *pendingPacket(NodeNum from, PacketId id)
{
PendingPacket *entry = findPendingPacket(from, id);
TEST_ASSERT_NOT_NULL(entry);
return entry->packet;
}
void clearPendingForTest()
{
while (!pending.empty())
stopRetransmission(pending.begin()->first);
}
void resetRouteHealthForTest()
{
for (auto &h : routeHealth)
h = RouteHealth{};
}
};
// Capture radio with a configurable per-packet airtime, so the pending-timer extension loops
// (which are no-ops with a 0-returning stub) become observable.
class TimedCaptureRadio : public RadioInterface
{
public:
ErrorCode send(meshtastic_MeshPacket *p) override
{
sentPackets.push_back(*p);
packetPool.release(p);
return ERRNO_OK;
}
bool cancelSending(NodeNum from, PacketId id) override
{
(void)from;
(void)id;
cancelCount++;
return false;
}
bool findInTxQueue(NodeNum from, PacketId id) override
{
(void)from;
(void)id;
return false;
}
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
{
(void)totalPacketLen;
(void)received;
return packetTimeMsec;
}
void reset()
{
sentPackets.clear();
cancelCount = 0;
packetTimeMsec = 0;
}
std::vector<meshtastic_MeshPacket> sentPackets;
uint32_t cancelCount = 0;
uint32_t packetTimeMsec = 0;
};
class MockRoutingModule : public RoutingModule
{
public:
void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0,
bool ackWantsAck = false) override
{
ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck);
}
std::list<std::tuple<meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t, bool>> ackNaks;
};
class ScopedAirTimeFixture
{
public:
ScopedAirTimeFixture() : previous(airTime) { airTime = &instance; }
~ScopedAirTimeFixture() { airTime = previous; }
private:
AirTime instance;
AirTime *previous;
};
static MockNodeDB *mockNodeDB = nullptr;
static ReliableRouterTestShim *reliableShim = nullptr;
static TimedCaptureRadio *radio = nullptr;
static MockRoutingModule *mockRoutingModule = nullptr;
static std::unique_ptr<ScopedAirTimeFixture> airTimeFixture;
static PacketId nextTestPacketId = 0x7A000000;
// ---------------------------------------------------------------------------
// Packet builders
// ---------------------------------------------------------------------------
static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, NodeNum from, NodeNum to, uint8_t channel,
bool wantAck = false)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = from;
p.to = to;
p.id = nextTestPacketId++;
p.channel = channel;
p.hop_start = 3;
p.hop_limit = 3; // hop_start == hop_limit -> getHopsAway() == 0 ("heard directly")
p.relay_node = 0x22;
p.next_hop = NO_NEXT_HOP_PREFERENCE;
p.want_ack = wantAck;
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = portnum;
return p;
}
static meshtastic_MeshPacket makeEncryptedToUs(uint8_t channel, bool wantAck)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = kRemoteNode;
p.to = kLocalNode;
p.id = nextTestPacketId++;
p.channel = channel;
p.hop_start = 3;
p.hop_limit = 3;
p.relay_node = 0x22;
p.next_hop = NO_NEXT_HOP_PREFERENCE;
p.want_ack = wantAck;
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.encrypted.size = 32;
return p;
}
static void expectSingleAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId id, ChannelIndex chIndex, uint8_t hopLimit,
bool ackWantsAck)
{
TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
const auto &ack = mockRoutingModule->ackNaks.front();
TEST_ASSERT_EQUAL_INT(err, std::get<0>(ack));
TEST_ASSERT_EQUAL_HEX32(to, std::get<1>(ack));
TEST_ASSERT_EQUAL_HEX32(id, std::get<2>(ack));
TEST_ASSERT_EQUAL_UINT8(chIndex, std::get<3>(ack));
TEST_ASSERT_EQUAL_UINT8(hopLimit, std::get<4>(ack));
TEST_ASSERT_EQUAL(ackWantsAck, std::get<5>(ack));
}
static void configureChannels()
{
memset(&channelFile, 0, sizeof(channelFile));
channelFile.channels_count = 2;
meshtastic_Channel primary = meshtastic_Channel_init_default;
primary.index = 0;
primary.has_settings = true;
primary.role = meshtastic_Channel_Role_PRIMARY;
strncpy(primary.settings.name, "primary", sizeof(primary.settings.name) - 1);
meshtastic_Channel secondary = meshtastic_Channel_init_default;
secondary.index = 1;
secondary.has_settings = true;
secondary.role = meshtastic_Channel_Role_SECONDARY;
strncpy(secondary.settings.name, "second", sizeof(secondary.settings.name) - 1);
secondary.settings.psk.size = 32;
memset(secondary.settings.psk.bytes, 0xAB, secondary.settings.psk.size);
channelFile.channels[0] = primary;
channelFile.channels[1] = secondary;
channels.onConfigChanged();
}
void setUp(void)
{
myNodeInfo.my_node_num = kLocalNode;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;
config.lora.override_duty_cycle = true;
config.lora.hop_limit = 3; // keep getHopLimitForResponse() deterministic across tests
config.security.private_key.size = 0;
owner.is_licensed = false;
// Keep our own key unset: the PKI_UNKNOWN_PUBKEY NAK handler dereferences nodeInfoModule (a null
// global here) only when owner.public_key.size == 32.
owner.public_key.size = 0;
mockNodeDB->clearTestNodes();
reliableShim->clearPendingForTest();
reliableShim->resetRouteHealthForTest();
radio->reset();
mockRoutingModule->ackNaks.clear();
configureChannels();
}
void tearDown(void) {}
// ===========================================================================
// Group 1 - want_ack ACK variants (decoded packets to us)
// ===========================================================================
void test_text_dm_want_ack_gets_want_ack_ack(void)
{
auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true);
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
TEST_ASSERT_NOT_EQUAL(0, expectedHop); // must be distinguishable from the 0-hop ACK branch
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true);
}
void test_text_reply_still_gets_want_ack_ack(void)
{
// shouldSuccessAckWithWantAck() runs before the response branch, so a text DM that is itself a
// reply still gets the reliable want-ack ACK (not the 0-hop response treatment).
auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true);
p.decoded.reply_id = 0x1234;
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true);
}
void test_nontext_dm_want_ack_gets_plain_ack(void)
{
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true);
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
TEST_ASSERT_NOT_EQUAL(0, expectedHop);
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/false);
}
void test_response_heard_directly_gets_zero_hop_ack(void)
{
// A response (request_id set) heard at 0 hops: the original sender cannot overhear an implicit
// ACK, so we ACK - but only with hop limit 0.
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true);
p.decoded.request_id = 0x4242;
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
}
void test_response_relayed_gets_no_ack(void)
{
// A relayed response with no next-hop addressing already got its implicit ACK from the
// rebroadcast; ACKing again would only burn airtime.
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true);
p.decoded.request_id = 0x4242;
p.hop_limit = 2; // hop_start 3 -> 1 hop away
reliableShim->sniffForTest(&p, nullptr);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
void test_response_relayed_via_next_hop_gets_zero_hop_ack(void)
{
// Relayed, but directed at a next_hop: the immediate relayer retransmits until stopped, so a
// 0-hop ACK is still required.
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true);
p.decoded.request_id = 0x4242;
p.hop_limit = 2;
p.next_hop = 0x77;
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
}
void test_broadcast_want_ack_gets_no_ack(void)
{
// 0-hop reliability is unicast-only: a want_ack broadcast is never ACKed (isToUs() is false).
auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0, /*wantAck=*/true);
reliableShim->sniffForTest(&p, nullptr);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
// ===========================================================================
// Group 2 - undecodable want_ack NAKs (encrypted packets to us)
// ===========================================================================
void test_pki_unknown_sender_gets_pki_unknown_pubkey_nak(void)
{
// channel==0 + sender absent from NodeDB -> the PKI key-amnesia NAK, on the primary channel.
auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true);
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop,
/*ackWantsAck=*/false);
}
void test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak(void)
{
// The sender is in the DB but we hold no key for it - same NAK as a fully unknown node.
mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/0);
auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true);
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop,
/*ackWantsAck=*/false);
}
void test_pki_known_key_sender_gets_no_channel_nak(void)
{
// Discriminator: with the sender's key on hand an undecodable channel-0 want_ack packet is NOT a
// key problem, so it falls through to the generic NO_CHANNEL NAK.
mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/32);
auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true);
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop,
/*ackWantsAck=*/false);
}
void test_unknown_channel_hash_gets_no_channel_nak(void)
{
// Nonzero channel hash we cannot decode -> NO_CHANNEL on the primary channel (not the hash).
auto p = makeEncryptedToUs(/*channel=*/0x5A, /*wantAck=*/true);
uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p);
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop,
/*ackWantsAck=*/false);
}
// ===========================================================================
// Group 3 - no want_ack, but we are the addressed next hop
// ===========================================================================
void test_next_hop_addressed_to_us_gets_zero_hop_ack(void)
{
// We were the addressed next hop: a 0-hop ACK stops the relayer's retransmissions even though
// the packet itself did not ask for an ACK.
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false);
p.next_hop = 0x11; // our last byte
p.hop_limit = 1;
reliableShim->sniffForTest(&p, nullptr);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
}
void test_next_hop_with_hop_limit_zero_gets_no_ack(void)
{
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false);
p.next_hop = 0x11;
p.hop_limit = 0;
reliableShim->sniffForTest(&p, nullptr);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
void test_next_hop_other_byte_gets_no_ack(void)
{
auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false);
p.next_hop = 0x22; // someone else's byte
p.hop_limit = 1;
reliableShim->sniffForTest(&p, nullptr);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}
// ===========================================================================
// Group 4 - explicit ACK/NAK vs pending retransmissions, MQTT gate, route health
// ===========================================================================
void test_explicit_ack_stops_retransmissions_and_clears_route_failures(void)
{
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis());
reliableShim->noteRouteFailure(kRemoteNode);
reliableShim->noteRouteFailure(kRemoteNode);
TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount());
auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1);
ack.decoded.request_id = original.id;
meshtastic_Routing routing = meshtastic_Routing_init_zero;
routing.error_reason = meshtastic_Routing_Error_NONE;
reliableShim->sniffForTest(&ack, &routing);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
// The end-to-end ACK proves the route to its sender works -> noteRouteSuccess clears failures.
RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode);
TEST_ASSERT_NOT_NULL(h);
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
}
void test_nak_stops_retransmissions_but_keeps_route_failures(void)
{
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis());
reliableShim->noteRouteFailure(kRemoteNode);
reliableShim->noteRouteFailure(kRemoteNode);
auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1);
nak.decoded.request_id = original.id;
meshtastic_Routing routing = meshtastic_Routing_init_zero;
routing.error_reason = meshtastic_Routing_Error_MAX_RETRANSMIT;
reliableShim->sniffForTest(&nak, &routing);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
// A NAK is not a delivery success: the failure count must survive.
RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode);
TEST_ASSERT_NOT_NULL(h);
TEST_ASSERT_EQUAL_UINT8(2, h->consecutiveFailures);
}
void test_pki_unknown_pubkey_nak_stops_retransmissions(void)
{
// The remote lost our key: its PKI_UNKNOWN_PUBKEY NAK must still clear the pending record.
// owner.public_key.size == 0 (setUp) keeps the NodeInfo re-send branch (a nodeInfoModule
// dereference, null in this harness) out of the path.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1);
nak.decoded.request_id = original.id;
meshtastic_Routing routing = meshtastic_Routing_init_zero;
routing.error_reason = meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY;
reliableShim->sniffForTest(&nak, &routing);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
}
void test_own_ack_echo_via_mqtt_keeps_retransmissions(void)
{
// An implicit ACK that is our own traffic echoed back via MQTT must not stop LoRa retries.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1);
echo.decoded.request_id = original.id;
echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
reliableShim->sniffForTest(&echo, nullptr);
TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount());
TEST_ASSERT_TRUE(reliableShim->hasPending(kLocalNode, original.id));
}
void test_own_ack_echo_via_lora_stops_retransmissions(void)
{
// Control for the MQTT gate: the identical from-us echo via LoRa does stop the retries.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1);
echo.decoded.request_id = original.id;
echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
reliableShim->sniffForTest(&echo, nullptr);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
}
void test_remote_ack_via_mqtt_still_stops_retransmissions(void)
{
// The gate is scoped to from-us echoes: a genuine end-to-end ACK arriving over MQTT counts.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1);
ack.decoded.request_id = original.id;
ack.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
meshtastic_Routing routing = meshtastic_Routing_init_zero;
routing.error_reason = meshtastic_Routing_Error_NONE;
reliableShim->sniffForTest(&ack, &routing);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
}
// ===========================================================================
// Group 5 - implicit ACK for our own overheard DM through shouldFilterReceived. This is the
// pre-existing route (a decodable copy still in encrypted wire form reaches it); the #11502
// opaque short-circuit is exercised separately in Group 5b.
// ===========================================================================
void test_overheard_own_dm_rebroadcast_mints_implicit_ack(void)
{
// The implicit ACK is minted from the header alone (from/id), so this route must work on a
// still-encrypted packet, and the LoRa copy stops the retransmissions.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero;
overheard.from = kLocalNode;
overheard.to = kRemoteNode;
overheard.id = original.id;
overheard.hop_start = 3;
overheard.hop_limit = 2;
overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
overheard.encrypted.size = 32;
reliableShim->filterForTest(&overheard);
// ACK is addressed to us (so it reaches the phone) on the pending copy's channel.
expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
}
void test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions(void)
{
// The MQTT copy still surfaces "Delivered to mesh" but must not cancel the LoRa retries.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero;
overheard.from = kLocalNode;
overheard.to = kRemoteNode;
overheard.id = original.id;
overheard.hop_start = 3;
overheard.hop_limit = 2;
overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
overheard.encrypted.size = 32;
reliableShim->filterForTest(&overheard);
expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount());
}
void test_overheard_foreign_packet_mints_no_implicit_ack(void)
{
// Someone else's traffic must never mint an ACK, even with a colliding packet id.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero;
foreign.from = kRemoteNode;
foreign.to = kThirdNode;
foreign.id = original.id;
foreign.hop_start = 3;
foreign.hop_limit = 2;
foreign.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
foreign.encrypted.size = 32;
reliableShim->filterForTest(&foreign);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount());
}
// ===========================================================================
// Group 5b - the real #11502 wiring: an overheard own DM under a channel hash we cannot decode
// (a PKI DM we sent) is OPAQUE_RELAY_ONLY in Router::perhapsHandleReceived and returns BEFORE
// shouldFilterReceived; the fix is the isFromUs branch there. Driven through the public ingress
// queue (enqueueReceivedMessage + runOnce), so deleting that branch fails these tests.
// ===========================================================================
// An encrypted copy of our own DM under an unknown channel hash: not to us (no PKI attempt), no
// hash match -> DECODE_OPAQUE -> OPAQUE_RELAY_ONLY. hop_limit > 0 so the opaque relay does not
// short-circuit before the ACK branch.
static meshtastic_MeshPacket makeOpaqueOwnOverheard(PacketId id, meshtastic_MeshPacket_TransportMechanism transport)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = kLocalNode;
p.to = kRemoteNode;
p.id = id;
p.channel = 0x5A;
p.hop_start = 3;
p.hop_limit = 2;
p.transport_mechanism = transport;
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.encrypted.size = 32;
memset(p.encrypted.bytes, 0xC3, p.encrypted.size);
return p;
}
static void ingressOverheard(const meshtastic_MeshPacket &p)
{
meshtastic_MeshPacket *copy = packetPool.allocCopy(p);
TEST_ASSERT_NOT_NULL(copy);
reliableShim->enqueueReceivedMessage(copy);
reliableShim->runOnce();
}
void test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries(void)
{
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA));
expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount());
}
void test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries(void)
{
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT));
expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false);
TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount());
}
void test_ingress_opaque_foreign_packet_mints_no_implicit_ack(void)
{
// The isFromUs guard on the opaque branch: someone else's opaque traffic with a colliding id
// is relayed but never ACKed.
auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
auto foreign = makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA);
foreign.from = kRemoteNode;
foreign.to = kThirdNode;
ingressOverheard(foreign);
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount());
}
// ===========================================================================
// Group 6 - pending-timer airtime extension in send() and shouldFilterReceived()
// ===========================================================================
void test_send_extends_other_pending_deadlines_not_own(void)
{
// While we transmit packet B we cannot hear an (implicit) ACK for pending A, so A's deadline
// must move out by B's airtime. B's own fresh record must not be self-extended.
radio->packetTimeMsec = 50000; // dwarfs any real time elapsed inside the test
auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id);
auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true);
auto *allocated = packetPool.allocCopy(b);
TEST_ASSERT_NOT_NULL(allocated);
TEST_ASSERT_EQUAL_INT(ERRNO_OK, reliableShim->send(allocated));
TEST_ASSERT_EQUAL_UINT32(2, reliableShim->pendingCount());
TEST_ASSERT_EQUAL_UINT32(aBefore + 50000, reliableShim->pendingNextTx(kLocalNode, a.id));
// B's deadline is millis-at-set + getRetransmissionMsec(B); a self-extension would push it a
// further 50s out, past anything the wall clock could account for.
uint32_t bTx = reliableShim->pendingNextTx(kLocalNode, b.id);
uint32_t retrans = radio->getRetransmissionMsec(reliableShim->pendingPacket(kLocalNode, b.id));
// Via Throttle rather than a bare millis() compare, per the house deadline rule.
TEST_ASSERT_TRUE_MESSAGE(Throttle::deadlinePassed(bTx - retrans), "own record must not be extended by its own send");
}
void test_receive_extends_all_pending_deadlines(void)
{
// While receiving any packet we cannot hear an ACK either: every pending deadline moves out by
// the received packet's airtime.
radio->packetTimeMsec = 40000;
auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kThirdNode, 1, /*wantAck=*/true);
reliableShim->seedRetry(b, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS);
uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id);
uint32_t bBefore = reliableShim->pendingNextTx(kLocalNode, b.id);
auto inbound = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false);
reliableShim->filterForTest(&inbound);
TEST_ASSERT_EQUAL_UINT32(aBefore + 40000, reliableShim->pendingNextTx(kLocalNode, a.id));
TEST_ASSERT_EQUAL_UINT32(bBefore + 40000, reliableShim->pendingNextTx(kLocalNode, b.id));
}
// ===========================================================================
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
airTimeFixture = std::make_unique<ScopedAirTimeFixture>();
mockNodeDB = new MockNodeDB();
nodeDB = mockNodeDB;
reliableShim = new ReliableRouterTestShim();
auto capture = std::make_unique<TimedCaptureRadio>();
radio = capture.get();
reliableShim->addInterface(std::move(capture));
mockRoutingModule = new MockRoutingModule();
routingModule = mockRoutingModule;
printf("\n=== want_ack ACK variants ===\n");
RUN_TEST(test_text_dm_want_ack_gets_want_ack_ack);
RUN_TEST(test_text_reply_still_gets_want_ack_ack);
RUN_TEST(test_nontext_dm_want_ack_gets_plain_ack);
RUN_TEST(test_response_heard_directly_gets_zero_hop_ack);
RUN_TEST(test_response_relayed_gets_no_ack);
RUN_TEST(test_response_relayed_via_next_hop_gets_zero_hop_ack);
RUN_TEST(test_broadcast_want_ack_gets_no_ack);
printf("\n=== undecodable want_ack NAKs ===\n");
RUN_TEST(test_pki_unknown_sender_gets_pki_unknown_pubkey_nak);
RUN_TEST(test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak);
RUN_TEST(test_pki_known_key_sender_gets_no_channel_nak);
RUN_TEST(test_unknown_channel_hash_gets_no_channel_nak);
printf("\n=== next-hop 0-hop ACK without want_ack ===\n");
RUN_TEST(test_next_hop_addressed_to_us_gets_zero_hop_ack);
RUN_TEST(test_next_hop_with_hop_limit_zero_gets_no_ack);
RUN_TEST(test_next_hop_other_byte_gets_no_ack);
printf("\n=== ACK/NAK vs pending retransmissions ===\n");
RUN_TEST(test_explicit_ack_stops_retransmissions_and_clears_route_failures);
RUN_TEST(test_nak_stops_retransmissions_but_keeps_route_failures);
RUN_TEST(test_pki_unknown_pubkey_nak_stops_retransmissions);
RUN_TEST(test_own_ack_echo_via_mqtt_keeps_retransmissions);
RUN_TEST(test_own_ack_echo_via_lora_stops_retransmissions);
RUN_TEST(test_remote_ack_via_mqtt_still_stops_retransmissions);
printf("\n=== implicit ACK for our own overheard DM ===\n");
RUN_TEST(test_overheard_own_dm_rebroadcast_mints_implicit_ack);
RUN_TEST(test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions);
RUN_TEST(test_overheard_foreign_packet_mints_no_implicit_ack);
printf("\n=== implicit ACK through the opaque ingress short-circuit (#11502) ===\n");
RUN_TEST(test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries);
RUN_TEST(test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries);
RUN_TEST(test_ingress_opaque_foreign_packet_mints_no_implicit_ack);
printf("\n=== pending-timer airtime extension ===\n");
RUN_TEST(test_send_extends_other_pending_deadlines_not_own);
RUN_TEST(test_receive_extends_all_pending_deadlines);
int result = UNITY_END();
airTimeFixture.reset();
exit(result);
}
void loop() {}