From acfcfb6008713fdf9c98a5191f00fc43375deb0c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 4 Oct 2026 04:01:40 -0700 Subject: [PATCH] Keep ESP contact saves responsive on PSRAM boards Extend the cooperative contact transaction to uncached ESP32 storage, including the PSRAM policy used by the XIAO and V4. Preserve owned contact copies, the 152-byte disk format, revision cancellation and synchronous durability operations. Keep contact capacities unchanged. Avoid SDK error logs when optional blob or transaction cleanup files are absent, while preserving metadata and deletion failure handling. Validate cached ESP/nRF and uncached PSRAM paths under ASan/UBSan, including bounded writes, mutation cancellation, faults, recovery and reboot readback. Update the existing uncached production fixture. --- examples/companion_radio/DataStore.cpp | 88 ++++- examples/companion_radio/DataStore.h | 26 +- examples/companion_radio/MyMesh.cpp | 4 +- src/helpers/ContactFileTransaction.h | 15 +- .../companion_uncached_storage/test.cpp | 11 + test/fixtures/contact_cache/FakeFilesystem.h | 144 +++++++ test/fixtures/contact_cache/mocks/Utils.h | 2 +- .../contact_cache/test_contact_cache.cpp | 135 +------ .../test_esp32_uncached.cpp | 353 ++++++++++++++++++ test/test_companion_uncached_storage.py | 23 +- test/test_contact_cache.py | 4 +- test/test_esp32_cooperative_contacts.py | 83 ++++ 12 files changed, 713 insertions(+), 175 deletions(-) create mode 100644 test/fixtures/contact_cache/FakeFilesystem.h create mode 100644 test/fixtures/contact_persistence/test_esp32_uncached.cpp create mode 100644 test/test_esp32_cooperative_contacts.py diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 85af1d074..62025a243 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -55,7 +55,7 @@ DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra } DataStore::~DataStore() { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); #endif } @@ -144,12 +144,14 @@ static bool contactPathPresence(FILESYSTEM* fs, const char* path, #endif void DataStore::begin() { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) // Reinitialization must not retain a streaming transaction or an inode // opened through an earlier filesystem route. Dirty mutations stay pending. cancelContactWrite(); +#if MESH_CONTACT_CACHE _contact_path_reader.close(); #endif +#endif #if defined(RP2040_PLATFORM) identity_store.begin(); #endif @@ -561,9 +563,11 @@ void DataStore::useVolatilePrimaryFS(FILESYSTEM& fs) { #endif void DataStore::disableSecondaryFS(bool authority_unknown) { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); +#if MESH_CONTACT_CACHE _contact_path_reader.close(); +#endif #endif _fsExtra = nullptr; #if defined(NRF52_PLATFORM) @@ -724,10 +728,12 @@ bool DataStore::removeFile(FILESYSTEM* fs, const char* filename) { } bool DataStore::formatFileSystem() { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); +#if MESH_CONTACT_CACHE _contact_path_reader.close(); #endif +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #if defined(NRF52_PLATFORM) resetContactPageState(); @@ -795,9 +801,9 @@ bool DataStore::formatFileSystem() { _channel_load_incomplete = false; _prefs_recovery_source = nullptr; _channel_recovery_source = nullptr; + _contact_write_requested = false; #if MESH_CONTACT_CACHE _cache_load_incomplete = false; - _contact_write_requested = false; #else _uncached_contact_load_incomplete = false; #endif @@ -1919,7 +1925,7 @@ bool DataStore::writeContactPage(DataStoreHost* host, uint8_t page, #endif void DataStore::loadContacts(DataStoreHost* host) { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); #endif #if !defined(NRF52_PLATFORM) && !MESH_CONTACT_CACHE @@ -2229,7 +2235,7 @@ void DataStore::loadContacts(DataStoreHost* host) { } bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); #endif if (hasIncompleteContactLoad()) return false; @@ -2242,10 +2248,11 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn success = markContactDirty(*contact) && success; } return flushContactWrites(host, filter) && success; -#elif MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#elif defined(ESP32_PLATFORM) // Explicit save/flush callers require a fully durable current table. An // unfinished lazy transaction may contain an earlier revision: discard it // before synchronously writing the current contacts through the same format. +#if MESH_CONTACT_CACHE auto& paths = mesh::contactPathStorage(); paths.beginCommit(); for (uint32_t i = 0;; ++i) { @@ -2258,6 +2265,7 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn _contact_write_requested = true; return false; } +#endif // The cold-path reader reuses one SPIFFS File during this streaming write. // Close it before replacing the original name so later reads reopen the // committed file. No complete contact-table copy is needed in RAM. @@ -2266,14 +2274,23 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn bool success = writer; uint8_t record[mesh::storage::CONTACT_RECORD_SIZE]; for (uint32_t i = 0; success; ++i) { +#if MESH_CONTACT_CACHE auto* c = host->getContactForStore(i); if (!c) break; +#else + ContactInfo snapshot; + if (!host->getContactForSave(i, snapshot)) break; + const auto* c = &snapshot; +#endif if (filter && !filter(*c)) continue; success = serializeContactRecord(*c, record) && writer.write(record, sizeof(record)) == sizeof(record); } +#if MESH_CONTACT_CACHE _contact_path_reader.close(); +#endif success = writer.commit(success); +#if MESH_CONTACT_CACHE if (success) { uint16_t index = 0; for (uint32_t i = 0;; ++i) { @@ -2284,6 +2301,7 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn } } paths.endCommit(success); +#endif _contact_write_requested = !success; return success; #else @@ -2340,7 +2358,7 @@ bool DataStore::markContactDirty(const ContactInfo& contact) { return _dirty_contact_pages.mark(slot / mesh::storage::CONTACTS_PER_PAGE); #else (void)contact; -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) ++_contact_write_revision; _contact_write_requested = true; #endif @@ -2357,7 +2375,7 @@ bool DataStore::releaseContact(const ContactInfo& contact) { return _dirty_contact_pages.mark(slot / mesh::storage::CONTACTS_PER_PAGE); #else (void)contact; -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) ++_contact_write_revision; _contact_write_requested = true; #endif @@ -2428,17 +2446,20 @@ bool DataStore::truncateLegacyContacts(uint16_t remaining_contacts) { } #endif -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) void DataStore::cancelContactWrite() { if (_contact_write == nullptr) return; delete _contact_write; _contact_write = nullptr; +#if MESH_CONTACT_CACHE _contact_path_reader.close(); mesh::contactPathStorage().endCommit(false); +#endif _contact_write_host = nullptr; _contact_write_filter = nullptr; } +#if MESH_CONTACT_CACHE bool DataStore::cancelCooperativeWrite() { // Cache pressure inside serialization/snapshot preservation must keep the // reentrancy veto. Only an app/radio path update between service passes can @@ -2447,9 +2468,10 @@ bool DataStore::cancelCooperativeWrite() { cancelContactWrite(); return true; } +#endif -bool DataStore::serviceCachedContactWrite(DataStoreHost* host, - bool (*filter)(const ContactInfo&)) { +bool DataStore::serviceContactWrite(DataStoreHost* host, + bool (*filter)(const ContactInfo&)) { struct ServiceGuard { bool& active; explicit ServiceGuard(bool& value) : active(value) { active = true; } @@ -2469,8 +2491,11 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, cancelContactWrite(); return true; } +#if MESH_CONTACT_CACHE auto& paths = mesh::contactPathStorage(); +#endif if (_contact_write == nullptr) { +#if MESH_CONTACT_CACHE paths.beginCommit(); for (uint32_t i = 0;; ++i) { auto* contact = host->getContactForStore(i); @@ -2481,12 +2506,15 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, paths.endCommit(false); return false; } +#endif _contact_write = new (std::nothrow) mesh::ContactFileTransaction( _getContactsChannelsFS(), "/contacts3", companionPathPresence); if (_contact_write == nullptr || !static_cast(*_contact_write)) { delete _contact_write; _contact_write = nullptr; +#if MESH_CONTACT_CACHE paths.endCommit(false); +#endif return false; } _contact_write_host = host; @@ -2497,10 +2525,19 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, return true; } if (!_contact_write_verifying) { +#if MESH_CONTACT_CACHE ContactInfo* contact; do { contact = host->getContactForStore(_contact_write_index++); } while (contact != nullptr && filter && !filter(*contact)); +#else + ContactInfo snapshot; + bool available; + do { + available = host->getContactForSave(_contact_write_index++, snapshot); + } while (available && filter && !filter(snapshot)); + const ContactInfo* contact = available ? &snapshot : nullptr; +#endif if (contact != nullptr) { uint8_t record[mesh::storage::CONTACT_RECORD_SIZE]; if (serializeContactRecord(*contact, record) @@ -2510,10 +2547,13 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, cancelContactWrite(); return false; } +#if MESH_CONTACT_CACHE _contact_path_reader.close(); +#endif _contact_write_verifying = true; return true; } +#if MESH_CONTACT_CACHE if (_contact_write->readyToPublish() && !paths.preserveSnapshots(0, 0x8000)) { cancelContactWrite(); @@ -2524,6 +2564,7 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, // inode during verification. Close it at the actual publication boundary. _contact_path_reader.close(); } +#endif const auto progress = _contact_write->serviceCommit(); if (progress == mesh::ContactFileTransaction::CommitProgress::Pending) { return true; @@ -2532,6 +2573,7 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, cancelContactWrite(); return false; } +#if MESH_CONTACT_CACHE // serviceCommit's final name replacement and this handle publication are // one non-yielding section. Every later cold read sees the corresponding // committed file and source indices, including retained ContactInfo copies. @@ -2542,6 +2584,7 @@ bool DataStore::serviceCachedContactWrite(DataStoreHost* host, if (!filter || filter(*contact)) paths.publish(contact->path_ref.handle(), index++); } paths.endCommit(true); +#endif delete _contact_write; _contact_write = nullptr; _contact_write_host = nullptr; @@ -2604,8 +2647,8 @@ bool DataStore::serviceContactWrites(DataStoreHost* host, if (!writeContactPage(host, (uint8_t)page, filter)) return false; _dirty_contact_pages.clear((uint8_t)page); return true; -#elif MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) - return serviceCachedContactWrite(host, filter); +#elif defined(ESP32_PLATFORM) + return serviceContactWrite(host, filter); #else return saveContacts(host, filter); #endif @@ -2613,7 +2656,7 @@ bool DataStore::serviceContactWrites(DataStoreHost* host, bool DataStore::flushContactWrites(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); #endif if (hasIncompleteContactLoad()) return false; @@ -2631,7 +2674,7 @@ bool DataStore::hasPendingContactWrites() const { #if defined(NRF52_PLATFORM) return !hasIncompleteContactLoad() && (!_dirty_contact_pages.empty() || _legacy_contacts_pending_cleanup); -#elif MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#elif defined(ESP32_PLATFORM) return _contact_write_requested || _contact_write != nullptr; #else return false; @@ -2906,7 +2949,7 @@ void DataStore::checkAdvBlobFile() { } bool DataStore::migrateToSecondaryFS() { -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) cancelContactWrite(); #endif if (_fsExtra == nullptr) return false; @@ -3741,9 +3784,18 @@ bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { char path[64]; makeBlobPath(key, key_len, path, sizeof(path)); +#if defined(ESP32_PLATFORM) + // 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); +#else _fs->remove(path); return true; // return true even if file did not exist +#endif } #endif diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 52a1cd57c..422265160 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -9,7 +9,7 @@ #include #endif #include -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) namespace mesh { class ContactFileTransaction; } #endif #if COMPANION_FEATURE_READER @@ -47,6 +47,18 @@ class DataStore #if defined(ESP32_PLATFORM) const char* _prefs_recovery_source = nullptr; const char* _channel_recovery_source = nullptr; + mesh::ContactFileTransaction* _contact_write = nullptr; + DataStoreHost* _contact_write_host = nullptr; + bool (*_contact_write_filter)(const ContactInfo&) = nullptr; + uint32_t _contact_write_revision = 0; + uint32_t _contact_write_active_revision = 0; + uint32_t _contact_write_index = 0; + bool _contact_write_verifying = false; + bool _contact_write_requested = false; + bool _contact_write_servicing = false; + void cancelContactWrite(); + bool serviceContactWrite(DataStoreHost* host, + bool (*filter)(const ContactInfo&)); #endif #if !defined(NRF52_PLATFORM) bool _channel_load_incomplete = false; @@ -59,18 +71,6 @@ class DataStore bool _cache_load_incomplete = false; #if defined(ESP32_PLATFORM) File _contact_path_reader; - mesh::ContactFileTransaction* _contact_write = nullptr; - DataStoreHost* _contact_write_host = nullptr; - bool (*_contact_write_filter)(const ContactInfo&) = nullptr; - uint32_t _contact_write_revision = 0; - uint32_t _contact_write_active_revision = 0; - uint32_t _contact_write_index = 0; - bool _contact_write_verifying = false; - bool _contact_write_requested = false; - bool _contact_write_servicing = false; - void cancelContactWrite(); - bool serviceCachedContactWrite(DataStoreHost* host, - bool (*filter)(const ContactInfo&)); #endif bool readStoredPath(uint16_t source, uint8_t path[64]) override; bool flushCachedPaths() override; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 10716de54..9a39c530a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -225,8 +225,8 @@ static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1; #define DIRECT_SEND_PERHOP_FACTOR 6.0f #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) - // A cached ESP save streams one record/CRC chunk per pass. Keep the full +#if defined(ESP32_PLATFORM) + // An ESP save streams one record/CRC chunk per pass. Keep the full // mesh loop responsive between steps without adding seconds of page delay. #define CONTACT_PAGE_WRITE_GAP 1 #else diff --git a/src/helpers/ContactFileTransaction.h b/src/helpers/ContactFileTransaction.h index 0ccef0e90..6ecf48fe6 100644 --- a/src/helpers/ContactFileTransaction.h +++ b/src/helpers/ContactFileTransaction.h @@ -34,6 +34,11 @@ private: present = fs->exists(path); return true; } + static bool removeIfPresent(FILESYSTEM* fs, const char* path, + PresenceProbe presence) { + bool present = false; + return probe(fs, path, present, presence) && (!present || fs->remove(path)); + } public: enum class CommitProgress : uint8_t { Pending, Succeeded, Failed }; static bool recover(FILESYSTEM* fs, const char* target, @@ -52,7 +57,7 @@ public: snprintf(_temp, sizeof(_temp), "%s.tmp", target); snprintf(_backup, sizeof(_backup), "%s.bak", target); if (!recover(fs, target, presence)) return; - if (fs->exists(_temp) && !fs->remove(_temp)) return; + if (!removeIfPresent(fs, _temp, presence)) return; #if defined(RP2040_PLATFORM) _file = fs->open(_temp, "w"); #else @@ -63,7 +68,7 @@ public: ~ContactFileTransaction() { if (_file) _file.close(); if (_verify) _verify.close(); - if (!_finished) _fs->remove(_temp); + if (!_finished) removeIfPresent(_fs, _temp, _presence); } operator bool() const { return _ok; } bool readyToPublish() const { @@ -132,8 +137,10 @@ public: } if (ok) ok = _fs->rename(_temp, _target); if (!ok && backed_up) _fs->rename(_backup, _target); - if (ok) _fs->remove(_backup); - else _fs->remove(_temp); + // Cleanup remains best effort after the publish/rollback result, but an + // absent file must not trigger the SDK's error log on the shared USB port. + if (ok) removeIfPresent(_fs, _backup, _presence); + else removeIfPresent(_fs, _temp, _presence); _finished = true; return ok ? CommitProgress::Succeeded : CommitProgress::Failed; } diff --git a/test/fixtures/companion_uncached_storage/test.cpp b/test/fixtures/companion_uncached_storage/test.cpp index 10474f098..6ecccea81 100644 --- a/test/fixtures/companion_uncached_storage/test.cpp +++ b/test/fixtures/companion_uncached_storage/test.cpp @@ -160,6 +160,9 @@ class DataStore { bool _identity_creation_blocked = false; bool _channel_load_incomplete = false; bool _uncached_contact_load_incomplete = false; +#if defined(ESP32_PLATFORM) +#include "contact_write_state_under_test.h" +#endif struct IdentityAdapter { bool recover(const char*) { return true; } bool load(const char*, mesh::LocalIdentity&) { @@ -173,6 +176,14 @@ class DataStore { } } identity_store; public: +#if defined(ESP32_PLATFORM) + ~DataStore(); + bool markContactDirty(const ContactInfo&); + bool releaseContact(const ContactInfo&); + bool serviceContactWrites(DataStoreHost*, bool (*filter)(const ContactInfo&) = nullptr); + bool flushContactWrites(DataStoreHost*, bool (*filter)(const ContactInfo&) = nullptr); + bool hasPendingContactWrites() const; +#endif FakeFilesystem* _getContactsChannelsFS() { return _fs; } File openRead(FakeFilesystem* fs, const char* path) { return fs->open(path); } bool loadMainIdentity(mesh::LocalIdentity&); diff --git a/test/fixtures/contact_cache/FakeFilesystem.h b/test/fixtures/contact_cache/FakeFilesystem.h new file mode 100644 index 000000000..73087d6ad --- /dev/null +++ b/test/fixtures/contact_cache/FakeFilesystem.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +enum { FILE_O_READ = 0, FILE_O_WRITE = 1 }; + +class FakeFilesystem; +class File { + FakeFilesystem* _fs = nullptr; + std::string _path; + size_t _position = 0; + bool _write = false; + unsigned _read_generation = 0; + std::vector _read_inode; +public: + File() = default; + explicit File(FakeFilesystem& fs) : _fs(&fs) {} + File(FakeFilesystem* fs, const char* path, bool write); + bool open(const char* path, uint8_t mode); + explicit operator bool() const { return _fs != nullptr; } + size_t read(uint8_t* bytes, size_t length); + size_t write(const uint8_t* bytes, size_t length); + size_t size() const; + bool seek(size_t position); + void flush() {} + void close() { _fs = nullptr; } +}; + +class FakeFilesystem { + struct Config { uint32_t block_count = 256, block_size = 4096; } _config; + struct Lfs { const Config* cfg; } _lfs{&_config}; +public: + using Files = std::map>; + Files files; + size_t capacity = 1024 * 1024; + size_t max_write = std::numeric_limits::max(); + size_t largest_read = 0, largest_write = 0; + std::string fail_read; + bool metadata_error = false, fail_create = false, fail_remove = false; + unsigned removes = 0, missing_remove_logs = 0; + unsigned fail_rename = 0, renames = 0, writes = 0, reads = 0, opens = 0; + std::vector rename_snapshots; + std::map generations; + bool exists(const char* path) const { return files.count(path) != 0; } + bool mkdir(const char*) { return true; } + bool remove(const char* path) { + ++removes; + if (!exists(path)) { ++missing_remove_logs; return false; } + if (fail_remove) return false; + ++generations[path]; + return files.erase(path) != 0; + } + File open(const char* path, const char* mode = "r", bool = false) { + ++opens; + if (*mode != 'r') { + if (fail_create) return File(); + files[path].clear(); + return File(this, path, true); + } + if (fail_read == path || !exists(path)) return File(); + return File(this, path, false); + } + bool rename(const char* from, const char* to) { + ++renames; + if (renames == fail_rename || !exists(from)) return false; +#if defined(ESP32_PLATFORM) + if (exists(to)) return false; +#endif + files[to] = files.at(from); + files.erase(from); + ++generations[from]; ++generations[to]; + rename_snapshots.push_back(files); + return true; + } + size_t totalBytes() const { return capacity; } + void setBlockCount(uint32_t count) { _config.block_count = count; } + Lfs* _getFS() { _lfs.cfg = &_config; return &_lfs; } + size_t usedBytes() const { + size_t total = 0; + for (const auto& f : files) total += f.second.size(); + return total; + } +} SPIFFS; + +int _getLfsUsedBlockCount(FakeFilesystem* fs) { + unsigned used = 2; // directory metadata pair + for (const auto& file : fs->files) used += (file.second.size() + 4095) / 4096; + return used; +} + +File::File(FakeFilesystem* fs, const char* path, bool write) + : _fs(fs), _path(path), _write(write) { + if (!write) { + _read_generation = fs->generations[path]; + _read_inode = fs->files.at(path); + } +} +bool File::open(const char* path, uint8_t mode) { + if (!_fs) return false; + *this = _fs->open(path, mode == FILE_O_WRITE ? "w" : "r"); + return static_cast(*this); +} +size_t File::read(uint8_t* bytes, size_t length) { + if (!_fs || _write || _fs->fail_read == _path) return 0; + ++_fs->reads; + _fs->largest_read = std::max(_fs->largest_read, length); + // An open file keeps its old inode across a rename/unlink. A later File + // opened on the same name observes the replacement, as SPIFFS/POSIX do. + auto& data = _fs->generations[_path] == _read_generation + ? _fs->files.at(_path) : _read_inode; + const size_t count = std::min(length, data.size() - std::min(data.size(), _position)); + if (count) memcpy(bytes, data.data() + _position, count); + _position += count; + return count; +} +size_t File::write(const uint8_t* bytes, size_t length) { + if (!_fs || !_write) return 0; + ++_fs->writes; + _fs->largest_write = std::max(_fs->largest_write, length); + const size_t count = std::min(length, _fs->max_write); + auto& data = _fs->files[_path]; + data.resize(_position + count); + memcpy(data.data() + _position, bytes, count); + _position += count; + return count; +} +size_t File::size() const { + if (!_fs) return 0; + return !_write && _fs->generations[_path] != _read_generation + ? _read_inode.size() : _fs->files.at(_path).size(); +} +bool File::seek(size_t pos) { + if (!_fs || pos > size()) return false; + _position = pos; + return true; +} diff --git a/test/fixtures/contact_cache/mocks/Utils.h b/test/fixtures/contact_cache/mocks/Utils.h index 6797975ae..55e97e0aa 100644 --- a/test/fixtures/contact_cache/mocks/Utils.h +++ b/test/fixtures/contact_cache/mocks/Utils.h @@ -8,6 +8,7 @@ namespace mesh { class Utils { public: + static void toHex(char* out, const uint8_t* bytes, size_t length); static void sha256(uint8_t* out, size_t size, const uint8_t* data, int length) { uint8_t digest[32]; SHA256(data, length, digest); @@ -15,4 +16,3 @@ public: } }; } - diff --git a/test/fixtures/contact_cache/test_contact_cache.cpp b/test/fixtures/contact_cache/test_contact_cache.cpp index 521085439..ef993c936 100644 --- a/test/fixtures/contact_cache/test_contact_cache.cpp +++ b/test/fixtures/contact_cache/test_contact_cache.cpp @@ -1,136 +1,4 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -enum { FILE_O_READ = 0, FILE_O_WRITE = 1 }; - -class FakeFilesystem; -class File { - FakeFilesystem* _fs = nullptr; - std::string _path; - size_t _position = 0; - bool _write = false; - unsigned _read_generation = 0; - std::vector _read_inode; -public: - File() = default; - explicit File(FakeFilesystem& fs) : _fs(&fs) {} - File(FakeFilesystem* fs, const char* path, bool write); - bool open(const char* path, uint8_t mode); - explicit operator bool() const { return _fs != nullptr; } - size_t read(uint8_t* bytes, size_t length); - size_t write(const uint8_t* bytes, size_t length); - size_t size() const; - bool seek(size_t position); - void flush() {} - void close() { _fs = nullptr; } -}; - -class FakeFilesystem { - struct Config { uint32_t block_count = 256, block_size = 4096; } _config; - struct Lfs { const Config* cfg; } _lfs{&_config}; -public: - using Files = std::map>; - Files files; - size_t capacity = 1024 * 1024; - size_t max_write = std::numeric_limits::max(); - std::string fail_read; - unsigned fail_rename = 0, renames = 0, writes = 0, reads = 0, opens = 0; - std::vector rename_snapshots; - std::map generations; - bool exists(const char* path) const { return files.count(path) != 0; } - bool mkdir(const char*) { return true; } - bool remove(const char* path) { - ++generations[path]; - return files.erase(path) != 0; - } - File open(const char* path, const char* mode = "r", bool = false) { - ++opens; - if (*mode != 'r') { - files[path].clear(); - return File(this, path, true); - } - if (fail_read == path || !exists(path)) return File(); - return File(this, path, false); - } - bool rename(const char* from, const char* to) { - ++renames; - if (renames == fail_rename || !exists(from)) return false; -#if defined(ESP32_PLATFORM) - if (exists(to)) return false; -#endif - files[to] = files.at(from); - files.erase(from); - ++generations[from]; ++generations[to]; - rename_snapshots.push_back(files); - return true; - } - size_t totalBytes() const { return capacity; } - void setBlockCount(uint32_t count) { _config.block_count = count; } - Lfs* _getFS() { _lfs.cfg = &_config; return &_lfs; } - size_t usedBytes() const { - size_t total = 0; - for (const auto& f : files) total += f.second.size(); - return total; - } -} SPIFFS; - -int _getLfsUsedBlockCount(FakeFilesystem* fs) { - unsigned used = 2; // directory metadata pair - for (const auto& file : fs->files) used += (file.second.size() + 4095) / 4096; - return used; -} - -File::File(FakeFilesystem* fs, const char* path, bool write) - : _fs(fs), _path(path), _write(write) { - if (!write) { - _read_generation = fs->generations[path]; - _read_inode = fs->files.at(path); - } -} -bool File::open(const char* path, uint8_t mode) { - if (!_fs) return false; - *this = _fs->open(path, mode == FILE_O_WRITE ? "w" : "r"); - return static_cast(*this); -} -size_t File::read(uint8_t* bytes, size_t length) { - if (!_fs || _write || _fs->fail_read == _path) return 0; - ++_fs->reads; - // An open file keeps its old inode across a rename/unlink. A later File - // opened on the same name observes the replacement, as SPIFFS/POSIX do. - auto& data = _fs->generations[_path] == _read_generation - ? _fs->files.at(_path) : _read_inode; - const size_t count = std::min(length, data.size() - std::min(data.size(), _position)); - if (count) memcpy(bytes, data.data() + _position, count); - _position += count; - return count; -} -size_t File::write(const uint8_t* bytes, size_t length) { - if (!_fs || !_write) return 0; - ++_fs->writes; - const size_t count = std::min(length, _fs->max_write); - auto& data = _fs->files[_path]; - data.resize(_position + count); - memcpy(data.data() + _position, bytes, count); - _position += count; - return count; -} -size_t File::size() const { - if (!_fs) return 0; - return !_write && _fs->generations[_path] != _read_generation - ? _read_inode.size() : _fs->files.at(_path).size(); -} -bool File::seek(size_t pos) { - if (!_fs || pos > size()) return false; - _position = pos; - return true; -} +#include "FakeFilesystem.h" #define FILESYSTEM FakeFilesystem #include @@ -179,6 +47,7 @@ class DataStore : public mesh::ContactPathBackend, public mesh::ContactSecretBac DataStoreHost* _cache_host; bool _cache_load_incomplete = false; #if defined(ESP32_PLATFORM) + File _contact_path_reader; #include "contact_write_state_under_test.h" FakeFilesystem* _fs = &SPIFFS; FakeFilesystem* _fsExtra = nullptr; diff --git a/test/fixtures/contact_persistence/test_esp32_uncached.cpp b/test/fixtures/contact_persistence/test_esp32_uncached.cpp new file mode 100644 index 000000000..51ebe5dd7 --- /dev/null +++ b/test/fixtures/contact_persistence/test_esp32_uncached.cpp @@ -0,0 +1,353 @@ +#include "FakeFilesystem.h" +#include +#include +#define FILESYSTEM FakeFilesystem +#include +#include +#include "packet_under_test.h" + +static_assert(MESH_CONTACT_CACHE == 0, + "This fixture must exercise PSRAM/explicit inline-path storage"); + +// Replace only the SDK metadata syscall; the production presence/error logic +// and transaction implementation are compiled unchanged. +int fixtureStat(FakeFilesystem* fs, const char* vfs_path, struct stat*) { + if (fs->metadata_error) { errno = EIO; return -1; } + assert(strncmp(vfs_path, "/spiffs", 7) == 0); + if (!fs->exists(vfs_path + 7)) { errno = ENOENT; return -1; } + return 0; +} +#include "presence_under_test.h" + +struct Host { + std::vector contacts; + unsigned copies = 0; + size_t capacity = 350; + bool onContactLoaded(const ContactInfo& c) { + if (contacts.size() >= capacity) return false; + contacts.push_back(c); + return true; + } + bool getContactForSave(uint32_t index, ContactInfo& contact) { + ++copies; + if (index >= contacts.size()) return false; + contact = contacts[index]; + return true; + } + ContactInfo* getContactForStore(uint32_t) { + assert(false && "Uncached ESP must keep getContactForSave copy semantics"); + return nullptr; + } +}; +using DataStoreHost = Host; + +// Hardware-only constructor/FS seams are supplied here. The state fields and +// all save/load/dirty/transaction bodies below come from production sources. +class DataStore { + FakeFilesystem* _fs; + bool _channel_load_incomplete = false; + bool _uncached_contact_load_incomplete = false; +#include "contact_write_state_under_test.h" +public: + explicit DataStore(FakeFilesystem& fs) : _fs(&fs) {} + ~DataStore(); + FakeFilesystem* _getContactsChannelsFS() const { return _fs; } + File openRead(FakeFilesystem* fs, const char* path) { return fs->open(path); } + bool hasIncompleteContactLoad() const; + void loadContacts(DataStoreHost*); + bool saveContacts(DataStoreHost*, bool (*)(const ContactInfo&)); + bool flushContactWrites(DataStoreHost*, bool (*)(const ContactInfo&)); + bool markContactDirty(const ContactInfo&); + bool releaseContact(const ContactInfo&); + bool serviceContactWrites(DataStoreHost*, bool (*)(const ContactInfo&)); + bool hasPendingContactWrites() const; + bool deleteBlobByKey(const uint8_t[], int); + bool verifying() const { return _contact_write_verifying; } + bool readyToPublish() const { + return _contact_write && _contact_write->readyToPublish(); + } +}; +#include "store_under_test.h" + +ContactInfo contact(unsigned number) { + ContactInfo c; + for (unsigned i = 0; i < 32; ++i) c.id.pub_key[i] = (number * 13 + i * 17) & 255; + memcpy(c.id.pub_key, &number, sizeof(number)); + snprintf(c.name, sizeof(c.name), "Fixture %u", number); + c.type = 1; + c.flags = number & 31; + c.tx_radio = number % 5; + c.sync_since = 1000 + number; + c.last_advert_timestamp = 2000 + number; + c.lastmod = 3000 + number; + c.gps_lat = 4000 + number; + c.gps_lon = -5000 - number; + uint8_t path[9]; + for (unsigned i = 0; i < sizeof(path); ++i) path[i] = number + i; + assert(c.setPath(path, 0x83)); + return c; +} + +Host table(unsigned count = 32) { + Host h; + for (unsigned i = 0; i < count; ++i) h.contacts.push_back(contact(i)); + return h; +} + +bool keepContact(const ContactInfo& c) { return c.type != 0; } +bool keepEven(const ContactInfo& c) { return (c.id.pub_key[0] & 1) == 0; } +using Filter = bool (*)(const ContactInfo&); + +std::vector image(const Host& h, Filter filter = nullptr) { + std::vector bytes; + for (const auto& c : h.contacts) { + if (filter && !filter(c)) continue; + uint8_t record[mesh::storage::CONTACT_RECORD_SIZE]; + assert(serializeContactRecord(c, record)); + bytes.insert(bytes.end(), record, record + sizeof(record)); + } + return bytes; +} + +unsigned drain(DataStore& store, Host& h, Filter filter = nullptr) { + const bool existed = SPIFFS.exists("/contacts3"); + const auto old = existed ? SPIFFS.files.at("/contacts3") : std::vector(); + unsigned passes = 0; + while (store.hasPendingContactWrites()) { + const unsigned writes = SPIFFS.writes, reads = SPIFFS.reads; + assert(++passes < 5000); + assert(store.serviceContactWrites(&h, filter)); + assert(SPIFFS.writes - writes <= 1); + assert(SPIFFS.reads - reads <= 1); + // Transport service runs here, between every production pass. It must + // never observe an unpublished replacement or a target-name gap. + if (store.hasPendingContactWrites()) { + assert(SPIFFS.exists("/contacts3") == existed); + if (existed) assert(SPIFFS.files.at("/contacts3") == old); + } + } + assert(SPIFFS.files.at("/contacts3") == image(h, filter)); + assert(!SPIFFS.exists("/contacts3.tmp")); + assert(SPIFFS.missing_remove_logs == 0); + return passes; +} + +void bootCheck(const Host& expected, Filter filter = nullptr) { + Host boot; + DataStore after_reset(SPIFFS); + after_reset.loadContacts(&boot); + assert(!after_reset.hasIncompleteContactLoad()); + assert(image(boot) == image(expected, filter)); + assert(!after_reset.hasPendingContactWrites()); +} + +void boundedFullTableAndFirstSave() { + SPIFFS = FakeFilesystem(); + Host h = table(350); + for (unsigned i = 0; i < h.contacts.size(); i += 7) h.contacts[i].type = 0; + DataStore store(SPIFFS); + assert(store.markContactDirty(h.contacts[0])); + const auto expected = image(h, keepContact); + const auto passes = drain(store, h, keepContact); + assert(passes > expected.size() / 64); + assert(SPIFFS.largest_read <= 64); + assert(SPIFFS.largest_write == mesh::storage::CONTACT_RECORD_SIZE); + assert(SPIFFS.removes == 0); // first publication has no backup/temp to delete + assert(h.copies >= h.contacts.size()); + bootCheck(h, keepContact); +} + +void mutationRestart(unsigned phase) { + SPIFFS = FakeFilesystem(); + Host h = table(); + SPIFFS.files["/contacts3"] = image(h); + const auto old = SPIFFS.files.at("/contacts3"); + DataStore store(SPIFFS); + assert(store.markContactDirty(h.contacts[0])); + unsigned passes = 0; + while ((phase == 0 && passes < 5) + || (phase == 1 && (!store.verifying() || SPIFFS.reads == 0)) + || (phase == 2 && !store.readyToPublish())) { + assert(++passes < 1000); + assert(store.serviceContactWrites(&h, nullptr)); + } + const auto removed = h.contacts[4]; + h.contacts.erase(h.contacts.begin() + 4); + assert(store.releaseContact(removed)); + std::reverse(h.contacts.begin(), h.contacts.end()); + h.contacts[0] = contact(777); + assert(store.markContactDirty(h.contacts[0])); + assert(store.serviceContactWrites(&h, nullptr)); // discard obsolete job + assert(store.hasPendingContactWrites()); + assert(!SPIFFS.exists("/contacts3.tmp")); + assert(SPIFFS.files.at("/contacts3") == old); + drain(store, h); + bootCheck(h); +} + +void faultAndRetry(unsigned fault) { + SPIFFS = FakeFilesystem(); + Host h = table(); + SPIFFS.files["/contacts3"] = image(h); + const auto old = SPIFFS.files.at("/contacts3"); + DataStore store(SPIFFS); + h.contacts[2] = contact(888); + assert(store.markContactDirty(h.contacts[2])); + if (fault == 0) SPIFFS.max_write = 50; + if (fault == 1) SPIFFS.fail_create = true; + if (fault == 7) SPIFFS.metadata_error = true; + bool injected = fault == 0 || fault == 1 || fault == 7; + bool success = true; + for (unsigned pass = 0; pass < 1000 && success; ++pass) { + if (!injected && store.verifying()) { + if (fault == 2) SPIFFS.fail_read = "/contacts3.tmp"; + if (fault == 3) SPIFFS.files.at("/contacts3.tmp")[20] ^= 1; + if (fault == 4) SPIFFS.files.at("/contacts3.tmp").pop_back(); + if (fault <= 4) injected = true; + } + if (!injected && store.readyToPublish()) { + SPIFFS.fail_rename = SPIFFS.renames + (fault == 5 ? 1 : 2); + injected = true; + } + success = store.serviceContactWrites(&h, nullptr); + } + assert(injected && !success); + assert(store.hasPendingContactWrites()); + assert(SPIFFS.files.at("/contacts3") == old); + assert(!SPIFFS.exists("/contacts3.tmp")); + assert(SPIFFS.missing_remove_logs == 0); + SPIFFS.max_write = std::numeric_limits::max(); + SPIFFS.fail_create = SPIFFS.metadata_error = false; + SPIFFS.fail_read.clear(); + SPIFFS.fail_rename = 0; + drain(store, h); + bootCheck(h); +} + +void syncFlushAndFilterChange() { + SPIFFS = FakeFilesystem(); + Host h = table(); + SPIFFS.files["/contacts3"] = image(h); + DataStore store(SPIFFS); + assert(store.markContactDirty(h.contacts[0])); + for (unsigned i = 0; i < 10; ++i) assert(store.serviceContactWrites(&h, nullptr)); + h.contacts[0] = contact(901); + assert(store.markContactDirty(h.contacts[0])); + assert(store.flushContactWrites(&h, keepEven)); + assert(!store.hasPendingContactWrites()); + assert(SPIFFS.files.at("/contacts3") == image(h, keepEven)); + bootCheck(h, keepEven); + assert(store.markContactDirty(h.contacts[0])); + for (unsigned i = 0; i < 10; ++i) assert(store.serviceContactWrites(&h, keepEven)); + assert(store.serviceContactWrites(&h, keepContact)); // changed filter cancels + assert(store.hasPendingContactWrites() && !SPIFFS.exists("/contacts3.tmp")); + drain(store, h, keepContact); + bootCheck(h, keepContact); + Host replacement = table(17); + assert(store.markContactDirty(h.contacts[0])); + for (unsigned i = 0; i < 10; ++i) assert(store.serviceContactWrites(&h, nullptr)); + assert(store.serviceContactWrites(&replacement, nullptr)); // changed host cancels + assert(store.hasPendingContactWrites() && !SPIFFS.exists("/contacts3.tmp")); + drain(store, replacement); + bootCheck(replacement); +} + +void realBackupCleanupFailure() { + SPIFFS = FakeFilesystem(); + Host h = table(); + const auto old = image(h); + SPIFFS.files["/contacts3"] = old; + SPIFFS.files["/contacts3.bak"] = {42}; + DataStore store(SPIFFS); + h.contacts[1] = contact(987); + assert(store.markContactDirty(h.contacts[1])); + SPIFFS.fail_remove = true; + bool success = true; + for (unsigned i = 0; i < 1000 && success; ++i) { + success = store.serviceContactWrites(&h, nullptr); + } + assert(!success && store.hasPendingContactWrites()); + assert(SPIFFS.files.at("/contacts3") == old); + assert(SPIFFS.files.at("/contacts3.bak") == std::vector({42})); + // Best-effort temp cleanup cannot hide the primary remove failure. + assert(SPIFFS.exists("/contacts3.tmp") && SPIFFS.missing_remove_logs == 0); + SPIFFS.fail_remove = false; + drain(store, h); + bootCheck(h); +} + +void rebootAbortRecoveryAndDurableRemoval() { + SPIFFS = FakeFilesystem(); + Host h = table(); + const auto old = image(h); + SPIFFS.files["/contacts3"] = old; + { + DataStore store(SPIFFS); + assert(store.markContactDirty(h.contacts[0])); + for (unsigned i = 0; i < 7; ++i) assert(store.serviceContactWrites(&h, nullptr)); + } // CPU reset/destruction must abandon an incomplete lazy image. + assert(SPIFFS.files.at("/contacts3") == old); + assert(!SPIFFS.exists("/contacts3.tmp")); + bootCheck(h); + assert(SPIFFS.rename("/contacts3", "/contacts3.bak")); // interrupted publication + bootCheck(h); // production load recovers the prior complete table + { + DataStore store(SPIFFS); + const auto removed = h.contacts[8]; + h.contacts.erase(h.contacts.begin() + 8); + assert(store.releaseContact(removed)); + drain(store, h); + } + bootCheck(h); // committed removal survives a new DataStore instance + SPIFFS.fail_read = "/contacts3"; + Host incomplete; + DataStore failed_boot(SPIFFS); + failed_boot.loadContacts(&incomplete); + assert(failed_boot.hasIncompleteContactLoad()); + const auto before = SPIFFS.files; + assert(!failed_boot.serviceContactWrites(&incomplete, nullptr)); + assert(!failed_boot.flushContactWrites(&incomplete, nullptr)); + assert(SPIFFS.files == before); +} + +void expectedAbsenceAndRealDeleteFailures() { + SPIFFS = FakeFilesystem(); + DataStore store(SPIFFS); + uint8_t key[32] = {1, 2, 3}; + char path[64]; + makeBlobPath(key, sizeof(key), path, sizeof(path)); + assert(store.deleteBlobByKey(key, sizeof(key))); + assert(SPIFFS.removes == 0 && SPIFFS.missing_remove_logs == 0); + SPIFFS.metadata_error = true; + assert(!store.deleteBlobByKey(key, sizeof(key))); + assert(SPIFFS.removes == 0); + SPIFFS.metadata_error = false; + SPIFFS.files[path] = {7, 8}; + SPIFFS.fail_remove = true; + assert(!store.deleteBlobByKey(key, sizeof(key))); + assert(SPIFFS.exists(path)); + SPIFFS.fail_remove = false; + assert(store.deleteBlobByKey(key, sizeof(key))); + assert(!SPIFFS.exists(path)); + assert(store.deleteBlobByKey(key, sizeof(key))); + assert(SPIFFS.removes == 2 && SPIFFS.missing_remove_logs == 0); + // Failed construction/destruction must not remove a temp that never existed. + SPIFFS.fail_create = true; + { mesh::ContactFileTransaction writer(&SPIFFS, "/empty", companionPathPresence); + assert(!writer); } + assert(SPIFFS.removes == 2 && SPIFFS.missing_remove_logs == 0); + SPIFFS.metadata_error = true; + { mesh::ContactFileTransaction writer(&SPIFFS, "/unreadable", companionPathPresence); + assert(!writer); } + assert(SPIFFS.removes == 2 && SPIFFS.missing_remove_logs == 0); +} + +int main() { + boundedFullTableAndFirstSave(); + for (unsigned phase = 0; phase < 3; ++phase) mutationRestart(phase); + for (unsigned fault = 0; fault < 8; ++fault) faultAndRetry(fault); + syncFlushAndFilterChange(); + realBackupCleanupFailure(); + rebootAbortRecoveryAndDurableRemoval(); + expectedAbsenceAndRealDeleteFailures(); +} diff --git a/test/test_companion_uncached_storage.py b/test/test_companion_uncached_storage.py index ee3ebafc2..e4fcd9ead 100644 --- a/test/test_companion_uncached_storage.py +++ b/test/test_companion_uncached_storage.py @@ -22,7 +22,7 @@ class CompanionUncachedStorageTest(unittest.TestCase): def run_platform(self, platform): source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() - signatures = ( + signatures = [ "static bool companionPathPresence(", "bool DataStore::loadMainIdentity(", "bool DataStore::canCreateMainIdentity() const", @@ -31,7 +31,22 @@ class CompanionUncachedStorageTest(unittest.TestCase): "void DataStore::loadContacts(", "bool DataStore::saveContacts(", "bool DataStore::hasIncompleteContactLoad() const", - ) + ] + if platform == "ESP32_PLATFORM": + signatures.extend(( + "static bool serializeContactRecord(", + "DataStore::~DataStore()", + "void DataStore::cancelContactWrite(", + "bool DataStore::serviceContactWrite(", + "bool DataStore::markContactDirty(", + "bool DataStore::releaseContact(", + "bool DataStore::serviceContactWrites(", + "bool DataStore::flushContactWrites(", + "bool DataStore::hasPendingContactWrites() const", + )) + # The serializer must be declared before save/service uses it. + signatures.insert(0, signatures.pop(signatures.index( + "static bool serializeContactRecord("))) packet = (ROOT / "src/Packet.cpp").read_text() generated = "namespace mesh {\n" + extract_braced( packet, "bool Packet::isValidPathLen(") + "\n}\n" @@ -39,6 +54,10 @@ class CompanionUncachedStorageTest(unittest.TestCase): with tempfile.TemporaryDirectory(prefix="mesh-uncached-store-") as temp: temp = Path(temp) (temp / "store_under_test.h").write_text(generated) + header = (ROOT / "examples/companion_radio/DataStore.h").read_text() + start = header.index(" mesh::ContactFileTransaction* _contact_write") + end = header.index("\n#endif", start) + (temp / "contact_write_state_under_test.h").write_text(header[start:end]) # IdentityStore.h only needs the platform filesystem declaration; # the concrete mock below implements the same file API. (temp / "FS.h").write_text( diff --git a/test/test_contact_cache.py b/test/test_contact_cache.py index ca4c1724d..bb38c306c 100644 --- a/test/test_contact_cache.py +++ b/test/test_contact_cache.py @@ -64,7 +64,7 @@ class ContactCacheTest(unittest.TestCase): helper_end = store.index("bool DataStore::readStoredPath(", helper_start) implementation = store[helper_start:helper_end] header = (ROOT / "examples/companion_radio/DataStore.h").read_text() - state_start = header.index(" File _contact_path_reader;") + state_start = header.index(" mesh::ContactFileTransaction* _contact_write") state_end = header.index("\n#endif", state_start) (temp / "contact_write_state_under_test.h").write_text( header[state_start:state_end]) @@ -73,7 +73,7 @@ class ContactCacheTest(unittest.TestCase): functions.extend(( "void DataStore::cancelContactWrite(", "bool DataStore::cancelCooperativeWrite(", - "bool DataStore::serviceCachedContactWrite(", + "bool DataStore::serviceContactWrite(", "bool DataStore::markContactDirty(", "bool DataStore::releaseContact(", "bool DataStore::serviceContactWrites(", diff --git a/test/test_esp32_cooperative_contacts.py b/test/test_esp32_cooperative_contacts.py new file mode 100644 index 000000000..3cad31c3c --- /dev/null +++ b/test/test_esp32_cooperative_contacts.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Run production ESP contact transactions with inline paths, including PSRAM.""" +from pathlib import Path +import subprocess +import tempfile +import unittest +from test_t096_full_memory import method + +ROOT = Path(__file__).resolve().parents[1] + + +class UncachedESPContactsTest(unittest.TestCase): + def test_actual_psram_cache_policy_and_cooperative_durability(self): + self.run_fixture(["-DBOARD_HAS_PSRAM=1"]) + + def test_explicit_uncached_qualification_configuration(self): + self.run_fixture(["-DMESH_CONTACT_CACHE=0"]) + + def run_fixture(self, policy): + with tempfile.TemporaryDirectory(prefix="mesh-esp-inline-contacts-") as directory: + temp = Path(directory) + source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + header = (ROOT / "examples/companion_radio/DataStore.h").read_text() + start = header.index(" mesh::ContactFileTransaction* _contact_write") + end = header.index("\n#endif", start) + (temp / "contact_write_state_under_test.h").write_text(header[start:end]) + signatures = ( + "static bool serializeContactRecord(", + "static bool deserializeContactRecord(", + "DataStore::~DataStore()", + "void DataStore::cancelContactWrite(", + "bool DataStore::serviceContactWrite(", + "bool DataStore::markContactDirty(", + "bool DataStore::releaseContact(", + "bool DataStore::serviceContactWrites(", + "bool DataStore::saveContacts(", + "bool DataStore::flushContactWrites(", + "bool DataStore::hasPendingContactWrites() const", + "bool DataStore::hasIncompleteContactLoad() const", + "void DataStore::loadContacts(", + "inline void makeBlobPath(", + ) + implementation = "\n".join(method(source, signature) for signature in signatures) + # Only the native SDK stat seam is substituted. The production + # presence logic still distinguishes ENOENT from metadata failure. + presence = method(source, "static bool companionPathPresence(") + presence = presence.replace("::stat(vfs_path, &info)", + "fixtureStat(fs, vfs_path, &info)") + (temp / "presence_under_test.h").write_text(presence) + # NRF/STM and ESP implementations have the same public signature. + delete_start = source.rindex("bool DataStore::deleteBlobByKey(") + implementation += "\n" + method(source[delete_start:], + "bool DataStore::deleteBlobByKey(") + utils = (ROOT / "src/Utils.cpp").read_text() + implementation += "\nnamespace mesh {\n" + implementation += 'static const char hex_chars[] = "0123456789ABCDEF";\n' + implementation += method(utils, "void Utils::toHex(") + "\n}\n" + (temp / "store_under_test.h").write_text(implementation) + packet = (ROOT / "src/Packet.cpp").read_text() + (temp / "packet_under_test.h").write_text("namespace mesh {\n" + "\n".join( + method(packet, signature) for signature in ( + "bool Packet::isValidPathLen(", "size_t Packet::writePath(", + "uint8_t Packet::copyPath(")) + + "\n}\n") + binary = temp / "test" + result = subprocess.run([ + "c++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", + "-DESP32_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1", *policy, + "-I", str(ROOT / "test/fixtures/contact_cache/mocks"), + "-I", str(ROOT / "test/fixtures/contact_cache"), + "-I", str(ROOT / "lib/ed25519"), "-I", str(ROOT / "src"), + "-I", str(temp), + str(ROOT / "test/fixtures/contact_persistence/test_esp32_uncached.cpp"), + "-lcrypto", "-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) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main()