Pr1.5 tmm nexthop (#10745)

* TrafficManagement: flat unified cache + persistent next-hop overflow store

Reworks the TrafficManagementModule cache layer (policing behaviour unchanged
from upstream) and adds a routing-hint overflow store:

- Flatten the ring: replace the cuckoo-hashed unified cache and the bucketed
  PSRAM NodeInfo index with plain flat arrays + linear scan (same idiom as
  WarmNodeStore). At LoRa packet rates an O(n) scan of the cache is negligible,
  and it removes a large amount of hashing/displacement complexity. The cache
  entry is 11 B; timestamps use a uniform +1 presence-offset so a 0 byte always
  means "empty" across every sub-store. Adds rebaseEpoch() so cached state
  survives the ~19 h relative-timestamp horizon instead of being flushed.

- Next-hop overflow cache: setNextHop/getNextHopHint store a confirmed last-byte
  relay for a destination, written only from NextHopRouter's ACK-confirmed
  decision (and mirrored from TraceRoute). NextHopRouter::getNextHop falls back
  to this cache when the hot NodeDB has no hint, so DMs/relays to long-tail
  nodes keep routing after the node ages out of NodeInfoLite.

- Persistence: preloadNextHopsFromNodeDB warm-starts the cache from persisted
  NodeInfoLite hints on first maintenance pass; next_hop entries are kept alive
  across the maintenance sweep (no TTL) and never clobbered by a stale preload.

All packet-policing logic (rate limit, position dedup, unknown-packet drop,
NodeInfo direct response, hop exhaustion) is the existing upstream behaviour,
untouched. HAS_TRAFFIC_MANAGEMENT defaults on so the module is compiled in. (see note).

Tests: upstream policing suite now actually runs (adds the MeshTypes.h include
that gates HAS_TRAFFIC_MANAGEMENT) plus 4 next-hop tests. Role-aware throttles,
politeness, precision clamp, port-interval and mesh-radius gating — and the
rate-limit >255 saturation fix — are deferred to the advanced-TMM branch.

Note: default dedup movement grid moves to ~91m, which also means 1.5km required to end up with the same signature position - coarser and therefore further than before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* TrafficManagement: fix cppcheck constVariablePointer warning

`node` in preloadNextHopsFromNodeDB() is never written through — mark
it const to satisfy cppcheck's constVariablePointer check in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add multi-hop NextHop recovery tests and unit tests for routing reliability

- Introduced a new test suite for multi-hop NextHop directed-message delivery and relay recovery in `test_nexthop_multihop_recovery.py`. This includes tests for end-to-end delivery and recovery after relay drop.
- Implemented unit tests in `test_main.cpp` for NextHop routing reliability mitigations, covering:
  - M1: Ambiguity-aware last-byte resolution.
  - M2: NextHopRouter's strict-neighbor gate and hop limit checks.
  - M3: Route-health freshness and failure decay.
- Enhanced mock classes to facilitate controlled testing of node behaviors and routing logic.

* grafting fixed

* Address Copilot review for PR #10735 (NextHop improvements)

- docs/nexthop-routing-reliability.md: update status from "no code
  changes yet" to reflect that mitigations and tests are implemented

RAM pressure and MIGRATION_VERBOSE concerns addressed upstream in
PR2.5 (per-platform TRAFFIC_MANAGEMENT_CACHE_SIZE) and PR2 (verbose
default=0) respectively; (0,0) sentinel fixed in PR2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CI: fix cppcheck constVariablePointer and test include path

- NextHopRouter.cpp: qualify two RouteHealth *h locals as const — only
  read for stale-route checks, never mutated through the pointer
- Router.cpp: qualify meshtastic_NodeInfoLite *node as const in
  shouldDecrementHopLimit — only read for favorite/role predicate
- test_position_module/test_main.cpp: change bare PositionModule.h to
  modules/PositionModule.h — build_flags sets -Isrc, not -Isrc/modules,
  so the bare form fails to resolve in the native PlatformIO test env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WarmStore: cache device role + protected category in last_heard low bits

Steal the low 6 bits of WarmNodeEntry.last_heard to carry an evicted node's
device role (4 bits) and a protected category (2 bits) for the hop-trim path,
at zero record-size cost (entry stays 40 B; no RAM/flash growth). The high bits
remain a real unix-seconds timestamp, quantised to 64 s — ample for warm LRU
ordering of long-tail nodes.

- absorb() packs role/protectedCat; place()/ring replay store the raw word so
  metadata round-trips through flash. LRU compares masked time (warmTimeOf).
- take() rehydration masks the metadata bits and restores the cached role so a
  re-admitted node isn't stuck at CLIENT until its next NodeInfo.
- NodeDB classifies the category (favorite/ignored/verified -> Flag;
  tracker/sensor/tak_tracker -> Role) at each eviction site.
- WarmNodeStore::lookupMeta() exposes role/category to consumers.
- Bump WARM_RING_MAGIC (WRNG->WRN2): old rings read as erased and rebuild;
  warm data is a non-critical evictee cache, so discard-on-upgrade is safe.

Tests: test_warm_store 11/11 (new meta round-trip + quantisation-aware ordering);
NodeDB compiles (test_nodedb_blocked 4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: migrate v1 rings/files by discarding last_heard, not the data

Previously the WRNG->WRN2 magic bump treated old rings as erased, discarding all
warm entries — including the PKI public keys that let evicted nodes keep
decrypting DMs. Instead, read v1 (WRNG / WRM1) records and keep each node's
identity + public key, discarding only last_heard (its low bits would otherwise
be misread as the new role/protected metadata). Records re-rank and re-learn
their role on next contact.

- Ring backend (nRF52840): ringReadHeader accepts both magics and reports v1 via
  an out-param; replay zeroes last_heard for v1 records. If the active head page
  is v1, force a rotation so new v2 records never land in a v1-headered page
  (which would discard their freshly-set role on the next load). Legacy pages
  convert to v2 as the ring rotates.
- File backend (warm.dat): bump WARM_STORE_MAGIC WRM1->WRM2; accept WRM1, verify
  CRC against the stored bytes, then discard last_heard and mark dirty so the
  next save rewrites as v2.

Tests: test_warm_store 12/12 (adds test_ws_v1_migration_discardsLastHeard:
key survives, role/protected reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: guard role bit-width + test eviction carries role/protected

- static_assert that the device role enum still fits the 4-bit warm metadata
  field (WARM_ROLE_MASK); fails the build loudly if a new role is added past 15
  rather than silently truncating role on eviction. (Max role today = 12.)
- Add test_migration_carriesRoleAndProtectedIntoWarm: a demoted TRACKER lands in
  the warm tier with its key, role=TRACKER and protected category=Role; a demoted
  CLIENT carries role=CLIENT/None. Exercises the NodeDB eviction path +
  warmProtectedCategory classification (the warm-store unit tests only cover
  absorb() directly).

Tests: test_nodedb_blocked 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix copilot comments

* fix(test): restore #if HAS_TRAFFIC_MANAGEMENT guard in TMM test

The rebase onto PR1.5 lost the top-level HAS_TRAFFIC_MANAGEMENT guard
that PR1.5 introduced, leaving the #else/#endif tail orphaned and
causing compile errors on non-TMM builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Tom
2026-06-19 19:52:58 -05:00
committed by GitHub
co-authored by GitHub Claude Opus 4.8 Ben Meadors
parent ca7d82629d
commit 22072c5f4b
21 changed files with 2529 additions and 737 deletions
+198 -61
View File
@@ -1,3 +1,4 @@
#include "MeshTypes.h" // Include BEFORE TestUtil.h — provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h)
#include "TestUtil.h"
#include <cstdlib>
#include <unity.h>
@@ -10,6 +11,7 @@
#if HAS_TRAFFIC_MANAGEMENT
#include "airtime.h"
#include "mesh/CryptoEngine.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
@@ -28,6 +30,25 @@ constexpr NodeNum kLocalNode = 0x11111111;
constexpr NodeNum kRemoteNode = 0x22222222;
constexpr NodeNum kTargetNode = 0x33333333;
// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks
// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global
// airTime reporting 100% channel utilization for the enclosing scope.
class ScopedBusyAirTime
{
public:
ScopedBusyAirTime() : previous(airTime)
{
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++)
busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period
airTime = &busy;
}
~ScopedBusyAirTime() { airTime = previous; }
private:
AirTime busy;
AirTime *previous;
};
class MockNodeDB : public NodeDB
{
public:
@@ -54,6 +75,32 @@ class MockNodeDB : public NodeDB
cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
}
// Role the TMM should see for the cached node (sender-role-aware throttles).
void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; }
// Seed a node into the hot-store buffer at index 1 (index 0 is reserved for
// "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of
// MAX_NUM_NODES slots with `numMeshNodes` as the logical count — we grow the
// buffer if needed and bump the count, never clear()/push_back() (which would
// shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill).
void setHotNode(NodeNum n, uint8_t nextHop)
{
if (meshNodes->size() < 2)
meshNodes->resize(2);
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
(*meshNodes)[1].num = n;
(*meshNodes)[1].next_hop = nextHop;
numMeshNodes = 2;
}
// Evict everything but "self" — simulates the hot DB rolling over. Logical
// count only; the buffer is left intact so the invariant holds.
void rollHotStore()
{
numMeshNodes = 1;
clearCachedNode();
}
private:
bool hasCachedNode = false;
NodeNum cachedNodeNum = 0;
@@ -121,6 +168,9 @@ static void resetTrafficConfig()
config = meshtastic_LocalConfig_init_zero;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
channelFile = meshtastic_ChannelFile_init_zero;
owner.is_licensed = false;
myNodeInfo.my_node_num = kLocalNode;
router = nullptr;
@@ -175,6 +225,42 @@ static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32
return packet;
}
static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST);
meshtastic_Position pos = meshtastic_Position_init_zero;
pos.has_latitude_i = true;
pos.has_longitude_i = true;
pos.latitude_i = lat;
pos.longitude_i = lon;
pos.precision_bits = precisionBits;
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
return packet;
}
static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out)
{
out = meshtastic_Position_init_zero;
return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out);
}
// Primary channel with a well-known single-byte PSK and the (empty -> preset)
// default name, so Channels::isWellKnownChannel(0) is true.
static void installWellKnownPrimaryChannel()
{
channelFile = meshtastic_ChannelFile_init_zero;
channelFile.channels_count = 1;
channelFile.channels[0].index = 0;
channelFile.channels[0].has_settings = true;
channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY;
channelFile.channels[0].settings.psk.size = 1;
channelFile.channels[0].settings.psk.bytes[0] = 1;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
}
static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
@@ -290,7 +376,7 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 3;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
@@ -305,31 +391,6 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
}
/**
* Verify routing/admin traffic is exempt from rate limiting.
* Important because throttling control traffic can destabilize the mesh.
*/
static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void)
{
moduleConfig.traffic_management.rate_limit_enabled = true;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode);
meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode);
for (int i = 0; i < 4; i++) {
ProcessMessage rr = module.handleReceived(routingPacket);
ProcessMessage ar = module.handleReceived(adminPacket);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(rr));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(ar));
}
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
}
/**
* Verify packets sourced from this node bypass dedup and rate limiting.
* Important so local transmissions are not accidentally self-throttled.
@@ -650,12 +711,15 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi
#endif
/**
* Verify relayed telemetry broadcasts are hop-exhausted when enabled.
* Verify relayed telemetry broadcasts are hop-exhausted when enabled AND the
* channel is congested (telemetry exhaustion is gated on channel utilization,
* unlike position exhaustion).
* Important to prevent further mesh propagation while still allowing one relay step.
*/
static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
packet.hop_start = 5;
@@ -677,6 +741,7 @@ static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void)
static void test_tm_alterReceived_skipsLocalAndUnicast(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // congestion satisfied, so only the skip conditions are under test
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode);
@@ -794,8 +859,11 @@ static void test_tm_positionDedup_precision32_allowsDistinctPositions(void)
}
/**
* Verify invalid precision=0 is treated as full precision.
* Important so invalid config does not collapse all positions into one fingerprint.
* Verify precision=0 falls back to the default precision (same contract as
* >32: getConfiguredOrDefault + sanitizePositionPrecision treat 0 as unset).
* Important so invalid config does not collapse all positions into one
* fingerprint — positions in different default-precision grid cells must
* still be distinct.
*/
static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void)
{
@@ -805,7 +873,7 @@ static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void)
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
@@ -857,11 +925,11 @@ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero
moduleConfig.traffic_management.rate_limit_max_packets = 10;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
ProcessMessage seeded = module.handleReceived(text);
ProcessMessage seeded = module.handleReceived(telemetry);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(duplicate);
meshtastic_TrafficManagementStats stats = module.getStats();
@@ -882,7 +950,7 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void)
moduleConfig.traffic_management.rate_limit_window_secs = 1;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
@@ -895,30 +963,6 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void)
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
}
/**
* Verify rate-limit thresholds above 255 effectively clamp to 255.
* Important because counters are uint8_t and must not overflow behavior.
*/
static void test_tm_rateLimit_thresholdAbove255_clamps(void)
{
moduleConfig.traffic_management.rate_limit_enabled = true;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 300;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
for (int i = 0; i < 255; i++) {
ProcessMessage result = module.handleReceived(packet);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
}
ProcessMessage dropped = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(dropped));
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
}
/**
* Verify unknown-packet tracking resets after its active window expires.
* Important so old unknown traffic does not trigger delayed drops.
@@ -966,12 +1010,14 @@ static void test_tm_unknownPackets_thresholdAbove255_clamps(void)
}
/**
* Verify relayed position broadcasts can also be hop-exhausted.
* Verify relayed position broadcasts can also be hop-exhausted — under the
* same pressure gate as telemetry (here: channel congestion).
* Important because telemetry and position use separate exhaust flags.
*/
static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
{
moduleConfig.traffic_management.exhaust_hop_position = true;
ScopedBusyAirTime busyChannel;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST);
packet.hop_start = 5;
@@ -985,7 +1031,6 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
TEST_ASSERT_TRUE(module.shouldExhaustHops(packet));
TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets);
}
/**
* Verify hop exhaustion ignores undecoded/encrypted packets.
* Important so we never mutate packets that were not decoded by this module.
@@ -993,6 +1038,7 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
static void test_tm_alterReceived_skipsUndecodedPackets(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // congestion satisfied, so only the undecoded skip is under test
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST);
packet.hop_start = 5;
@@ -1014,6 +1060,7 @@ static void test_tm_alterReceived_skipsUndecodedPackets(void)
static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
@@ -1039,6 +1086,7 @@ static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void)
static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
@@ -1083,6 +1131,93 @@ static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void)
TEST_ASSERT_EQUAL_INT32(60 * 1000, interval);
}
// ---------------------------------------------------------------------------
// Next-hop overflow cache
// ---------------------------------------------------------------------------
/**
* Round-trip set/get of a confirmed next hop, plus the input guards.
*/
static void test_tm_nextHop_setAndGetRoundTrip(void)
{
TrafficManagementModuleTestShim module;
// Unknown node yields no hint.
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
// Store a confirmed hop and read it back.
module.setNextHop(kTargetNode, 0x42);
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
// Zero dest and zero byte are rejected (no spurious entry created).
module.setNextHop(0, 0x42);
module.setNextHop(kRemoteNode, 0);
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kRemoteNode));
// Last-write-wins on re-confirmation.
module.setNextHop(kTargetNode, 0x99);
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
}
/**
* The headline scenario: a node carrying a next hop in the hot NodeInfoLite DB
* is warm-loaded into the TMM cache, then the hot DB is "rolled" (the node ages
* out entirely). The hint must still be served — now exclusively from TMM.
*/
static void test_tm_nextHop_servedAfterNodeDbRoll(void)
{
TrafficManagementModuleTestShim module;
// Seed the hot NodeInfoLite DB with a node that has a confirmed next hop.
mockNodeDB->setHotNode(kTargetNode, 0x42);
// Warm-start the overflow cache from the hot DB.
module.preloadNextHopsFromNodeDB();
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
// Roll the main NodeInfoLite DB: the node is evicted from the hot store.
mockNodeDB->rollHotStore();
TEST_ASSERT_NULL(nodeDB->getMeshNode(kTargetNode)); // gone from the hot store
// Hit is still served — proving it now comes from the TMM overflow cache.
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
}
/**
* Preload must not clobber a freshly-learned (confirmed) hop with a possibly
* stale persisted one from NodeInfoLite.
*/
static void test_tm_nextHop_preloadDoesNotClobberLearned(void)
{
TrafficManagementModuleTestShim module;
// A fresher confirmed hop is already cached.
module.setNextHop(kTargetNode, 0x99);
// The hot DB carries an older next hop for the same node.
mockNodeDB->setHotNode(kTargetNode, 0x42);
module.preloadNextHopsFromNodeDB();
// The freshly-learned hop survives.
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
}
/**
* A pure routing hint (no dedup/rate/unknown state) must survive the maintenance
* sweep — next_hop != 0 keeps the slot alive even though it has no TTL.
*/
static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void)
{
TrafficManagementModuleTestShim module;
module.setNextHop(kTargetNode, 0x42);
// The sweep frees slots whose sub-stores are all empty; next_hop must veto that.
module.runOnce();
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
}
} // namespace
void setUp(void)
@@ -1106,7 +1241,6 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow);
RUN_TEST(test_tm_positionDedup_allowsMovedPosition);
RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold);
RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts);
RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters);
RUN_TEST(test_tm_localDestination_bypassesTransitFilters);
RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops);
@@ -1130,7 +1264,6 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset);
RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero);
RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires);
RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps);
RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires);
RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps);
RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast);
@@ -1139,6 +1272,10 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped);
RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval);
RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval);
RUN_TEST(test_tm_nextHop_setAndGetRoundTrip);
RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll);
RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned);
RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep);
exit(UNITY_END());
}