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.
This commit is contained in:
1 parent
49a02f59d7
commit
acfcfb6008
12 files changed
+713
-175
No files matched your search
@@ -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&);
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <Arduino.h>
|
||||
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<uint8_t> _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<std::string, std::vector<uint8_t>>;
|
||||
Files files;
|
||||
size_t capacity = 1024 * 1024;
|
||||
size_t max_write = std::numeric_limits<size_t>::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<Files> rename_snapshots;
|
||||
std::map<std::string, unsigned> 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<bool>(*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;
|
||||
}
|
||||
+1
-1
@@ -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:
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+2
-133
@@ -1,136 +1,4 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <Arduino.h>
|
||||
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<uint8_t> _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<std::string, std::vector<uint8_t>>;
|
||||
Files files;
|
||||
size_t capacity = 1024 * 1024;
|
||||
size_t max_write = std::numeric_limits<size_t>::max();
|
||||
std::string fail_read;
|
||||
unsigned fail_rename = 0, renames = 0, writes = 0, reads = 0, opens = 0;
|
||||
std::vector<Files> rename_snapshots;
|
||||
std::map<std::string, unsigned> 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<bool>(*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 <helpers/ContactInfo.h>
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
#include "FakeFilesystem.h"
|
||||
#include <cerrno>
|
||||
#include <sys/stat.h>
|
||||
#define FILESYSTEM FakeFilesystem
|
||||
#include <helpers/ContactInfo.h>
|
||||
#include <helpers/ContactFileTransaction.h>
|
||||
#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<ContactInfo> 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<uint8_t> image(const Host& h, Filter filter = nullptr) {
|
||||
std::vector<uint8_t> 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<uint8_t>();
|
||||
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<size_t>::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<uint8_t>({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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(",
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user