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()