Files
9084439392 test(nodedb): drive the deferred node save from a real peer (#12114)
The deferred-save cases added in #11930 set the dirty flag through the test shim. These two go
through NodeDB::updateUser() with a new peer inside the one-minute throttle window, check the peer
is not yet in nodes.proto, then read nodes.proto back after the minute passes and after a reboot
and assert the peer is there under its name. Both fail if updateUser() stops marking the database
dirty.

Co-authored-by: marshallumsted <bot@marshallumsted.dev>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-10-10 00:39:24 +00:00

401 lines
15 KiB
C++

// NodeDB save-failure contract, NodeDB::saveProto() and NodeDB::saveToDisk() in src/mesh/NodeDB.cpp.
//
// saveProto() must report a write that did not land. SafeFile::close() verifies the .tmp by hash and
// renames it over the live file; saveProto() used to capture that result and then return the
// pb_encode status alone, so a torn page or a failed rename counted as a successful save and the
// caller's recovery never ran. The regression guarded: saveProto() returning true while the live
// file was never replaced.
//
// saveToDisk() must not answer a failed write with fsFormat(). A write that fails once is far more
// often a rail dip or a busy SoftDevice than a corrupt filesystem, and the format takes every file on
// the device with it; on nRF52 that is the "critical error 12/13" report followed by a new node
// identity. The contract pinned here: a transient failure is retried and the retry lands, and a rail
// that is still unsafe at the retry gate makes saveToDisk() return false and leave the filesystem
// alone. The regression guarded: any path from a single failed write straight into fsFormat().
//
// The rail is driven through powerHAL_isPowerLevelSafe(), whose native default is a weak "always
// safe"; this suite supplies a strong, scripted definition. Windows links the default strongly, so
// the gate-driven cases are compiled out there and only the rename case runs. The format branch
// itself cannot be reached in a native test: on portduino a FLASH_CORRUPTION critical error exits the
// process, which is exactly the outcome the assertions here prove is not taken.
//
// NodeDB::saveNodeDatabaseIfDirty() and the reboot observer must eventually persist a node change that
// updateUser() deferred under its once-a-minute budget. updateUser() used to skip the save outright
// when the last one was under a minute old, so a NodeInfo from a new peer heard in that window was
// never written and the peer was gone after the next restart (#11928). The contract pinned: a pending
// change survives the throttle and is written once the minute passes, and a reboot writes it at once.
// The regression guarded: a dirty node database that is never saved.
#include "MeshTypes.h" // Include BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>
#if defined(ARCH_PORTDUINO)
#define NSR_TEST_ENTRY extern "C"
#else
#define NSR_TEST_ENTRY
#endif
#include "FSCommon.h" // defines FSCom; must precede the feature guard below
#if defined(FSCom)
#include "UptimeClock.h" // test clock
#include "mesh/NodeDB.h"
#include "power/PowerHAL.h"
#include "sleep.h" // notifyReboot
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <vector>
// Friend seam declared in NodeDB.h (PIO_UNIT_TESTING): drive the deferred-save state directly.
// Never instantiated - constructing one would run the real boot sequence.
class NodeDBTestShim : public NodeDB
{
public:
static void markDirty(NodeDB *db, uint32_t lastSaveMs)
{
db->nodeDatabaseDirty = true;
db->lastNodeDbSave = lastSaveMs;
}
static bool dirty(const NodeDB *db) { return db->nodeDatabaseDirty; }
// A node save "just happened", so updateUser()'s next save is deferred by the one-minute throttle.
static void markSaved(NodeDB *db, uint32_t lastSaveMs) { db->lastNodeDbSave = lastSaveMs; }
};
namespace
{
// --- Scripted rail -------------------------------------------------------------------------------
// Each powerHAL_isPowerLevelSafe() call consumes one entry; past the end the rail reads safe.
// saveToDisk() asks at entry, saveToDiskNoRetry() and saveProto() ask again, and the retry gate asks
// before each retry, so the second reading is the first one a write can see.
std::vector<bool> railScript;
size_t railCalls = 0;
void scriptRail(std::initializer_list<bool> readings)
{
railScript.assign(readings);
railCalls = 0;
}
// --- File helpers ----------------------------------------------------------------------------------
bool readFileBytes(const char *path, std::vector<uint8_t> &out)
{
out.clear();
File f = FSCom.open(path, FILE_O_READ);
if (!f)
return false;
uint8_t buf[512];
size_t n;
while ((n = f.read(buf, sizeof(buf))) > 0)
out.insert(out.end(), buf, buf + n);
f.close();
return true;
}
void writeFileBytes(const char *path, const std::vector<uint8_t> &bytes)
{
FSCom.remove(path); // FILE_O_WRITE is append on some backends; start clean
File f = FSCom.open(path, FILE_O_WRITE);
TEST_ASSERT_TRUE_MESSAGE(f, path);
TEST_ASSERT_EQUAL_size_t(bytes.size(), f.write(bytes.data(), bytes.size()));
f.close();
}
// FNV-1a content fingerprint; answers only "did this file change?". 0 == missing file.
uint64_t fileFingerprint(const char *path)
{
std::vector<uint8_t> bytes;
if (!readFileBytes(path, bytes))
return 0;
uint64_t h = 1469598103934665603ULL;
for (uint8_t b : bytes) {
h ^= b;
h *= 1099511628211ULL;
}
return h;
}
// Make the next persisted config differ from the last one, so a landed write is visible.
void bumpConfig()
{
config.device.node_info_broadcast_secs += 1;
}
// Read nodes.proto back from flash and report whether it holds this node, and under which name.
bool persistedNodeName(NodeNum num, std::string &longName)
{
meshtastic_NodeDatabase reloaded{};
if (nodeDB->loadProto(nodeDatabaseFileName, nodeDB->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase),
&meshtastic_NodeDatabase_msg, &reloaded) != LoadFileResult::LOAD_SUCCESS)
return false;
for (const auto &n : reloaded.nodes) {
if (n.num == num) {
longName = n.long_name;
return true;
}
}
return false;
}
// A peer heard for the first time, with a key so updateUser() takes the normal path.
meshtastic_User makePeer(const char *longName)
{
meshtastic_User u = meshtastic_User_init_zero;
strncpy(u.long_name, longName, sizeof(u.long_name) - 1);
strncpy(u.short_name, "PR", sizeof(u.short_name) - 1);
u.public_key.size = 32;
memset(u.public_key.bytes, 0x5A, 32);
u.public_key.bytes[0] = 0x01;
return u;
}
// PortduinoFS::rmdir() is unlink() underneath and cannot remove a directory, so go to the host.
bool removeHostDirectory(const char *fsPath)
{
std::string host = std::string(getenv("HOME")) + "/.portduino/default" + fsPath;
return ::rmdir(host.c_str()) == 0;
}
} // namespace
#if !defined(_WIN32)
// Strong definition shadows the weak native default in src/power/PowerHAL.cpp.
bool powerHAL_isPowerLevelSafe()
{
if (railCalls < railScript.size())
return railScript[railCalls++];
railCalls++;
return true;
}
#endif
// --- saveProto ------------------------------------------------------------------------------------
// A directory squatting on the live path makes the final rename fail after the .tmp was written and
// verified, which is the only failure SafeFile::close() reports that pb_encode cannot see.
static void test_saveProto_failedRename_returnsFalse(void)
{
TEST_MESSAGE("=== saveProto: a failed rename is reported, not swallowed ===");
std::vector<uint8_t> live;
TEST_ASSERT_TRUE(readFileBytes(configFileName, live));
TEST_ASSERT_TRUE(FSCom.remove(configFileName));
TEST_ASSERT_TRUE(FSCom.mkdir(configFileName));
const bool saved = nodeDB->saveProto(configFileName, meshtastic_LocalConfig_size, &meshtastic_LocalConfig_msg, &config);
// Restore the sandbox before asserting so a failure does not leak the directory into later tests.
std::string tmp = std::string(configFileName) + ".tmp";
FSCom.remove(tmp.c_str());
TEST_ASSERT_TRUE(removeHostDirectory(configFileName));
writeFileBytes(configFileName, live);
TEST_ASSERT_FALSE(saved);
}
#if !defined(_WIN32)
// --- saveToDisk retry gate -------------------------------------------------------------------------
static void test_saveToDisk_railDipDuringWrite_retriesAndLands(void)
{
TEST_MESSAGE("=== saveToDisk: one unsafe reading mid-write is retried, no format ===");
const uint64_t before = fileFingerprint(configFileName);
TEST_ASSERT_NOT_EQUAL_UINT64(0, before);
bumpConfig();
scriptRail({true, false}); // entry safe, the write itself sees the dip, the retry gate is safe
TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG));
TEST_ASSERT_GREATER_THAN_size_t(2, railCalls); // the retry gate was consulted
TEST_ASSERT_NOT_EQUAL_UINT64(before, fileFingerprint(configFileName));
TEST_ASSERT_TRUE(FSCom.exists(deviceStateFileName)); // an fsFormat() would have taken this too
}
static void test_saveToDisk_railStillUnsafeAtRetry_bailsWithoutFormat(void)
{
TEST_MESSAGE("=== saveToDisk: rail unsafe at the retry gate returns false, no format ===");
const uint64_t before = fileFingerprint(configFileName);
TEST_ASSERT_NOT_EQUAL_UINT64(0, before);
bumpConfig();
scriptRail({true, false, false, false, false}); // never recovers while this save is in progress
// On portduino a FLASH_CORRUPTION critical error exits the process, so surviving this call is
// itself the proof that no format was attempted.
TEST_ASSERT_FALSE(nodeDB->saveToDisk(SEGMENT_CONFIG));
TEST_ASSERT_EQUAL_UINT64(before, fileFingerprint(configFileName));
TEST_ASSERT_TRUE(FSCom.exists(deviceStateFileName));
}
// The whole point of the retry gate: a write that keeps failing while the filesystem still reads is a
// busy or lock-protected flash, not corruption, and formatting would take every other file with it.
static void test_saveToDisk_writeFailsButFsReadable_doesNotFormat(void)
{
TEST_MESSAGE("=== saveToDisk: unwritable but readable filesystem must not be formatted ===");
std::vector<uint8_t> live;
TEST_ASSERT_TRUE(readFileBytes(configFileName, live));
const uint64_t deviceStateBefore = fileFingerprint(deviceStateFileName);
// A directory on the live path fails every rename, so all retries fail - while /prefs and the
// other protos stay perfectly readable.
TEST_ASSERT_TRUE(FSCom.remove(configFileName));
TEST_ASSERT_TRUE(FSCom.mkdir(configFileName));
const bool saved = nodeDB->saveToDisk(SEGMENT_CONFIG);
std::string tmp = std::string(configFileName) + ".tmp";
FSCom.remove(tmp.c_str());
TEST_ASSERT_TRUE(removeHostDirectory(configFileName));
writeFileBytes(configFileName, live);
TEST_ASSERT_FALSE(saved);
// The decisive assertion: an fsFormat() would have taken devicestate with it.
TEST_ASSERT_TRUE(FSCom.exists(deviceStateFileName));
TEST_ASSERT_EQUAL_UINT64(deviceStateBefore, fileFingerprint(deviceStateFileName));
}
static void test_saveToDisk_railUnsafeAtEntry_returnsFalseImmediately(void)
{
TEST_MESSAGE("=== saveToDisk: rail unsafe at entry writes nothing ===");
const uint64_t before = fileFingerprint(configFileName);
bumpConfig();
scriptRail({false});
TEST_ASSERT_FALSE(nodeDB->saveToDisk(SEGMENT_CONFIG));
TEST_ASSERT_EQUAL_size_t(1, railCalls);
TEST_ASSERT_EQUAL_UINT64(before, fileFingerprint(configFileName));
}
#endif // !_WIN32
// --- Deferred node database save -------------------------------------------------------------------
static void test_saveNodeDatabaseIfDirty_waitsOutThrottleThenSaves(void)
{
TEST_MESSAGE("=== saveNodeDatabaseIfDirty: a deferred change is written once the minute passes ===");
Time::setTestMillis(10 * 60 * 1000);
NodeDBTestShim::markDirty(nodeDB, Time::getMillis()); // a save just happened, so this change was deferred
nodeDB->saveNodeDatabaseIfDirty();
TEST_ASSERT_TRUE(NodeDBTestShim::dirty(nodeDB)); // still inside the minute: nothing written yet
Time::advanceTestMillis(60 * 1000);
nodeDB->saveNodeDatabaseIfDirty();
TEST_ASSERT_FALSE(NodeDBTestShim::dirty(nodeDB)); // cleared only by a save that landed
}
static void test_rebootObserver_savesDirtyNodeDatabase(void)
{
TEST_MESSAGE("=== reboot: a pending node change is written before restart ===");
Time::setTestMillis(20 * 60 * 1000);
NodeDBTestShim::markDirty(nodeDB, Time::getMillis()); // the throttle alone would hold it back
notifyReboot.notifyObservers(NULL);
TEST_ASSERT_FALSE(NodeDBTestShim::dirty(nodeDB));
}
// The same contract driven from a real peer: a NodeInfo arriving inside the throttle window must reach
// nodes.proto once the window passes, not just clear the dirty flag.
static void test_updateUser_deferredPeer_isPersistedAfterThrottle(void)
{
TEST_MESSAGE("=== updateUser: a peer deferred by the throttle is written to nodes.proto later ===");
constexpr NodeNum kPeer = 0xE1000101;
Time::setTestMillis(30 * 60 * 1000);
NodeDBTestShim::markSaved(nodeDB, Time::getMillis());
meshtastic_User u = makePeer("Bob");
TEST_ASSERT_TRUE(nodeDB->updateUser(kPeer, u));
std::string name;
TEST_ASSERT_FALSE(persistedNodeName(kPeer, name)); // deferred: not on flash yet
Time::advanceTestMillis(60 * 1000);
nodeDB->saveNodeDatabaseIfDirty();
TEST_ASSERT_TRUE(persistedNodeName(kPeer, name));
TEST_ASSERT_EQUAL_STRING("Bob", name.c_str());
}
// A reboot inside the throttle window must not lose a peer heard just before it.
static void test_updateUser_deferredPeer_isPersistedOnReboot(void)
{
TEST_MESSAGE("=== updateUser: a deferred peer is written to nodes.proto at reboot ===");
constexpr NodeNum kPeer = 0xE1000102;
Time::setTestMillis(40 * 60 * 1000);
NodeDBTestShim::markSaved(nodeDB, Time::getMillis());
meshtastic_User u = makePeer("Carol");
TEST_ASSERT_TRUE(nodeDB->updateUser(kPeer, u));
std::string name;
TEST_ASSERT_FALSE(persistedNodeName(kPeer, name));
notifyReboot.notifyObservers(NULL);
TEST_ASSERT_TRUE(persistedNodeName(kPeer, name));
TEST_ASSERT_EQUAL_STRING("Carol", name.c_str());
}
void setUp(void)
{
scriptRail({});
}
void tearDown(void)
{
Time::useRealClock();
}
NSR_TEST_ENTRY void setup()
{
initializeTestEnvironment();
nodeDB = new NodeDB(); // first boot on the pristine per-suite sandbox persists the default set
UNITY_BEGIN();
printf("\n=== saveProto ===\n");
RUN_TEST(test_saveProto_failedRename_returnsFalse);
#if !defined(_WIN32)
printf("\n=== saveToDisk retry gate ===\n");
RUN_TEST(test_saveToDisk_railDipDuringWrite_retriesAndLands);
RUN_TEST(test_saveToDisk_railStillUnsafeAtRetry_bailsWithoutFormat);
RUN_TEST(test_saveToDisk_writeFailsButFsReadable_doesNotFormat);
RUN_TEST(test_saveToDisk_railUnsafeAtEntry_returnsFalseImmediately);
#endif
printf("\n=== deferred node database save ===\n");
RUN_TEST(test_saveNodeDatabaseIfDirty_waitsOutThrottleThenSaves);
RUN_TEST(test_rebootObserver_savesDirtyNodeDatabase);
RUN_TEST(test_updateUser_deferredPeer_isPersistedAfterThrottle);
RUN_TEST(test_updateUser_deferredPeer_isPersistedOnReboot);
exit(UNITY_END());
}
NSR_TEST_ENTRY void loop() {}
#else // !FSCom
void setUp(void) {}
void tearDown(void) {}
NSR_TEST_ENTRY void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
NSR_TEST_ENTRY void loop() {}
#endif