Service ESP received advert cache writes between loop passes

This commit is contained in:
mikecarper
2026-10-04 08:20:28 -07:00
parent 62d76b7bab
commit 55e4f47338
9 changed files with 984 additions and 8 deletions
+322 -5
View File
@@ -6,6 +6,7 @@
#include "PrefsStorageLayout.h"
#include <helpers/FileRead.h>
#include <helpers/AdvertDataHelpers.h>
#include <helpers/LazyPersistence.h>
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#include <helpers/ContactFileTransaction.h>
#endif
@@ -145,6 +146,7 @@ static bool contactPathPresence(FILESYSTEM* fs, const char* path,
void DataStore::begin() {
#if defined(ESP32_PLATFORM)
_advert_write.clear();
// Reinitialization must not retain a streaming transaction or an inode
// opened through an earlier filesystem route. Dirty mutations stay pending.
cancelContactWrite();
@@ -729,6 +731,8 @@ bool DataStore::removeFile(FILESYSTEM* fs, const char* filename) {
bool DataStore::formatFileSystem() {
#if defined(ESP32_PLATFORM)
// A failed format keeps the latest accepted RAM packets available to retry.
_advert_write.pause();
cancelContactWrite();
#if MESH_CONTACT_CACHE
_contact_path_reader.close();
@@ -796,6 +800,7 @@ bool DataStore::formatFileSystem() {
bool fs_success = ((fs::SPIFFSFS *)_fs)->format();
esp_err_t nvs_err = nvs_flash_erase(); // no need to reinit, will be done by reboot
if (fs_success && nvs_err == ESP_OK) {
_advert_write.clear();
_identity_creation_blocked = false;
_prefs_load_incomplete = false;
_channel_load_incomplete = false;
@@ -3761,9 +3766,34 @@ inline void makeBlobPath(const uint8_t key[], int key_len, char* path, size_t pa
uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) {
char path[64];
#if defined(ESP32_PLATFORM)
if (key_len <= 0) return 0;
#endif
makeBlobPath(key, key_len, path, sizeof(path));
if (_fs->exists(path)) {
#if defined(ESP32_PLATFORM)
const int prefix_len = key_len > 8 ? 8 : key_len;
for (const auto& slot : _advert_write.slots) {
if (slot.len && slot.key_len == prefix_len
&& memcmp(slot.key, key, prefix_len) == 0) {
memcpy(dest_buf, slot.bytes, slot.len);
return slot.len;
}
}
// SPIFFS cannot rename over an existing file. A reset between the staged
// target->backup and temp->target renames leaves the prior valid cache here.
_advert_write.synchronous_io = true;
bool present = false;
if (!companionPathPresence(_fs, path, present)) return 0;
if (!present) {
const size_t used = strlen(path);
strcpy(path + used, ".bak");
if (!companionPathPresence(_fs, path, present)) return 0;
}
#else
const bool present = _fs->exists(path);
#endif
if (present) {
File f = openRead(_fs, path);
if (f) {
int len = f.read(dest_buf, 255); // currently MAX 255 byte blob len supported!!
@@ -3777,12 +3807,21 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b
bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) {
char path[64];
makeBlobPath(key, key_len, path, sizeof(path));
#if defined(ESP32_PLATFORM)
_advert_write.synchronous_io = true;
invalidateAdvertWrite(key, key_len);
#endif
File f = openWrite(_fs, path);
if (f) {
int n = f.write(src_buf, len);
f.close();
if (n == len) return true; // success!
if (n == len) {
#if defined(ESP32_PLATFORM)
retireAdvertWrite(key, key_len);
#endif
return true;
}
_fs->remove(path); // blob was only partially written!
}
@@ -3794,12 +3833,23 @@ bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
makeBlobPath(key, key_len, path, sizeof(path));
#if defined(ESP32_PLATFORM)
_advert_write.synchronous_io = true;
invalidateAdvertWrite(key, key_len);
// VFS remove() logs an error for normal absence, contaminating the binary
// USB stream. Distinguish absence from metadata failure before calling it;
// a real deletion failure must preserve the live contact for rollback.
bool present = false;
if (!companionPathPresence(_fs, path, present)) return false;
return !present || _fs->remove(path);
char backup[68];
snprintf(backup, sizeof(backup), "%s.bak", path);
bool present = false, backup_present = false;
// Probe both before mutating either; removing the backup first preserves
// the authoritative target if a later deletion fails and the contact rolls
// back. A successful deletion invalidates publication before staged cleanup.
if (!companionPathPresence(_fs, path, present)
|| !companionPathPresence(_fs, backup, backup_present)) return false;
if (backup_present && !_fs->remove(backup)) return false;
if (present && !_fs->remove(path)) return false;
retireAdvertWrite(key, key_len);
return true;
#else
_fs->remove(path);
@@ -3808,6 +3858,273 @@ bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
}
#endif
#if defined(ESP32_PLATFORM)
bool DataStore::queueAdvertByKey(const uint8_t key[], int key_len,
const uint8_t src_buf[], uint8_t len) {
if (key_len <= 0 || len == 0) return false;
const uint8_t prefix_len = key_len > 8 ? 8 : key_len;
AdvertWriteState::Slot* chosen = nullptr;
for (auto& slot : _advert_write.slots) {
if (slot.len && slot.key_len == prefix_len
&& memcmp(slot.key, key, prefix_len) == 0) {
chosen = &slot;
break;
}
if (!slot.len && chosen == nullptr) chosen = &slot;
}
// Never force a filesystem flush or allocate another packet on overflow.
if (chosen == nullptr) return false;
memcpy(chosen->key, key, prefix_len);
memcpy(chosen->bytes, src_buf, len);
chosen->key_len = prefix_len;
chosen->len = len;
chosen->revision = ++_advert_write.revision;
return true;
}
void DataStore::retireAdvertWrite(const uint8_t key[], int key_len) {
if (key_len <= 0) return;
const int prefix_len = key_len > 8 ? 8 : key_len;
for (auto& slot : _advert_write.slots) {
if (slot.len && slot.key_len == prefix_len
&& memcmp(slot.key, key, prefix_len) == 0) {
slot.len = 0;
slot.revision = ++_advert_write.revision;
}
}
}
void DataStore::invalidateAdvertWrite(const uint8_t key[], int key_len) {
if (key_len <= 0) return;
const int prefix_len = key_len > 8 ? 8 : key_len;
auto& job = _advert_write;
for (auto& slot : job.slots) {
if (slot.len && slot.key_len == prefix_len
&& memcmp(slot.key, key, prefix_len) == 0) {
slot.revision = ++job.revision;
}
}
if (job.stage != AdvertWriteState::Stage::Idle
&& job.active_key_len == prefix_len
&& memcmp(job.active_key, key, prefix_len) == 0) {
// A synchronous API may have changed target/backup even when it returned
// failure. Re-probe the independent active key before recovery/publication.
job.cancelling = true;
job.stage = AdvertWriteState::Stage::CancelClose;
job.retry_at = 0;
}
}
bool DataStore::consumeSynchronousAdvertIO() {
const bool result = _advert_write.synchronous_io;
_advert_write.synchronous_io = false;
return result;
}
bool DataStore::hasPendingAdvertWrites() const {
if (_advert_write.stage != AdvertWriteState::Stage::Idle) return true;
for (const auto& slot : _advert_write.slots) if (slot.len) return true;
return false;
}
bool DataStore::isAdvertWriteDue(uint32_t now) const {
return hasPendingAdvertWrites() && (_advert_write.retry_at == 0
|| static_cast<int32_t>(now - _advert_write.retry_at) >= 0);
}
bool DataStore::serviceAdvertWrites(uint32_t now) {
auto& job = _advert_write;
using Stage = AdvertWriteState::Stage;
static const char* const temp = "/advert.tmp";
if (!isAdvertWriteDue(now)) return true;
job.retry_at = 0;
if (job.stage == Stage::Idle) {
for (uint8_t n = 0; n < AdvertWriteState::CAPACITY; ++n) {
const uint8_t i = (job.next + n) % AdvertWriteState::CAPACITY;
if (!job.slots[i].len) continue;
job.active = i;
job.next = (i + 1) % AdvertWriteState::CAPACITY;
job.active_revision = job.slots[i].revision;
job.active_key_len = job.slots[i].key_len;
memcpy(job.active_key, job.slots[i].key, job.active_key_len);
makeBlobPath(job.slots[i].key, job.slots[i].key_len,
job.target, sizeof(job.target));
snprintf(job.backup, sizeof(job.backup), "%s.bak", job.target);
job.offset = 0;
job.cancelling = false;
job.stage = Stage::TargetProbe;
break;
}
if (job.stage == Stage::Idle) return true;
}
auto& slot = job.slots[job.active];
if (!job.cancelling && (!slot.len || slot.revision != job.active_revision)) {
job.cancelling = true;
job.stage = Stage::CancelClose;
}
const auto retry = [&]() {
const uint32_t delay = mesh::recordLazyPersistenceSaveFailure(
job.failures, 1000, mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
job.retry_at = now + delay;
if (job.retry_at == 0) job.retry_at = 1;
return false;
};
const auto fail = [&]() {
job.cancelling = true;
job.stage = Stage::CancelClose;
return retry();
};
const auto prepared = [&]() {
if (job.cancelling) {
job.stage = Stage::Idle;
job.active = AdvertWriteState::CAPACITY;
} else job.stage = Stage::OpenWrite;
};
// Exactly one filesystem API operation in each arm. In particular, open,
// fclose (which may flush stdio), crypto in BaseChatMesh, and contact-file
// work must not accumulate in the same serviced loop pass. SPIFFS may still
// spend an unbounded amount of wall time inside one individual operation.
switch (job.stage) {
case Stage::TargetProbe:
if (!companionPathPresence(_fs, job.target, job.target_present)) return fail();
job.stage = Stage::BackupProbe;
break;
case Stage::BackupProbe:
if (!companionPathPresence(_fs, job.backup, job.backup_present)) return fail();
job.stage = job.backup_present ? (job.target_present ? Stage::RemoveBackup
: Stage::RestoreBackup)
: Stage::TempProbe;
break;
case Stage::RestoreBackup:
if (!_fs->rename(job.backup, job.target)) return fail();
job.stage = Stage::TempProbe;
break;
case Stage::RemoveBackup:
if (!_fs->remove(job.backup)) return fail();
job.stage = Stage::TempProbe;
break;
case Stage::TempProbe:
if (!companionPathPresence(_fs, temp, job.temp_present)) return fail();
if (job.temp_present) job.stage = Stage::RemoveTemp;
else prepared();
break;
case Stage::RemoveTemp:
if (!_fs->remove(temp)) return fail();
prepared();
break;
case Stage::OpenWrite:
job.file = openWrite(_fs, temp);
if (!job.file) return fail();
job.stage = Stage::ConfigureWrite;
break;
case Stage::ConfigureWrite:
if (!job.file.setBufferSize(sizeof(job.scratch))) return fail();
job.stage = Stage::Write;
break;
case Stage::Write: {
const uint8_t count = static_cast<size_t>(slot.len - job.offset) > sizeof(job.scratch)
? sizeof(job.scratch) : slot.len - job.offset;
if (job.file.write(slot.bytes + job.offset, count) != count) return fail();
job.offset += count;
if (job.offset == slot.len) job.stage = Stage::CloseWrite;
break;
}
case Stage::CloseWrite:
job.file.close();
job.stage = Stage::OpenVerify;
break;
case Stage::OpenVerify:
job.file = openRead(_fs, temp);
if (!job.file) return fail();
job.stage = Stage::ConfigureVerify;
break;
case Stage::ConfigureVerify:
if (!job.file.setBufferSize(sizeof(job.scratch))) return fail();
job.stage = Stage::VerifySize;
break;
case Stage::VerifySize:
if (job.file.size() != slot.len) return fail();
job.offset = 0;
job.stage = Stage::Verify;
break;
case Stage::Verify: {
const uint8_t count = static_cast<size_t>(slot.len - job.offset) > sizeof(job.scratch)
? sizeof(job.scratch) : slot.len - job.offset;
if (job.file.read(job.scratch, count) != count
|| memcmp(job.scratch, slot.bytes + job.offset, count) != 0) return fail();
job.offset += count;
if (job.offset == slot.len) job.stage = Stage::CloseVerify;
break;
}
case Stage::CloseVerify:
job.file.close();
job.stage = Stage::PublishTargetProbe;
break;
case Stage::PublishTargetProbe:
if (!companionPathPresence(_fs, job.target, job.target_present)) return fail();
job.stage = Stage::PublishBackupProbe;
break;
case Stage::PublishBackupProbe:
if (!companionPathPresence(_fs, job.backup, job.backup_present)) return fail();
job.stage = job.target_present ? (job.backup_present ? Stage::PublishRemoveBackup
: Stage::PublishBackup)
: Stage::Publish;
break;
case Stage::PublishRemoveBackup:
if (!_fs->remove(job.backup)) return fail();
job.stage = Stage::PublishBackup;
break;
case Stage::PublishBackup:
if (!_fs->rename(job.target, job.backup)) return fail();
job.backup_present = true;
job.stage = Stage::Publish;
break;
case Stage::Publish:
// SPIFFS rejects rename over an existing destination. The two renames
// have an explicit gap; RAM serves the new packet and disk readers after
// a reset use the old backup. No unverified temp is ever a visible blob.
if (!_fs->rename(temp, job.target)) return fail();
job.stage = Stage::PublishCleanup;
break;
case Stage::PublishCleanup:
if (job.backup_present && !_fs->remove(job.backup)) return retry();
slot.len = 0;
job.failures = 0;
job.stage = Stage::Idle;
job.active = AdvertWriteState::CAPACITY;
break;
case Stage::CancelClose:
job.file.close();
job.stage = Stage::TargetProbe;
break;
case Stage::Idle:
break;
}
return true;
}
bool DataStore::flushAdvertWrites(uint32_t now) {
// Explicit reboot/shutdown/import paths require durability and may block.
// On failure keep accepted packets in RAM, but never leave a file open.
_advert_write.synchronous_io = hasPendingAdvertWrites()
|| _advert_write.synchronous_io;
for (unsigned pass = 0; hasPendingAdvertWrites() && pass < 512; ++pass) {
_advert_write.retry_at = 0;
if (!serviceAdvertWrites(now)) {
const uint32_t retry_at = _advert_write.retry_at;
_advert_write.pause();
_advert_write.retry_at = retry_at;
return false;
}
}
if (hasPendingAdvertWrites()) {
_advert_write.pause();
return false;
}
return true;
}
#endif
#if MESH_CONTACT_CACHE
namespace {
bool cachedContactFilter(const ContactInfo& c) { return c.type != ADV_TYPE_NONE; }
+62
View File
@@ -60,6 +60,59 @@ class DataStore
bool serviceContactWrite(DataStoreHost* host,
bool (*filter)(const ContactInfo&));
#endif
#if defined(ESP32_PLATFORM)
// Advert packets are reconstructable cache data. Keep accepted updates in
// RAM while their filesystem operations run on separate loop passes.
struct AdvertWriteState {
static constexpr uint8_t CAPACITY = 4;
struct Slot {
uint8_t key[8] = {};
uint8_t bytes[255] = {};
uint32_t revision = 0;
uint8_t key_len = 0;
uint8_t len = 0;
} slots[CAPACITY];
enum class Stage : uint8_t {
Idle, TargetProbe, BackupProbe, RestoreBackup, RemoveBackup,
TempProbe, RemoveTemp, OpenWrite, ConfigureWrite, Write, CloseWrite,
OpenVerify, ConfigureVerify, VerifySize, Verify, CloseVerify,
PublishTargetProbe, PublishBackupProbe, PublishRemoveBackup,
PublishBackup, Publish, PublishCleanup, CancelClose
} stage = Stage::Idle;
File file;
uint8_t scratch[64] = {};
uint8_t active_key[8] = {};
uint8_t active_key_len = 0;
char target[24] = {};
char backup[28] = {};
uint32_t revision = 0;
uint32_t active_revision = 0;
uint32_t retry_at = 0;
uint8_t active = CAPACITY;
uint8_t next = 0;
uint8_t offset = 0;
uint8_t failures = 0;
bool target_present = false;
bool backup_present = false;
bool temp_present = false;
bool cancelling = false;
bool synchronous_io = false;
~AdvertWriteState() { file.close(); }
void pause() {
file.close();
stage = Stage::Idle;
active = CAPACITY;
retry_at = 0;
}
void clear() {
pause();
for (auto& slot : slots) slot.len = 0;
failures = 0;
}
} _advert_write;
void invalidateAdvertWrite(const uint8_t key[], int key_len);
void retireAdvertWrite(const uint8_t key[], int key_len);
#endif
#if !defined(NRF52_PLATFORM)
bool _channel_load_incomplete = false;
#if !MESH_CONTACT_CACHE
@@ -163,6 +216,15 @@ public:
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);
bool deleteBlobByKey(const uint8_t key[], int key_len);
#if defined(ESP32_PLATFORM)
bool queueAdvertByKey(const uint8_t key[], int key_len,
const uint8_t src_buf[], uint8_t len);
bool serviceAdvertWrites(uint32_t now);
bool flushAdvertWrites(uint32_t now = 0);
bool hasPendingAdvertWrites() const;
bool isAdvertWriteDue(uint32_t now) const;
bool consumeSynchronousAdvertIO();
#endif
File openRead(const char* filename);
File openRead(FILESYSTEM* fs, const char* filename);
File openDirectory(const char* path);
+47
View File
@@ -6809,6 +6809,11 @@ bool MyMesh::isContactWriteDue() const {
}
bool MyMesh::flushContactsBeforeReboot() {
#if defined(ESP32_PLATFORM)
const uint32_t now = _ms->getMillis();
if (_store->hasPendingAdvertWrites() && !_store->isAdvertWriteDue(now)) return false;
if (!_store->flushAdvertWrites(now)) return false;
#endif
// Non-nRF stores use the legacy monolithic file and therefore do not report
// dirty pages. The lazy-write deadline still proves that RAM may be newer.
if (!dirty_contacts_expiry && !_store->hasPendingContactWrites()) return true;
@@ -10316,6 +10321,9 @@ void MyMesh::loop() {
if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs();
#endif
#if defined(ESP32_PLATFORM)
servicePersistence();
#else
// is there are pending dirty contacts write needed?
if (isContactWriteDue()) {
const bool success = _store->serviceContactWrites(this, save_filter);
@@ -10330,6 +10338,7 @@ void MyMesh::loop() {
dirty_contacts_expiry, dirty_contacts_failures);
}
}
#endif
#ifdef DISPLAY_CLASS
if (_ui) {
@@ -10344,6 +10353,38 @@ void MyMesh::loop() {
#endif
}
#if defined(ESP32_PLATFORM)
void MyMesh::servicePersistence() {
// Advert crypto runs before serial service. Defer its cache I/O until here,
// and alternate with due contact work so neither job can starve the other.
// Only one persistence job is serviced in a loop pass.
if (_store->consumeSynchronousAdvertIO()) return;
const bool contact_write_due = isContactWriteDue();
const bool advert_write_due = _store->isAdvertWriteDue(_ms->getMillis());
const bool service_advert = advert_write_due
&& (!contact_write_due || _advert_write_next);
if (service_advert) {
_store->serviceAdvertWrites(_ms->getMillis());
_advert_write_next = false;
return;
}
_advert_write_next = true;
if (isContactWriteDue()) {
const bool success = _store->serviceContactWrites(this, save_filter);
if (!success) {
scheduleContactWriteRetry();
} else if (_store->hasPendingContactWrites()) {
dirty_contacts_failures = 0;
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(CONTACT_PAGE_WRITE_GAP));
} else {
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
}
}
}
#endif
bool MyMesh::advert() {
mesh::Packet* pkt;
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
@@ -10364,6 +10405,9 @@ bool MyMesh::canRecoverUsbLogging() const {
// Do not discard dirty pages or interrupt a protocol/flash/radio operation.
// Normal lazy writes resolve this veto without adding writes to each poll.
return dirty_contacts_expiry == 0 && !_store->hasPendingContactWrites()
#if defined(ESP32_PLATFORM)
&& !_store->hasPendingAdvertWrites()
#endif
&& !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply()
&& !command_radio_apply_pending && !saved_radio_apply_pending
&& _scheduled_reboot_at == 0 && !hasPendingReqs()
@@ -10418,6 +10462,9 @@ bool MyMesh::hasPendingWork() const {
#endif
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at)))
|| contact_write_needs_polling
#if defined(ESP32_PLATFORM)
|| _store->hasPendingAdvertWrites()
#endif
|| (emergency_client_repeat_packet != NULL
&& millisHasNowPassed(emergency_client_repeat_send_at))
#if COMPANION_FEATURE_TEMP_RADIO
+11
View File
@@ -491,7 +491,12 @@ private:
return _store->getBlobByKey(key, key_len, dest_buf);
}
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override {
#if defined(ESP32_PLATFORM)
return len > 0 && len <= 255
&& _store->queueAdvertByKey(key, key_len, src_buf, len);
#else
return _store->putBlobByKey(key, key_len, src_buf, len);
#endif
}
void checkCLIRescueCmd();
@@ -596,6 +601,9 @@ private:
// helpers, short-cuts
bool saveChannels() { return _store->saveChannels(this); }
void saveContacts();
#if defined(ESP32_PLATFORM)
void servicePersistence();
#endif
void scheduleContactWriteRetry();
bool isContactWriteDue() const;
bool flushContactsBeforeReboot();
@@ -722,6 +730,9 @@ private:
unsigned long sign_data_deadline;
unsigned long dirty_contacts_expiry;
uint8_t dirty_contacts_failures;
#if defined(ESP32_PLATFORM)
bool _advert_write_next = true;
#endif
TransportKey send_scope;
+134
View File
@@ -0,0 +1,134 @@
#pragma once
#include <algorithm>
#include <cassert>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <sys/stat.h>
class FakeFilesystem;
struct Stream {
FakeFilesystem* fs;
std::string path;
bool writer, closed = false;
size_t position = 0, buffer_size = 4096;
std::vector<uint8_t> buffer;
Stream(FakeFilesystem*, const char*, bool);
void close();
~Stream() { close(); }
};
class File {
std::shared_ptr<Stream> stream;
public:
File() = default;
File(FakeFilesystem*, const char*, bool);
explicit operator bool() const { return stream && !stream->closed; }
bool setBufferSize(size_t);
size_t write(const uint8_t*, size_t);
size_t read(uint8_t*, size_t);
size_t size();
void close() { if (stream) stream->close(); stream.reset(); }
};
class FakeFilesystem {
public:
std::map<std::string, std::vector<uint8_t>> files;
unsigned operations = 0, handles = 0, backend_writes = 0;
unsigned rename_calls = 0, remove_calls = 0, config_calls = 0;
unsigned metadata_calls = 0, fail_metadata_at = 0;
unsigned fail_rename = 0, fail_remove = 0, fail_config = 0;
bool fail_metadata = false, fail_open = false, partial_open = false;
bool fail_read = false, short_write = false, fail_close = false, fail_format = false;
size_t max_backend_write = 0;
bool exists(const char* path) const { return files.count(path) != 0; }
bool mkdir(const char*) { ++operations; return true; }
bool format() { ++operations; if (fail_format) return false; files.clear(); return true; }
File open(const char* path, const char* mode = "r", bool = false) {
++operations;
bool writer = mode[0] == 'w';
if (fail_open) {
if (writer && partial_open) files[path].clear();
return File();
}
if (!writer && !exists(path)) return File();
if (writer) files[path].clear();
return File(this, path, writer);
}
bool rename(const char* from, const char* to) {
++operations; ++rename_calls;
if (rename_calls == fail_rename || !exists(from) || exists(to)) return false;
files[to] = files.at(from); files.erase(from); return true;
}
bool remove(const char* path) {
++operations; ++remove_calls;
assert(exists(path) && "No VFS log for normal absence");
if (remove_calls == fail_remove) return false;
files.erase(path); return true;
}
void backend(const std::string& path, const std::vector<uint8_t>& bytes) {
if (bytes.empty()) return;
++backend_writes;
max_backend_write = std::max(max_backend_write, bytes.size());
files[path].insert(files[path].end(), bytes.begin(), bytes.end());
}
};
inline Stream::Stream(FakeFilesystem* f, const char* p, bool w)
: fs(f), path(p), writer(w) { ++fs->handles; }
inline void Stream::close() {
if (closed) return;
++fs->operations;
if (writer && !fs->fail_close) fs->backend(path, buffer);
buffer.clear(); closed = true; --fs->handles;
}
inline File::File(FakeFilesystem* fs, const char* path, bool writer)
: stream(std::make_shared<Stream>(fs, path, writer)) {}
inline bool File::setBufferSize(size_t length) {
assert(stream && stream->position == 0);
++stream->fs->operations; ++stream->fs->config_calls;
if (stream->fs->config_calls == stream->fs->fail_config) return false;
stream->buffer_size = length; return true;
}
inline size_t File::write(const uint8_t* data, size_t length) {
assert(stream && stream->writer); ++stream->fs->operations;
if (stream->fs->short_write && length) --length;
size_t consumed = 0;
while (consumed < length) {
if (stream->buffer.size() == stream->buffer_size) {
stream->fs->backend(stream->path, stream->buffer); stream->buffer.clear();
}
size_t n = std::min(length-consumed, stream->buffer_size-stream->buffer.size());
stream->buffer.insert(stream->buffer.end(), data+consumed, data+consumed+n);
consumed += n;
if (stream->buffer.size() == stream->buffer_size) {
stream->fs->backend(stream->path, stream->buffer); stream->buffer.clear();
}
}
stream->position += length; return length;
}
inline size_t File::read(uint8_t* data, size_t length) {
assert(stream && !stream->writer); ++stream->fs->operations;
if (stream->fs->fail_read) return 0;
const auto& bytes = stream->fs->files.at(stream->path);
size_t n = std::min(length, bytes.size()-std::min(stream->position, bytes.size()));
if (n) memcpy(data, bytes.data()+stream->position, n);
stream->position += n; return n;
}
inline size_t File::size() {
assert(stream); ++stream->fs->operations; return stream->fs->files.at(stream->path).size();
}
inline int fixtureStat(FakeFilesystem* fs, const char* path, struct stat*) {
++fs->operations;
++fs->metadata_calls;
if (fs->fail_metadata || fs->metadata_calls == fs->fail_metadata_at) { errno = EIO; return -1; }
assert(strncmp(path, "/spiffs", 7) == 0);
if (!fs->exists(path+7)) { errno = ENOENT; return -1; }
return 0;
}
namespace fs { using SPIFFSFS = FakeFilesystem; }
using esp_err_t = int;
constexpr int ESP_OK = 0;
inline int nvs_flash_erase() { return ESP_OK; }
+314
View File
@@ -0,0 +1,314 @@
#include "FakeFilesystem.h"
#include <array>
#include <helpers/LazyPersistence.h>
#define FILESYSTEM FakeFilesystem
namespace mesh { struct Utils {
static void toHex(char* dest, const uint8_t* src, int length) {
for (int i=0; i<length; ++i) sprintf(dest+2*i, "%02X", src[i]);
dest[2*length]=0;
}
}; }
struct ContactInfo {};
class MyMesh;
using DataStoreHost = MyMesh;
class DataStore {
public:
FakeFilesystem* _fs;
bool _identity_creation_blocked=false, _prefs_load_incomplete=false;
bool _channel_load_incomplete=false, _contact_write_requested=false;
bool _uncached_contact_load_incomplete=false, _cache_load_incomplete=false;
const char* _prefs_recovery_source=nullptr;
const char* _channel_recovery_source=nullptr;
File _contact_path_reader;
bool contact_pending=false;
unsigned contact_passes=0;
#include "advert_state.h"
explicit DataStore(FakeFilesystem& fs):_fs(&fs) {}
File openRead(FakeFilesystem* fs,const char* path){return fs->open(path);}
void cancelContactWrite(){}
void begin();
bool formatFileSystem();
bool queueAdvertByKey(const uint8_t[],int,const uint8_t[],uint8_t);
bool serviceAdvertWrites(uint32_t);
bool flushAdvertWrites(uint32_t now=0);
bool hasPendingAdvertWrites() const;
bool isAdvertWriteDue(uint32_t) const;
bool consumeSynchronousAdvertIO();
uint8_t getBlobByKey(const uint8_t[],int,uint8_t[]);
bool putBlobByKey(const uint8_t[],int,const uint8_t[],uint8_t);
bool deleteBlobByKey(const uint8_t[],int);
bool hasPendingContactWrites() const{return contact_pending;}
bool serviceContactWrites(DataStoreHost*,bool(*)(const ContactInfo&)) {
++_fs->operations; ++contact_passes; return true;
}
bool flushContactWrites(DataStoreHost*,bool(*)(const ContactInfo&)) {
++_fs->operations; ++contact_passes; contact_pending=false; return true;
}
};
#include "presence.h"
#include "store.h"
constexpr uint32_t CONTACT_PAGE_WRITE_GAP=1;
struct Clock { uint32_t now=10000; uint32_t getMillis() const{return now;} };
struct RadioDriver {
bool isWatchdogObserving() const{return false;}
bool isCalibratingNoiseFloor() const{return false;}
} radio_driver;
struct SerialInterface {
bool hasPendingIO() const{return false;}
bool isReplyRouteAvailable(const SerialInterface*) const{return false;}
};
struct DelayedReplies { bool hasBinaryTrace() const{return false;} };
class MyMesh {
public:
DataStore* _store;
Clock* _ms;
bool _advert_write_next=true;
unsigned long dirty_contacts_expiry=0;
uint8_t dirty_contacts_failures=0;
bool(*save_filter)(const ContactInfo&)=nullptr;
bool _radio_available=false, _iter_started=false;
bool command_radio_apply_pending=false, saved_radio_apply_pending=false;
uint32_t _scheduled_reboot_at=0, radio_apply_retry_at=0;
uint32_t emergency_client_repeat_send_at=0;
void* sign_data=nullptr;
void* emergency_client_repeat_packet=nullptr;
SerialInterface* _serial=nullptr;
SerialInterface* _iter_reply_route=nullptr;
DelayedReplies _delayed_replies;
bool putBlobByKey(const uint8_t[],int,const uint8_t[],int);
void servicePersistence();
bool flushContactsBeforeReboot();
bool hasPendingWork() const;
bool canRecoverUsbLogging() const;
bool isDualRadioActive() const{return false;}
bool hasOutbound() const{return false;}
bool isAnyTempRadioActive() const{return false;}
bool hasPendingOtaApply() const{return false;}
bool hasPendingReqs() const{return false;}
bool hasQueuedWorkDue() const{return false;}
bool hasRetryWorkDue() const{return false;}
bool millisHasNowPassed(uint32_t when) const{return _ms->now>=when;}
bool isContactWriteDue() const{return _store->contact_pending;}
void scheduleContactWriteRetry(){++dirty_contacts_failures;}
unsigned long futureMillis(uint32_t delay) const{return _ms->now+delay;}
};
#include "mesh.h"
using Key=std::array<uint8_t,32>;
using Blob=std::vector<uint8_t>;
using Stage=DataStore::AdvertWriteState::Stage;
Key key(unsigned n){Key k{};k[0]=n;return k;}
Blob blob(unsigned n, unsigned len=255){return Blob(len,n);}
std::string path(const Key& key){char path[64];makeBlobPath(key.data(),32,path,sizeof(path));return path;}
Blob get(DataStore& store,const Key& k){uint8_t bytes[255];auto n=store.getBlobByKey(k.data(),32,bytes);return Blob(bytes,bytes+n);}
void enqueue(DataStore& store,const Key& k,const Blob& b){assert(store.queueAdvertByKey(k.data(),32,b.data(),b.size()));}
void step(DataStore& store,FakeFilesystem& fs,uint32_t now=10000) {
auto before=fs.operations, backend=fs.backend_writes;
fs.max_backend_write=0;
store.serviceAdvertWrites(now);
assert(fs.operations-before<=1 && "one storage operation per cooperative pass");
assert(fs.backend_writes-backend<=1);
assert(fs.max_backend_write<=64);
}
void drain(DataStore& store,FakeFilesystem& fs) {
unsigned n=0;
while(store.hasPendingAdvertWrites()){assert(++n<1000);step(store,fs,10000+n*1000);}
assert(fs.handles==0);
}
void until(DataStore& store,FakeFilesystem& fs,Stage target) {
unsigned n=0;
do {assert(++n<1000);step(store,fs,10000+n*1000);}
while(store._advert_write.stage!=target);
}
void rxRouteAndScheduling() {
FakeFilesystem fs;DataStore store(fs);Clock clock;MyMesh mesh{&store,&clock};
auto k=key(1);auto b=blob(1);
const auto before=fs.operations;
assert(mesh.putBlobByKey(k.data(),32,b.data(),b.size()));
assert(fs.operations==before && "advert_rx_no_fs");
assert(get(store,k)==b && fs.operations==before);
store.contact_pending=true;
unsigned blob_passes=0, serial_services=0;
while(store.hasPendingAdvertWrites()) {
++serial_services;
auto old=fs.operations, contacts=store.contact_passes;
mesh.servicePersistence();
assert(fs.operations-old<=1);
if(store.contact_passes==contacts)++blob_passes;
++clock.now;
}
assert(serial_services>20 && blob_passes>10 && store.contact_passes>10);
assert(fs.files.at(path(k))==b && !fs.exists("/advert.tmp"));
// Explicit durability work must not be followed by another automatic job.
enqueue(store,k,blob(2));assert(store.putBlobByKey(k.data(),32,b.data(),b.size()));
auto old=fs.operations;mesh.servicePersistence();assert(old==fs.operations);
// Disk share/export work consumes the same pass; a RAM share does not.
store.consumeSynchronousAdvertIO();
assert(get(store,k)==b);enqueue(store,key(2),blob(2));
old=fs.operations;mesh.servicePersistence();assert(old==fs.operations);
assert(get(store,key(2))==blob(2));
old=fs.operations;mesh.servicePersistence();assert(fs.operations==old+1);
store.contact_pending=false;drain(store,fs);
}
void coalesceOverflowAndDeleteEveryStage() {
for(unsigned stage=0;stage<24;++stage) {
FakeFilesystem fs;DataStore store(fs);auto k=key(1), other=key(2);auto old=blob(1), fresh=blob(2);
fs.files[path(k)]=old;enqueue(store,k,fresh);
for(unsigned i=0;i<stage && store.hasPendingAdvertWrites();++i)step(store,fs);
enqueue(store,k,blob(3));assert(get(store,k)==blob(3));
drain(store,fs);assert(fs.files.at(path(k))==blob(3));
enqueue(store,k,old);
for(unsigned i=0;i<stage && store.hasPendingAdvertWrites();++i)step(store,fs);
assert(store.deleteBlobByKey(k.data(),32));
// Reusing the active slot must not turn its old temp into another key.
enqueue(store,other,fresh);drain(store,fs);
assert(get(store,k).empty() && fs.files.at(path(other))==fresh);
}
FakeFilesystem fs;DataStore store(fs);
for(unsigned i=1;i<=4;++i)enqueue(store,key(i),blob(i));
auto before=fs.operations;
auto fifth=key(5);auto b=blob(5);
assert(!store.queueAdvertByKey(fifth.data(),32,b.data(),b.size()));
assert(fs.operations==before);enqueue(store,key(2),blob(22));
Key same_prefix=key(2);same_prefix[31]=99;
assert(get(store,same_prefix)==blob(22));
drain(store,fs);assert(fs.files.at(path(key(2)))==blob(22));
}
void crashBoundaries() {
for(unsigned stop=0;stop<26;++stop) {
FakeFilesystem fs;auto k=key(1);auto old=blob(1), fresh=blob(2);
fs.files[path(k)]=old;
{
DataStore store(fs);enqueue(store,k,fresh);
for(unsigned i=0;i<stop && store.hasPendingAdvertWrites();++i)step(store,fs);
}
assert(fs.handles==0);
DataStore boot(fs);auto value=get(boot,k);
assert(value==old || value==fresh);
assert(!boot.hasPendingAdvertWrites());
enqueue(boot,k,blob(3));drain(boot,fs);
assert(get(boot,k)==blob(3));
assert(!fs.exists((path(k)+".bak").c_str()));
}
}
void synchronousMutationAndRollback() {
for(unsigned stage=0;stage<24;++stage) {
FakeFilesystem fs;DataStore store(fs);auto k=key(1);auto old=blob(1), queued=blob(2), explicit_blob=blob(3);
fs.files[path(k)]=old;enqueue(store,k,queued);
for(unsigned i=0;i<stage && store.hasPendingAdvertWrites();++i)step(store,fs);
assert(store.putBlobByKey(k.data(),32,explicit_blob.data(),explicit_blob.size()));
drain(store,fs);assert(get(store,k)==explicit_blob);
enqueue(store,k,queued);until(store,fs,Stage::Publish);
fs.short_write=true;
assert(!store.putBlobByKey(k.data(),32,explicit_blob.data(),explicit_blob.size()));
assert(get(store,k)==queued);fs.short_write=false;drain(store,fs);assert(get(store,k)==queued);
enqueue(store,k,old);until(store,fs,Stage::Publish);
fs.fail_remove=fs.remove_calls+1;
assert(!store.deleteBlobByKey(k.data(),32));
assert(get(store,k)==old);fs.fail_remove=0;drain(store,fs);assert(get(store,k)==old);
}
FakeFilesystem fs;DataStore store(fs);auto k=key(1);auto old=blob(1), fresh=blob(2);
fs.files[path(k)]=old;fs.files[path(k)+".bak"]=blob(8);enqueue(store,k,fresh);
fs.fail_remove=fs.remove_calls+1;
assert(!store.deleteBlobByKey(k.data(),32));assert(fs.files.at(path(k))==old);
fs.fail_remove=0;fs.fail_metadata=true;
auto removes=fs.remove_calls;assert(!store.deleteBlobByKey(k.data(),32));assert(fs.remove_calls==removes);
fs.fail_metadata=false;drain(store,fs);assert(get(store,k)==fresh);
fs.files[path(k)]=old;fs.files[path(k)+".bak"]=blob(8);enqueue(store,k,fresh);
fs.fail_metadata_at=fs.metadata_calls+2;
removes=fs.remove_calls;assert(!store.deleteBlobByKey(k.data(),32));
assert(fs.remove_calls==removes && fs.files.at(path(k))==old);
fs.fail_metadata_at=0;fs.fail_remove=fs.remove_calls+2;
assert(!store.deleteBlobByKey(k.data(),32));
assert(fs.files.at(path(k))==old && !fs.exists((path(k)+".bak").c_str()));
fs.fail_remove=0;drain(store,fs);assert(get(store,k)==fresh);
}
void failuresAndLifecycle() {
for(unsigned fault=0;fault<9;++fault) {
FakeFilesystem fs;DataStore store(fs);auto k=key(1);auto old=blob(1), fresh=blob(2);
fs.files[path(k)]=old;enqueue(store,k,fresh);
switch(fault) {
case 0:fs.fail_metadata=true;break;
case 1:fs.fail_open=true;fs.partial_open=true;break;
case 2:fs.fail_config=1;break;
case 3:fs.short_write=true;break;
case 4:fs.fail_close=true;break;
case 5:fs.fail_read=true;break;
case 6:fs.fail_rename=1;break;
case 7:fs.fail_rename=2;break;
case 8:fs.fail_config=2;break;
}
assert(!store.flushAdvertWrites(10000));
assert(fs.handles==0 && get(store,k)==fresh);
assert(!store.isAdvertWriteDue(10999));
fs.fail_metadata=fs.fail_open=fs.partial_open=fs.short_write=fs.fail_close=fs.fail_read=false;
fs.fail_config=fs.fail_rename=0;drain(store,fs);
assert(get(store,k)==fresh && !fs.exists("/advert.tmp"));
}
{
FakeFilesystem fs;DataStore store(fs);auto k=key(1);auto old=blob(1), fresh=blob(2);
fs.files[path(k)]=old;enqueue(store,k,fresh);until(store,fs,Stage::OpenVerify);
fs.files.at("/advert.tmp")[73]^=1;
assert(!store.flushAdvertWrites(10000));
assert(fs.files.at(path(k))==old && get(store,k)==fresh && fs.handles==0);
drain(store,fs);assert(get(store,k)==fresh);
}
FakeFilesystem fs;DataStore store(fs);auto k=key(1);auto b=blob(1);
enqueue(store,k,b);until(store,fs,Stage::Write);assert(fs.handles==1);
store.begin();assert(fs.handles==0 && !store.hasPendingAdvertWrites());
enqueue(store,k,b);until(store,fs,Stage::Write);fs.fail_format=true;
assert(!store.formatFileSystem());assert(fs.handles==0 && get(store,k)==b);
fs.fail_format=false;drain(store,fs);enqueue(store,k,blob(2));
assert(store.formatFileSystem());assert(!store.hasPendingAdvertWrites() && get(store,k).empty());
// Reboot and identity-import paths use the real MyMesh durability gate.
Clock clock;MyMesh mesh{&store,&clock};enqueue(store,k,b);
assert(mesh.flushContactsBeforeReboot());assert(get(store,k)==b && fs.handles==0);
enqueue(store,k,blob(2));fs.fail_open=true;
assert(!mesh.flushContactsBeforeReboot());assert(fs.handles==0);
auto before=fs.operations;assert(!mesh.flushContactsBeforeReboot());assert(fs.operations==before);
// Persistent errors use the shared bounded exponential retry policy;
// received coalescing adverts cannot postpone or cancel the backoff.
FakeFilesystem broken_fs;DataStore broken(broken_fs);enqueue(broken,k,b);
broken_fs.fail_metadata=true;uint32_t now=10000;
for(unsigned failure=0;failure<12;++failure) {
while(broken.serviceAdvertWrites(now)) ++now;
uint32_t delay=broken._advert_write.retry_at-now;
assert(delay==std::min(1000u<<failure,300000u));
enqueue(broken,k,blob(2));
auto ops=broken_fs.operations;broken.serviceAdvertWrites(now+delay-1);
assert(ops==broken_fs.operations && get(broken,k)==blob(2));
now+=delay;
}
}
void pendingWorkAndRecovery() {
FakeFilesystem fs;DataStore store(fs);Clock clock;MyMesh mesh{&store,&clock};
const auto k=key(1);const auto b=blob(1);
assert(!mesh.hasPendingWork() && mesh.canRecoverUsbLogging());
enqueue(store,k,b);
assert(mesh.hasPendingWork() && !mesh.canRecoverUsbLogging());
fs.fail_open=true;
assert(!store.flushAdvertWrites(clock.now));
assert(!store.isAdvertWriteDue(clock.now));
// An accepted RAM packet awaiting retry must not be abandoned by sleep or
// discarded when the USB logger's recovery path resets the transport.
assert(mesh.hasPendingWork() && !mesh.canRecoverUsbLogging());
fs.fail_open=false;clock.now+=1000;until(store,fs,Stage::Write);
assert(store.deleteBlobByKey(k.data(),32));
assert(get(store,k).empty());
// Successful deletion retires RAM immediately, while the old file handle
// and reserved temp still need cooperative cancellation and cleanup.
assert(mesh.hasPendingWork() && !mesh.canRecoverUsbLogging());
drain(store,fs);
assert(!mesh.hasPendingWork() && mesh.canRecoverUsbLogging());
}
int main() {
rxRouteAndScheduling();coalesceOverflowAndDeleteEveryStage();crashBoundaries();
synchronousMutationAndRollback();failuresAndLifecycle();pendingWorkAndRecovery();
puts("PASS: actual ESP queue/route/scheduler, latest RAM, crash/rollback, faults and lifecycle");
}
+3
View File
@@ -47,6 +47,9 @@ class DataStore : public mesh::ContactPathBackend, public mesh::ContactSecretBac
DataStoreHost* _cache_host;
bool _cache_load_incomplete = false;
#if defined(ESP32_PLATFORM)
// Advert queue lifecycle has a dedicated production fixture. The contact
// reinitialization test needs only this unrelated hardware lifecycle seam.
struct { void clear() {} } _advert_write;
File _contact_path_reader;
#include "contact_write_state_under_test.h"
FakeFilesystem* _fs = &SPIFFS;
@@ -52,7 +52,12 @@ class CompanionContactSleepContractTest(unittest.TestCase):
def test_contact_write_is_serviced_only_after_its_deadline(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
body = function_body(mesh, "void MyMesh::loop()")
loop = function_body(mesh, "void MyMesh::loop()")
self.assertIn("servicePersistence();", loop)
self.assertLess(loop.index("checkSerialInterface();"), loop.index("servicePersistence();"))
self.assertIn("#if defined(ESP32_PLATFORM)\n servicePersistence();\n#else", loop)
self.assertIn("_store->serviceContactWrites(this, save_filter)", loop)
body = function_body(mesh, "void MyMesh::servicePersistence()")
self.assertIn("if (isContactWriteDue())", body)
self.assertIn("_store->serviceContactWrites(this, save_filter)", body)
@@ -82,7 +87,7 @@ class CompanionContactSleepContractTest(unittest.TestCase):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
begin = function_body(mesh, "void MyMesh::begin(")
save = function_body(mesh, "void MyMesh::saveContacts()")
loop = function_body(mesh, "void MyMesh::loop()")
loop = function_body(mesh, "void MyMesh::servicePersistence()")
for body in (begin, save, loop):
self.assertIn("mesh::nonzeroLazyPersistenceDeadline(", body)
@@ -93,7 +98,7 @@ class CompanionContactSleepContractTest(unittest.TestCase):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
save = function_body(mesh, "void MyMesh::saveContacts()")
loop = function_body(mesh, "void MyMesh::loop()")
loop = function_body(mesh, "void MyMesh::servicePersistence()")
retry = function_body(mesh, "void MyMesh::scheduleContactWriteRetry()")
self.assertIn("uint8_t dirty_contacts_failures;", header)
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Exercise production ESP advert jobs and their MyMesh route under sanitizers."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_t096_full_memory import method
ROOT = Path(__file__).resolve().parents[1]
class AdvertCacheTest(unittest.TestCase):
def test_inline_contacts(self):
self.run_fixture(0)
def test_cached_contacts(self):
self.run_fixture(1)
def test_synchronous_received_advert_is_detected(self):
self.run_fixture(0, synchronous=True)
def run_fixture(self, cache, synchronous=False):
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
header = (ROOT / 'examples/companion_radio/DataStore.h').read_text()
start = header.index(' struct AdvertWriteState {')
end = header.index('\n#endif', start)
state = header[start:end]
signatures = ('static File openWrite(', 'inline void makeBlobPath(', 'bool DataStore::queueAdvertByKey(',
'void DataStore::retireAdvertWrite(', 'void DataStore::invalidateAdvertWrite(',
'bool DataStore::consumeSynchronousAdvertIO(',
'bool DataStore::hasPendingAdvertWrites() const',
'bool DataStore::isAdvertWriteDue(', 'bool DataStore::serviceAdvertWrites(',
'bool DataStore::flushAdvertWrites(', 'void DataStore::begin()',
'bool DataStore::formatFileSystem()')
# ESP/non-LittleFS blob methods follow the final makeBlobPath helper.
blob_source = source[source.rindex('inline void makeBlobPath('):]
implementation = '\n'.join(method(source, s) for s in signatures)
implementation += '\n' + '\n'.join(method(blob_source, s) for s in (
'uint8_t DataStore::getBlobByKey(', 'bool DataStore::putBlobByKey(',
'bool DataStore::deleteBlobByKey('))
presence = method(source, 'static bool companionPathPresence(').replace(
'::stat(vfs_path, &info)', 'fixtureStat(fs, vfs_path, &info)')
mesh_header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text()
route = method(mesh_header, ' bool putBlobByKey(').replace(' override', '')
route = route.replace('bool putBlobByKey(', 'bool MyMesh::putBlobByKey(', 1)
if synchronous:
route = route.replace('_store->queueAdvertByKey(', '_store->putBlobByKey(')
mesh_source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
scheduling = method(mesh_source, 'void MyMesh::servicePersistence()')
flush = method(mesh_source, 'bool MyMesh::flushContactsBeforeReboot()')
work = method(mesh_source, 'bool MyMesh::hasPendingWork() const')
recovery = method(mesh_source, 'bool MyMesh::canRecoverUsbLogging() const')
# The entire production scheduling body runs below. This order check
# additionally pins its call site after either serial dispatch route.
loop = method(mesh_source, 'void MyMesh::loop()')
self.assertLess(loop.index('checkSerialInterface();'), loop.index('servicePersistence();'))
self.assertLess(loop.index('checkCLIRescueCmd();'), loop.index('servicePersistence();'))
with tempfile.TemporaryDirectory(prefix='mesh-advert-jobs-') as directory:
temp = Path(directory)
(temp / 'advert_state.h').write_text(state)
(temp / 'presence.h').write_text(presence)
(temp / 'store.h').write_text(implementation)
(temp / 'mesh.h').write_text('\n'.join((route, scheduling, flush, work, recovery)))
binary = temp / 'test'
result = subprocess.run([
'c++', '-std=c++17', '-O1', '-g', '-Wall', '-Wextra',
'-fsanitize=address,undefined', '-fno-omit-frame-pointer',
'-DESP32_PLATFORM=1', '-DESP32=1', f'-DMESH_CONTACT_CACHE={cache}',
'-I', str(ROOT / 'test/fixtures/advert_cache'),
'-I', str(ROOT / 'src'), '-I', str(temp),
str(ROOT / 'test/fixtures/advert_cache/test.cpp'), '-o', str(binary),
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True)
if synchronous:
self.assertNotEqual(result.returncode, 0)
self.assertIn('advert_rx_no_fs', result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout+result.stderr)
if __name__ == '__main__':
unittest.main()