mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-21 05:45:27 -04:00
* feat(nodedb): greet only while the node store is under half full The ad-hoc greeting in MeshService::handleFromRadio() was gated on !isFull(), so a node kept sending unsolicited NodeInfo right up to the last free slot - on a dense mesh that is the regime where the store is already churning and the greeting is least likely to buy a lasting entry. Add NodeDB::isHalfEmpty(), true only when strictly more than half the slots are free, and gate the greeting on it instead. The comparison is written as 2 * numMeshNodes < cap so a half-full store reads false with no integer rounding, and MAX_NUM_NODES is read into a local because portduino resolves it through a runtime call. The helper keeps the MINIMUM_SAFE_FREE_HEAP term that !isFull() used to contribute: low heap disqualifies the store regardless of occupancy, so a sparse database on a memory-starved device still does not transmit. Admission is untouched - updateFrom() and getOrCreateMeshNode() still fill to capacity. Only greeting stops early. * fix(nodeinfo): raise the minimum greeting window to 30 minutes The !shorterTimeout branch of NodeInfoModule::allocReply() used a 10-minute base, so a node that had just greeted one neighbour could greet the next ten minutes later. Raise the base to 30 minutes. This is the floor, not the window: getConfiguredOrDefaultMsScaled() still multiplies by the congestion coefficient for the roles that scale, so a busy mesh stretches it further. ROUTER/ROUTER_LATE and the tracker/sensor roles bypass the scaling and get a flat 30 minutes. The interactive paths are unaffected - they pass shorterTimeout and keep their own 60-second gate. The periodic broadcast is unaffected too: default_node_info_broadcast_secs is 3 hours with a 1-hour minimum, both clear of the new floor, so the timer is not swallowed by the throttle. * fix(nodeinfo): a send restarts the routine broadcast countdown sendOurNodeInfo() left the OSThread schedule alone, so an ad-hoc send had no effect on the periodic broadcast: run() anchors the next run at runned() + interval, and nothing re-anchored it when the send came from a greeting, a PKI decrypt failure or a completed key verification. The routine copy could follow minutes behind an ad-hoc one, putting two NodeInfos on the air for no gain. Call setIntervalFromNow() with the configured broadcast interval once the packet is queued, so the next periodic copy is a full interval from the send rather than from the last tick. It sits on the return-true path only: a send vetoed by allocReply() - throttle, airtime ceiling, reply suppression - must not be able to silence the routine broadcast. Calling it from inside runOnce() is harmless, since run() then applies the same interval from a last_run of effectively now. * test(nodeinfo): cover the send window, the countdown reset and the greeting gate Three behaviours from this branch had no coverage: isHalfEmpty()'s exclusive boundary, the 30-minute send floor, and the countdown reset on a send. isHalfEmpty() goes to test_nodedb_blocked, which already owns the full-store cases and clears the hot store per test. Three tests sweep the cap over the sizes real deployments have - portduino resolves MAX_NUM_NODES from General.MaxNodes on every read, so a predicate that cached it would greet at the wrong occupancy - and pin the band where admission outlives greeting. That suite had no tearDown; it has one now, restoring the cap so an assertion firing mid-sweep cannot leak a 2-node cap into the tests after it. test_nodeinfo_send_window is new because nothing in the tree stands up NodeInfoModule's send path. Six tests: the floor at 30 minutes with 10 refused, the interactive 60-second gate staying separate, the countdown re-armed by a broadcast and by an ad-hoc unicast, left alone by a refused send, and a preset change consumed only by a send that goes out. The scaling above 40 online nodes is deliberately not retested here - getConfiguredOrDefaultMsScaled() is test_default's contract, per preset and per role. These tests pin the base and leave the multiplier alone. NodeInfoModule gains two PIO_UNIT_TESTING accessors for the countdown: concurrency::OSThread is a private base, so a test shim cannot reach it and only the class itself can. They compile out of a shipping build. The heap term in isHalfEmpty()/isFull() stays uncovered: memGet.getFreeHeap() returns UINT32_MAX on portduino, so a native test could only pin a stub. * chore(trunk): exempt test_nodedb_blocked from the trufflehog Lob detector test_removeNodeByNum_presentNodeOnFullDb is exactly 35 characters after the test_ prefix, which is the length of a Lob API key, and trufflehog's detector matches the bare identifier. The name is years old; it surfaces now only because this branch touches the file, and the pre-push gate reports a finding in a changed file as new. Added to the ignore block that already carries the same detector's hex-literal false positives, with the reason stated alongside them. Nothing in that file is a credential. * fix(nodeinfo): exempt a licensed station from the floor, delay only on a real send Two review findings on the 30-minute window. Ham mode sets node_info_broadcast_secs to 600 s for the FCC minimum call-sign announcement (AdminModule.cpp). The new floor refused every one of those sends until 30 minutes had passed, so a licensed station's call sign went out three times less often than the regulation asks - a regression the old 10-minute base did not have. A licensed station now keeps its own interval whenever that is shorter than the floor. The exemption is exactly the licensed case because nothing else can get under the floor: a set-config clamps the field to an hour, and the userprefs path clamps identically. sendOurNodeInfo() ignored what sendToMesh() returned, so a packet the router declined - no interface, queue full - still re-armed the routine broadcast and still reported success, which let runOnce() consume a pending channel change for a send that never reached the air. Only ERRNO_OK and ERRNO_SHOULD_RELEASE now count; sendToMesh() has already released the packet in both cases. Both are pinned by tests that fail without them, measured: the licensed case fails at "11 min is past it, and the floor must not override it", the declined send at "a declined send is not a send". The licensed test carries an unlicensed control on the same configuration, so deleting the floor outright would not satisfy it. * test(nodeinfo): assert the deadline the scheduler reads, from an aged last_run The countdown cases asserted Thread::interval, which is not what schedules the next run: shouldRun() keys off _cached_next_run, and the two ways of writing it differ. setIntervalFromNow() recomputes it from now; Thread::setInterval() recomputes it from last_run. Swap the call in sendOurNodeInfo() for the latter and the period still reads three hours while the deadline lands wherever the last tick was - firing the routine copy right behind an ad-hoc send, the exact thing the reset exists to prevent. Every test passed. Assert the deadline instead, from a fixture where the two answers are distinguishable: ageLastRunForTests() calls Thread::runned() with an hour-old timestamp, the state a periodic thread is genuinely in between runs, so a deadline off last_run lands an hour early against a five second tolerance. Measured: with setInterval() in place of setIntervalFromNow(), the new case fails by 3600004 ms and the eight others pass, including the one asserting the period - which is what says the old assertion could not see this. runned() and _cached_next_run are protected in Thread and OSThread is a private base, so the hooks live on NodeInfoModule, with the two already there. Raised by Copilot on #11897. * fix(nodeinfo): a declined send must not start the throttle window either allocReply() stamped TransmitHistory when it built the packet, before anything had been sent. The previous commit made sendOurNodeInfo() report a router rejection instead of swallowing it, but the stamp was already written by then, so a packet that never reached the air still started the window - and with the floor now at 30 minutes, that silences the node for half an hour over a send that failed. allocReply() has two callers and only one of them can see the outcome: the module framework sends its own reply through currentReply, with no post-send hook a module can reach (MeshModule::sendResponse is not virtual). So the stamp stays there for that path, and sendOurNodeInfo() defers it across its own allocReply() call and stamps once the router has accepted the packet. deferHistoryStamp mirrors the shorterTimeout member alongside it - same call-scoped signal, same lifetime. test_sendWindow_aRejectedSendDoesNotStartTheWindow asserts both halves: no stamp after the rejection, and the retry immediately after goes out. The existing rejected-send case checked the first failure and the countdown only, which is how this survived it. 248/248 across every suite that touches NodeInfoModule (admin_session_repro, admin_radio, nodeinfo_send_window, traffic_management, fuzz_packets) plus transmit_history, whose subject this is. Raised by CodeRabbit on #11897.
473 lines
21 KiB
C++
473 lines
21 KiB
C++
// Tests for the NodeDB hot-store migration and favourite/ignored (blocked)
|
|
// retention paths - src/mesh/NodeDB.cpp.
|
|
#include "MeshTypes.h" // BEFORE TestUtil.h - provides WARM_NODE_COUNT / MAX_NUM_NODES via mesh-pb-constants.h
|
|
#include "TestUtil.h"
|
|
#include <unity.h>
|
|
|
|
#if defined(ARCH_PORTDUINO)
|
|
#define NDB_TEST_ENTRY extern "C"
|
|
#else
|
|
#define NDB_TEST_ENTRY
|
|
#endif
|
|
|
|
// The migration demotes overflow into the warm tier, so these tests need it.
|
|
#if WARM_NODE_COUNT > 0
|
|
|
|
#include "mesh/NodeDB.h"
|
|
#if defined(ARCH_PORTDUINO)
|
|
#include "platform/portduino/PortduinoGlue.h"
|
|
#endif
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
// Subclass shim: exposes the private maintenance paths (via the friend
|
|
// declaration in NodeDB.h) and lets a test own the hot store directly
|
|
// (meshNodes/numMeshNodes are public). Declared at global scope so it matches
|
|
// `friend class NodeDBTestShim` - an anonymous-namespace class would not.
|
|
class NodeDBTestShim : public NodeDB
|
|
{
|
|
public:
|
|
void runDemote() { demoteOldestHotNodesToWarm(); }
|
|
void runCleanup() { cleanupMeshDB(); }
|
|
void stampUntrusted(NodeNum num, uint32_t uptimeSecs) { recordHeardWhileClockUntrusted(num, uptimeSecs); }
|
|
|
|
// Read back the role + protected category the warm tier cached for a node.
|
|
bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); }
|
|
bool warmTake(NodeNum n, WarmNodeEntry &out) { return warmStore.take(n, out); }
|
|
|
|
void clearHot()
|
|
{
|
|
meshNodes->clear();
|
|
numMeshNodes = 0;
|
|
}
|
|
|
|
// The warm tier outlives setUp() (and a prior run's warm.dat), so a test that
|
|
// asserts on a warm row has to start from an empty one.
|
|
void clearWarm() { warmStore.clear(); }
|
|
|
|
// keySize < 32 seeds a partial key, as a truncated/short NodeInfo would leave behind.
|
|
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey,
|
|
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, pb_size_t keySize = 32)
|
|
{
|
|
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
|
n.num = num;
|
|
n.last_heard = lastHeard;
|
|
n.role = role;
|
|
if (favorite)
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
|
if (ignored)
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
|
if (withUser)
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
|
if (withKey) {
|
|
n.public_key.size = keySize;
|
|
memset(n.public_key.bytes, static_cast<uint8_t>(num & 0xff), keySize);
|
|
n.public_key.bytes[0] = 0x01; // ensure non-zero (all-zero == "no key")
|
|
}
|
|
meshNodes->push_back(n);
|
|
numMeshNodes = meshNodes->size();
|
|
}
|
|
|
|
// Index 0 is our own node; the eviction/migration scans treat it as self.
|
|
void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu, false, false, /*withUser=*/true, /*withKey=*/false); }
|
|
|
|
// isHalfEmpty() and isFull() read numMeshNodes against MAX_NUM_NODES and nothing else, so the
|
|
// occupancy tests set the count directly rather than allocating rows at every cap under test.
|
|
void setOccupancy(int n) { numMeshNodes = (pb_size_t)n; }
|
|
};
|
|
|
|
namespace
|
|
{
|
|
|
|
NodeDBTestShim *db = nullptr;
|
|
int savedMaxNodes = 0;
|
|
|
|
bool warmHasKey(NodeNum n)
|
|
{
|
|
meshtastic_NodeInfoLite_public_key_t k = {0, {0}};
|
|
return db->copyPublicKey(n, k) && k.size == 32;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void setUp(void)
|
|
{
|
|
db->clearHot();
|
|
#if defined(ARCH_PORTDUINO)
|
|
savedMaxNodes = portduino_config.MaxNodes;
|
|
#endif
|
|
}
|
|
void tearDown(void)
|
|
{
|
|
#if defined(ARCH_PORTDUINO)
|
|
// The occupancy sweeps below move the cap. Restore it here rather than at the end of each
|
|
// test, so an assertion that fires mid-sweep cannot leak a 2-node cap into the next test.
|
|
portduino_config.MaxNodes = savedMaxNodes;
|
|
#endif
|
|
}
|
|
|
|
// Migration: a database from a larger-cap build trims to MAX_NUM_NODES; the
|
|
// oldest non-protected nodes are demoted into the warm tier (keys preserved),
|
|
// while self, favourites and ignored survive even when they are the oldest.
|
|
static void test_migration_demotesOldestKeepsKeepersAndSelf(void)
|
|
{
|
|
db->seedSelf();
|
|
const int extra = MAX_NUM_NODES + 30; // overflow well past the MAX-2 cap
|
|
for (int i = 1; i <= extra; i++) {
|
|
const bool fav = (i == 1); // oldest, but a favourite
|
|
const bool ign = (i == 2); // 2nd-oldest, but blocked
|
|
db->push(2000 + i, /*last_heard=*/i, fav, ign, /*withUser=*/true, /*withKey=*/true);
|
|
}
|
|
|
|
db->runDemote();
|
|
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes());
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x0BADF00D)); // self retained
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(2000 + 1)); // oldest favourite retained
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(2000 + 2)); // oldest ignored retained
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(2000 + extra)); // freshest retained
|
|
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3)); // oldest non-protected demoted out of hot
|
|
TEST_ASSERT_TRUE(warmHasKey(2000 + 3)); // ...but its key kept in the warm tier
|
|
}
|
|
|
|
// Eviction carries the device role + protected category into the warm tier. A TRACKER is
|
|
// hop-protected but NOT eviction-protected, so it gets demoted with its key; the warm
|
|
// record must report role=TRACKER / category=Role. A plain CLIENT carries role=CLIENT/None.
|
|
static void test_migration_carriesRoleAndProtectedIntoWarm(void)
|
|
{
|
|
db->seedSelf();
|
|
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
|
for (int i = 1; i <= extra; i++) {
|
|
const auto role = (i == 3) ? meshtastic_Config_DeviceConfig_Role_TRACKER : meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true,
|
|
/*withKey=*/true, role);
|
|
}
|
|
|
|
db->runDemote();
|
|
|
|
uint8_t role = 0xFF, prot = 0xFF;
|
|
// TRACKER (i=3): demoted out of hot, key kept, role + protected carried into warm.
|
|
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3));
|
|
TEST_ASSERT_TRUE(warmHasKey(2000 + 3));
|
|
TEST_ASSERT_TRUE(db->warmMeta(2000 + 3, role, prot));
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, role);
|
|
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
|
// CLIENT (i=4): also demoted, carries role=CLIENT / category=None.
|
|
TEST_ASSERT_TRUE(db->warmMeta(2000 + 4, role, prot));
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_CLIENT, role);
|
|
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
|
}
|
|
|
|
// The signer bit is learned from verified traffic, not NodeInfo, so it must survive a warm
|
|
// round trip. The plain node is the control: re-admission restores it, it doesn't invent it.
|
|
static void test_migration_carriesSignerBitThroughWarm(void)
|
|
{
|
|
db->seedSelf();
|
|
const NodeNum signerNum = 2000 + 3;
|
|
const NodeNum plainNum = 2000 + 4;
|
|
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
|
for (int i = 1; i <= extra; i++)
|
|
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, /*withKey=*/true);
|
|
nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(db->getMeshNode(signerNum)));
|
|
|
|
db->runDemote();
|
|
|
|
// Both are out of the hot store and held in the warm tier.
|
|
TEST_ASSERT_NULL(db->getMeshNode(signerNum));
|
|
TEST_ASSERT_NULL(db->getMeshNode(plainNum));
|
|
|
|
const meshtastic_NodeInfoLite *back = db->getOrCreateMeshNode(signerNum);
|
|
TEST_ASSERT_NOT_NULL(back);
|
|
TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(back), "signer bit must survive a warm-tier round trip");
|
|
|
|
const meshtastic_NodeInfoLite *plainBack = db->getOrCreateMeshNode(plainNum);
|
|
TEST_ASSERT_NOT_NULL(plainBack);
|
|
TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit");
|
|
}
|
|
|
|
// A warm record stores 32 raw key bytes with no length, so a partial hot-store key would be
|
|
// indistinguishable from a real one once demoted. It must land as a keyless placeholder instead.
|
|
static void test_migration_dropsShortKeyOnDemotion(void)
|
|
{
|
|
db->clearWarm();
|
|
db->seedSelf();
|
|
const NodeNum shortKeyNum = 2000 + 3;
|
|
const NodeNum fullKeyNum = 2000 + 4;
|
|
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
|
// Warm entries steal the low 7 bits of last_heard for role and protected-category metadata
|
|
// (WARM_TIME_MASK), so seed multiples of 128 to keep the values representable once demoted.
|
|
for (int i = 1; i <= extra; i++)
|
|
db->push(2000 + i, /*last_heard=*/(uint32_t)i * 128, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true,
|
|
/*withKey=*/true, meshtastic_Config_DeviceConfig_Role_CLIENT,
|
|
/*keySize=*/(NodeNum)(2000 + i) == shortKeyNum ? 31 : 32);
|
|
|
|
db->runDemote();
|
|
|
|
// Both left the hot store; only the full key is allowed through to the warm tier.
|
|
TEST_ASSERT_NULL(db->getMeshNode(shortKeyNum));
|
|
TEST_ASSERT_NULL(db->getMeshNode(fullKeyNum));
|
|
TEST_ASSERT_FALSE_MESSAGE(warmHasKey(shortKeyNum), "a 31-byte key must not be demoted as if it were a full key");
|
|
TEST_ASSERT_TRUE_MESSAGE(warmHasKey(fullKeyNum), "a full 32-byte key still survives demotion");
|
|
|
|
// The short-key node is still held, just keyless, so re-admission restores its last_heard.
|
|
uint8_t role = 0xFF, prot = 0xFF;
|
|
TEST_ASSERT_TRUE_MESSAGE(db->warmMeta(shortKeyNum, role, prot), "keyless placeholder row must still be present");
|
|
WarmNodeEntry placeholder = {};
|
|
TEST_ASSERT_TRUE_MESSAGE(db->warmTake(shortKeyNum, placeholder), "placeholder must be readable from the warm tier");
|
|
TEST_ASSERT_EQUAL_UINT32_MESSAGE(3u * 128, warmTimeOf(placeholder), "the keyless placeholder must carry last_heard");
|
|
}
|
|
|
|
// Favourite handling: a favourite is never the eviction victim, even when it is
|
|
// the oldest node in a full hot store.
|
|
static void test_eviction_preservesFavorite(void)
|
|
{
|
|
db->seedSelf();
|
|
for (int i = 1; i < MAX_NUM_NODES; i++) { // fill to MAX_NUM_NODES total (incl. self)
|
|
const bool fav = (i == 1); // oldest non-self, favourite
|
|
db->push(3000 + i, /*last_heard=*/i, fav, false, /*withUser=*/true, /*withKey=*/true);
|
|
}
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); // full
|
|
|
|
TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x99990000)); // forces an eviction
|
|
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(3000 + 1)); // favourite survived despite being oldest
|
|
TEST_ASSERT_NULL(db->getMeshNode(3000 + 2)); // oldest non-favourite evicted
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x99990000));
|
|
}
|
|
|
|
// A node heard during this boot is newer than every persisted epoch, including valid epochs after
|
|
// 2038. Ranking both domains in one uint32_t incorrectly evicts the current-boot node first.
|
|
static void test_eviction_prefersCurrentBootStampOverPost2038Epoch(void)
|
|
{
|
|
constexpr NodeNum futureDated = 0x70000001;
|
|
constexpr NodeNum heardThisBoot = 0x70000002;
|
|
|
|
db->seedSelf();
|
|
db->push(futureDated, 0xB5000000u, false, false, /*withUser=*/true, /*withKey=*/true);
|
|
db->push(heardThisBoot, 0, false, false, /*withUser=*/true, /*withKey=*/true);
|
|
db->stampUntrusted(heardThisBoot, 10);
|
|
for (int i = 3; i < MAX_NUM_NODES; i++)
|
|
db->push(0x70000000u + i, UINT32_MAX, false, false, /*withUser=*/true, /*withKey=*/true);
|
|
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes());
|
|
TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x79999999));
|
|
|
|
TEST_ASSERT_NULL(db->getMeshNode(futureDated));
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(heardThisBoot));
|
|
}
|
|
|
|
// Ignored handling: an ignored node survives eviction (like a favourite), and is
|
|
// never purged by cleanupMeshDB even with no user info (a block set by bare ID).
|
|
static void test_ignored_survivesEvictionAndCleanup(void)
|
|
{
|
|
// (a) eviction protection
|
|
db->clearHot();
|
|
db->seedSelf();
|
|
for (int i = 1; i < MAX_NUM_NODES; i++) {
|
|
const bool ign = (i == 1); // oldest non-self, blocked
|
|
db->push(4000 + i, /*last_heard=*/i, false, ign, /*withUser=*/true, /*withKey=*/true);
|
|
}
|
|
TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x88880000));
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(4000 + 1)); // blocked node survived
|
|
TEST_ASSERT_NULL(db->getMeshNode(4000 + 2)); // oldest non-blocked evicted
|
|
|
|
// (b) cleanup protection - ignored kept without user info, plain no-user purged
|
|
db->clearHot();
|
|
db->seedSelf();
|
|
db->push(5000, 100, false, /*ignored=*/true, /*withUser=*/false, false);
|
|
db->push(5001, 100, false, false, /*withUser=*/false, false);
|
|
db->runCleanup();
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(5000)); // blocked-by-ID kept despite no user info
|
|
TEST_ASSERT_NULL(db->getMeshNode(5001)); // ordinary no-user node purged
|
|
}
|
|
|
|
// Protected-node cap: at most MAX_NUM_NODES-2 nodes may be protected, so >=2
|
|
// evictable slots always remain. setProtectedFlag refuses once the cap is hit.
|
|
static void test_protectedCap_refusesBeyondLimit(void)
|
|
{
|
|
db->seedSelf();
|
|
for (int i = 0; i < MAX_NUM_NODES - 2; i++)
|
|
db->push(6000 + i, 100, /*favorite=*/true, false, /*withUser=*/true, false);
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 2, db->numProtectedNodes());
|
|
|
|
db->push(7000, 100, false, false, /*withUser=*/true, false);
|
|
meshtastic_NodeInfoLite *fresh = db->getMeshNode(7000);
|
|
TEST_ASSERT_NOT_NULL(fresh);
|
|
TEST_ASSERT_FALSE(db->setProtectedFlag(fresh, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)); // refused at cap
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(fresh)); // unchanged
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 2, db->numProtectedNodes());
|
|
|
|
// Adding another flag to an already-protected node doesn't grow the set, so
|
|
// it's still allowed at the cap.
|
|
meshtastic_NodeInfoLite *already = db->getMeshNode(6000);
|
|
TEST_ASSERT_TRUE(db->setProtectedFlag(already, NODEINFO_BITFIELD_IS_IGNORED_MASK, true));
|
|
}
|
|
|
|
// removeNodeByNum() compacts survivors down and clears the slots that leaves free. A full
|
|
// store with no matching node frees none, so there is nothing past the last node to clear.
|
|
static void test_removeNodeByNum_absentNodeOnFullDb(void)
|
|
{
|
|
db->seedSelf();
|
|
for (int i = 1; i < MAX_NUM_NODES; i++) // fill to MAX_NUM_NODES total (incl. self)
|
|
db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true);
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes());
|
|
|
|
db->removeNodeByNum(0xDEADBEEF); // absent; ASan flags a write past the last slot
|
|
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); // nothing removed
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x0BADF00D)); // self intact
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 1));
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // last slot intact
|
|
}
|
|
|
|
// Control for the above: a matching node on a full store is still removed, the survivors
|
|
// compact down, and the freed tail slot is cleared.
|
|
static void test_removeNodeByNum_presentNodeOnFullDb(void)
|
|
{
|
|
db->seedSelf();
|
|
for (int i = 1; i < MAX_NUM_NODES; i++)
|
|
db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true);
|
|
|
|
db->removeNodeByNum(8000 + 5);
|
|
|
|
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 1, (int)db->getNumMeshNodes());
|
|
TEST_ASSERT_NULL(db->getMeshNode(8000 + 5));
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 4));
|
|
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // survivors kept
|
|
}
|
|
|
|
#if defined(ARCH_PORTDUINO)
|
|
// NodeDB::isHalfEmpty() and the band it opens against isFull(). The ad-hoc greeting in
|
|
// MeshService::handleFromRadio() reads it before sending an unsolicited NodeInfo to a node it holds
|
|
// no user record for: greeting now stops at the half-way mark while admission continues to the cap,
|
|
// so there is a deliberate occupancy band in which the store still takes new nodes but no longer
|
|
// introduces itself to them. Before this the gate was !isFull(), and a node kept greeting up to the
|
|
// last free slot - the regime where the store is already churning and the entry a greeting buys is
|
|
// least likely to survive.
|
|
//
|
|
// Sweeping the cap matters because it is not a constant: on portduino MAX_NUM_NODES resolves to
|
|
// General.MaxNodes on every read, and a predicate that captured it once - a static, a value copied
|
|
// in the constructor - would greet at the wrong occupancy on every deployment that sets one.
|
|
//
|
|
// Not covered: the MINIMUM_SAFE_FREE_HEAP term both predicates carry. memGet.getFreeHeap() returns
|
|
// UINT32_MAX on portduino, so that branch is unreachable natively and is not faked.
|
|
|
|
// The caps a real deployment has - STM32WL's 10, the nRF52840/ESP32 120, portduino/ESP32-S3 200 and
|
|
// 250 - plus odd caps, and 2 where an off-by-one stops being one slot and becomes the upper half.
|
|
static constexpr int kCaps[] = {2, 3, 10, 11, 120, 121, 200, 250};
|
|
|
|
static const char *occ(int cap, int n)
|
|
{
|
|
static char buf[64];
|
|
snprintf(buf, sizeof(buf), "cap=%d occupancy=%d", cap, n);
|
|
return buf;
|
|
}
|
|
|
|
// Strictly more than half the slots must be free: exactly half full is not half empty, and at an
|
|
// odd cap the unsplittable slot counts as empty (2n < cap). Relaxing this to >= hands greeting one
|
|
// more slot at every cap.
|
|
static void test_halfEmpty_boundaryIsExclusiveAtEveryCap(void)
|
|
{
|
|
for (int cap : kCaps) {
|
|
portduino_config.MaxNodes = cap;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(cap, (int)MAX_NUM_NODES, "MAX_NUM_NODES must track General.MaxNodes at runtime");
|
|
|
|
const int halfWay = cap / 2;
|
|
|
|
db->setOccupancy(0);
|
|
TEST_ASSERT_TRUE_MESSAGE(db->isHalfEmpty(), occ(cap, 0)); // a fresh node must greet
|
|
|
|
db->setOccupancy(halfWay);
|
|
TEST_ASSERT_EQUAL_MESSAGE(2 * halfWay < cap, db->isHalfEmpty(), occ(cap, halfWay));
|
|
|
|
db->setOccupancy(halfWay + 1);
|
|
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), occ(cap, halfWay + 1));
|
|
|
|
db->setOccupancy(cap);
|
|
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), occ(cap, cap));
|
|
TEST_ASSERT_TRUE_MESSAGE(db->isFull(), occ(cap, cap));
|
|
}
|
|
}
|
|
|
|
// The band is the point of the change: above the half-way mark and below the cap, admission
|
|
// continues (!isFull) while greeting has stopped (!isHalfEmpty). The two are never both true. If
|
|
// either predicate drifts the band closes, and greeting either runs to the last slot again or stops
|
|
// when admission does.
|
|
static void test_halfEmpty_theBandWhereAdmissionOutlivesGreeting(void)
|
|
{
|
|
for (int cap : kCaps) {
|
|
portduino_config.MaxNodes = cap;
|
|
|
|
int bandWidth = 0;
|
|
for (int n = 0; n <= cap; n++) {
|
|
db->setOccupancy(n);
|
|
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty() && db->isFull(), occ(cap, n));
|
|
if (n < cap && !db->isHalfEmpty()) {
|
|
TEST_ASSERT_FALSE_MESSAGE(db->isFull(), occ(cap, n));
|
|
bandWidth++;
|
|
}
|
|
}
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(cap - (cap / 2 + (cap % 2)), bandWidth, occ(cap, -1));
|
|
}
|
|
}
|
|
|
|
// The same occupancy changes answer when the cap moves underneath it, with no store change - what a
|
|
// General.MaxNodes edit plus a restart does, and what a cached cap gets wrong.
|
|
static void test_halfEmpty_followsACapChangedUnderneathIt(void)
|
|
{
|
|
db->setOccupancy(70);
|
|
|
|
portduino_config.MaxNodes = 120;
|
|
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), "70 of 120 is past the half-way mark");
|
|
|
|
portduino_config.MaxNodes = 200;
|
|
TEST_ASSERT_TRUE_MESSAGE(db->isHalfEmpty(), "70 of 200 leaves more than half free");
|
|
|
|
portduino_config.MaxNodes = 140;
|
|
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), "70 of 140 is exactly half full, which is not half empty");
|
|
|
|
portduino_config.MaxNodes = 141;
|
|
TEST_ASSERT_TRUE_MESSAGE(db->isHalfEmpty(), "70 of 141 leaves the spare slot free, so more than half");
|
|
}
|
|
#endif // ARCH_PORTDUINO
|
|
|
|
NDB_TEST_ENTRY void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
db = new NodeDBTestShim();
|
|
nodeDB = db;
|
|
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf);
|
|
RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm);
|
|
RUN_TEST(test_migration_carriesSignerBitThroughWarm);
|
|
RUN_TEST(test_migration_dropsShortKeyOnDemotion);
|
|
RUN_TEST(test_eviction_preservesFavorite);
|
|
RUN_TEST(test_eviction_prefersCurrentBootStampOverPost2038Epoch);
|
|
RUN_TEST(test_ignored_survivesEvictionAndCleanup);
|
|
RUN_TEST(test_protectedCap_refusesBeyondLimit);
|
|
RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb);
|
|
RUN_TEST(test_removeNodeByNum_presentNodeOnFullDb);
|
|
#if defined(ARCH_PORTDUINO)
|
|
RUN_TEST(test_halfEmpty_boundaryIsExclusiveAtEveryCap);
|
|
RUN_TEST(test_halfEmpty_theBandWhereAdmissionOutlivesGreeting);
|
|
RUN_TEST(test_halfEmpty_followsACapChangedUnderneathIt);
|
|
#endif
|
|
exit(UNITY_END());
|
|
}
|
|
NDB_TEST_ENTRY void loop() {}
|
|
|
|
#else // WARM_NODE_COUNT == 0 - nothing to exercise here
|
|
|
|
void setUp(void) {}
|
|
void tearDown(void) {}
|
|
NDB_TEST_ENTRY void setup()
|
|
{
|
|
UNITY_BEGIN();
|
|
exit(UNITY_END());
|
|
}
|
|
NDB_TEST_ENTRY void loop() {}
|
|
|
|
#endif
|