diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index bc6c2173..48a3611d 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -19,6 +19,10 @@ #include #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) #include +#include "ResilientInternalExtraFS.h" +#endif +#if defined(NRF52_PLATFORM) +#include #endif #endif @@ -154,18 +158,98 @@ void DataStore::begin() { bool primary_ready = validateLfsFilesystem(_fs); if (!primary_ready) { - // A traversal error can be transient, and formatting here would erase the - // only identity before MyMesh can distinguish recovery from a fresh boot. - // Preserve the filesystem and fail closed; a reboot may recover it, while - // an explicit factory reset remains the destructive recovery operation. + for (uint8_t retry = 0; retry < 3 && !primary_ready; ++retry) { + delay(3000); + primary_ready = validateLfsFilesystem(_fs); + } + if (!primary_ready) { + MESH_DEBUG_PRINTLN( + "DataStore: primary metadata remains unreadable after delayed retries; reinitializing storage"); + primary_ready = _fs->format() && validateLfsFilesystem(_fs); + if (!primary_ready) { + mesh::storage::RamFallbackFileSystem* ram_primary = + mesh::storage::createRamFallbackFileSystem(); + if (ram_primary == nullptr) { + _primary_storage_unavailable = true; + _identity_creation_blocked = true; + _contact_load_incomplete = true; + _prefs_load_incomplete = true; + _fsExtra = nullptr; + return; + } + MESH_DEBUG_PRINTLN( + "DataStore: verified primary erase/write failed; using volatile RAM filesystem"); + useVolatilePrimaryFS(ram_primary->filesystem()); + primary_ready = true; + } else { + _identity_creation_blocked = false; + _contact_load_incomplete = false; + _prefs_load_incomplete = false; +#if defined(EXTRAFS) && !defined(QSPIFLASH) + if (_configuredFsExtra != nullptr) { + ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); + const bool scan = !extra->pageMapReady(); + if (scan) extra->resetPageMapForDestructiveRecovery(); + else extra->requirePageMapRewrite(); + if (!reinitializeInternalExtraFS(scan)) disableSecondaryFS(false); + } +#endif + } + } + } + + // A mounted filesystem can still contain an unreadable identity record. + // Keep the normal bounded reads, then try three more times three seconds + // apart. Only after those fail may startup wipe the unusable primary store. + mesh::LocalIdentity identity_probe; + IdentityLoadResult identity_state = + identity_store.loadResult("_main", identity_probe); + if (identity_state == IdentityLoadResult::Unreadable) { + for (uint8_t retry = 0; retry < 3; ++retry) { + delay(3000); + identity_state = identity_store.loadResult("_main", identity_probe); + if (identity_state != IdentityLoadResult::Unreadable) break; + } + } + if (identity_state == IdentityLoadResult::Unreadable) { MESH_DEBUG_PRINTLN( - "DataStore: primary LittleFS metadata is unavailable; preserving it and blocking startup writes"); - _primary_storage_unavailable = true; - _identity_creation_blocked = true; - _contact_load_incomplete = true; - _prefs_load_incomplete = true; - _fsExtra = nullptr; - return; + "DataStore: identity remains unreadable after delayed retries; reinitializing primary storage"); + const bool primary_reinitialized = _fs->format() + && validateLfsFilesystem(_fs); + if (!primary_reinitialized) { + mesh::storage::RamFallbackFileSystem* ram_primary = + mesh::storage::createRamFallbackFileSystem(); + if (ram_primary == nullptr) { + _primary_storage_unavailable = true; + _identity_creation_blocked = true; + _contact_load_incomplete = true; + _prefs_load_incomplete = true; + _fsExtra = nullptr; + return; + } + MESH_DEBUG_PRINTLN( + "DataStore: verified primary erase/write failed; using volatile RAM filesystem"); + useVolatilePrimaryFS(ram_primary->filesystem()); + } else { + _identity_creation_blocked = false; + _contact_load_incomplete = false; + _prefs_load_incomplete = false; +#if defined(EXTRAFS) && !defined(QSPIFLASH) + if (_configuredFsExtra != nullptr) { + ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); + const bool scan = !extra->pageMapReady(); + if (scan) extra->resetPageMapForDestructiveRecovery(); + else extra->requirePageMapRewrite(); + if (!reinitializeInternalExtraFS(scan)) { + // The primary identity is now a fresh authoritative install. An + // unusable old secondary must not block that new identity. + disableSecondaryFS(false); + } + } +#endif + } } #if defined(EXTRAFS) && !defined(QSPIFLASH) // Validate primary first: automatic secondary recovery must never hide a @@ -257,7 +341,8 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) { static const char* fixed_temp_paths[] = { "/_main.id.tmp", "/new_prefs.tmp", "/channels2.tmp", "/contacts3.tmp", "/contacts4.mig.tmp", "/adv_blobs.tmp", - "/.extrafs.mig.tmp"}; + "/.extrafs.mig.tmp", "/extrafs.badpages.tmp", + "/extrafs.badpages.bak.tmp"}; for (size_t i = 0; i < sizeof(fixed_temp_paths) / sizeof(fixed_temp_paths[0]); i++) { if (fs->exists(fixed_temp_paths[i])) fs->remove(fixed_temp_paths[i]); } @@ -283,7 +368,6 @@ bool DataStore::recoverInternalExtraFSOnBoot() { ResilientInternalExtraFS* extra = static_cast(_configuredFsExtra); if (_primary_storage_unavailable - || !extra->pageMapReady() || !mesh::storage::isExpectedInternalExtraFsGeometry( extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize()) || !mesh::storage::isInternalExtraFsReservedByApplication( @@ -293,6 +377,21 @@ bool DataStore::recoverInternalExtraFSOnBoot() { return false; } + if (!extra->pageMapReady()) { + MESH_DEBUG_PRINTLN( + "DataStore: both ExtraFS page maps are unreadable; scanning and rebuilding reserved secondary storage"); + extra->resetPageMapForDestructiveRecovery(); + if (!reinitializeInternalExtraFS(true)) { + disableSecondaryFS(true); + return false; + } + _secondary_authority_unknown = false; + _contact_load_incomplete = false; + _identity_creation_blocked = false; + _prefs_load_incomplete = false; + return true; + } + if (extra->recoveryPending()) { MESH_DEBUG_PRINTLN("DataStore: running requested boot-time ExtraFS scan"); if (!reinitializeInternalExtraFS(true)) { @@ -330,6 +429,10 @@ bool DataStore::recoverInternalExtraFSOnBoot() { } extra->acknowledgeRecoveredBootHint(); + if (extra->pageMapNeedsSave() && !extra->savePageMap(*_fs)) { + disableSecondaryFS(true); + return false; + } // This runs only before migration and user-data loading. Clear the initial // mount quarantine, not errors from a later incomplete contact/prefs load. @@ -349,13 +452,19 @@ bool DataStore::reinitializeInternalExtraFS(bool scan_physical_pages) { if (!mesh::storage::isExpectedInternalExtraFsGeometry( extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize()) - || !extra->pageMapReady() || !mesh::storage::isInternalExtraFsReservedByApplication( (uint32_t)(uintptr_t)__flash_arduino_end)) { _fsExtra = nullptr; MESH_DEBUG_PRINTLN("DataStore: refusing internal ExtraFS repair with unexpected geometry"); return false; } + if (!extra->pageMapReady()) { + if (!scan_physical_pages) { + _fsExtra = nullptr; + return false; + } + extra->resetPageMapForDestructiveRecovery(); + } // A physical test is destructive and must run only during early boot, before // radio/UI tasks can issue competing SoftDevice flash operations. Ordinary @@ -422,6 +531,21 @@ void DataStore::markPrimaryFSUnavailable() { #endif } +#if defined(NRF52_PLATFORM) +void DataStore::useVolatilePrimaryFS(FILESYSTEM& fs) { + _fs = &fs; + identity_store.useFileSystem(fs); + _fsExtra = nullptr; + _configuredFsExtra = nullptr; + _volatile_primary_fs = true; + _primary_storage_unavailable = false; + _secondary_authority_unknown = false; + _identity_creation_blocked = false; + _contact_load_incomplete = false; + _prefs_load_incomplete = false; +} +#endif + void DataStore::disableSecondaryFS(bool authority_unknown) { _fsExtra = nullptr; #if defined(NRF52_PLATFORM) @@ -594,10 +718,22 @@ bool DataStore::formatFileSystem() { // Factory reset/rebuild is already an explicit destructive operation. Use // the configured pointer so it also clears and reactivates an ExtraFS which // normal boot deliberately quarantined after failed traversal validation. - const bool secondary_success = _configuredFsExtra == nullptr - || reinitializeInternalExtraFS(); + bool secondary_success = true; + if (primary_success && _configuredFsExtra != nullptr) { + ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); + if (!extra->pageMapReady()) { + extra->resetPageMapForDestructiveRecovery(); + } else { + // The primary format erased both map files. Recreate them before the + // secondary format so a reboot never guesses a retired page is healthy. + extra->requirePageMapRewrite(); + } + secondary_success = reinitializeInternalExtraFS(); + } #else - const bool secondary_success = _fsExtra == nullptr || _fsExtra->format(); + const bool secondary_success = !primary_success + || _fsExtra == nullptr || _fsExtra->format(); #endif const bool success = primary_success && secondary_success; #if defined(NRF52_PLATFORM) @@ -688,7 +824,7 @@ bool DataStore::requestInternalExtraFSBootScan() { } ResilientInternalExtraFS* extra = static_cast(_configuredFsExtra); - return extra->requestBootScan(); + return extra->requestBootScan(true); } bool DataStore::formatInternalExtraFSHealth(char* reply, diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index bfa4e831..cb8fadb6 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -76,6 +76,7 @@ class DataStore bool _contact_load_incomplete = false; bool _primary_storage_unavailable = false; bool _secondary_authority_unknown = false; + bool _volatile_primary_fs = false; uint32_t _contact_page_generations[mesh::storage::CONTACT_PAGE_COUNT]; bool _legacy_contacts_pending_cleanup; bool _legacy_migration_ready; @@ -112,6 +113,10 @@ public: #endif FILESYSTEM* getPrimaryFS() const { return _fs; } FILESYSTEM* getSecondaryFS() const { return _fsExtra; } +#if defined(NRF52_PLATFORM) + void useVolatilePrimaryFS(FILESYSTEM& fs); + bool isVolatilePrimaryFS() const { return _volatile_primary_fs; } +#endif void markPrimaryFSUnavailable(); void disableSecondaryFS(bool authority_unknown = true); bool loadMainIdentity(mesh::LocalIdentity &identity); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 68943c00..8450235a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -21,6 +21,9 @@ #include #include #include +#if defined(NRF52_PLATFORM) +#include +#endif #if defined(ENABLE_OTA) #include #endif @@ -1853,6 +1856,17 @@ void MyMesh::begin(bool has_display, bool radio_available) { const bool prefs_ready = _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); +#if defined(NRF52_PLATFORM) + // A volatile primary filesystem is the final recovery mode after the + // physical medium has exhausted its delayed reads and destructive repair. + // Apply the diagnostic name before BLE, USB, MQTT, or adverts consume it. + if (_store->isVolatilePrimaryFS()) { + strncpy(_prefs.node_name, mesh::storage::BAD_FILESYSTEM_NODE_NAME, + sizeof(_prefs.node_name)); + _prefs.node_name[sizeof(_prefs.node_name) - 1] = 0; + } +#endif + // v1.17.1.2 repairs the Companion default-off regression for both fresh // installs and devices that already persisted the regressed value. The // appended policy marker makes this a one-time migration, so a later diff --git a/examples/companion_radio/ResilientInternalExtraFS.cpp b/examples/companion_radio/ResilientInternalExtraFS.cpp index aa30f953..3aa933e2 100644 --- a/examples/companion_radio/ResilientInternalExtraFS.cpp +++ b/examples/companion_radio/ResilientInternalExtraFS.cpp @@ -4,13 +4,17 @@ #include #include +#include #include #include #include namespace { constexpr char PAGE_MAP_PATH[] = "/extrafs.badpages"; -constexpr uint32_t PAGE_MAP_MAGIC = 0x31475042UL; // "BPG1" on nRF52 +constexpr char PAGE_MAP_BACKUP_PATH[] = "/extrafs.badpages.bak"; +constexpr uint32_t PAGE_MAP_V1_MAGIC = 0x31475042UL; // "BPG1" on nRF52 +constexpr uint32_t PAGE_MAP_V2_MAGIC = 0x32475042UL; // "BPG2" on nRF52 +constexpr uint8_t PAGE_MAP_READ_ATTEMPTS = 3; #if defined(NRF52840_XXAA) constexpr uint32_t PRIMARY_START = 0xED000UL; #else @@ -67,17 +71,103 @@ int primaryErase(const struct lfs_config*, lfs_block_t block) { int primarySync(const struct lfs_config*) { FlashLock lock; - flash_nrf5x_flush(); - return 0; + return mesh_flash_nrf5x_flush_checked() ? 0 : LFS_ERR_IO; } -struct PageMapRecord { +struct LegacyPageMapRecord { uint32_t magic; uint32_t bad_pages; uint32_t pending_pages; uint32_t check; }; +struct PageMapRecord { + uint32_t magic; + uint32_t generation; + uint32_t bad_pages; + uint32_t pending_pages; + uint32_t check; +}; + +enum class PageMapReadState : uint8_t { Missing, Valid, Invalid }; + +struct PageMapCandidate { + PageMapReadState state = PageMapReadState::Invalid; + uint32_t generation = 0; + uint32_t bad_pages = 0; + uint32_t pending_pages = 0; + bool legacy = false; +}; + +bool validPageMapValues(uint32_t bad_pages, uint32_t pending_pages) { + return (bad_pages & ~mesh::storage::INTERNAL_EXTRAFS_PAGE_MASK) == 0 + && (pending_pages + & ~(mesh::storage::INTERNAL_EXTRAFS_PAGE_MASK | (1UL << 31))) == 0 + && (bad_pages & pending_pages + & mesh::storage::INTERNAL_EXTRAFS_PAGE_MASK) == 0 + && mesh::storage::countInternalExtraFsBadPages(bad_pages) <= 4; +} + +PageMapCandidate readPageMapCandidate(Adafruit_LittleFS& primary, + const char* path) { + PageMapCandidate candidate; + for (uint8_t attempt = 0; attempt < PAGE_MAP_READ_ATTEMPTS; ++attempt) { + struct lfs_info info = {}; + primary._lockFS(); + const int stat_result = lfs_stat(primary._getFS(), path, &info); + primary._unlockFS(); + if (stat_result == LFS_ERR_NOENT) { + candidate.state = PageMapReadState::Missing; + return candidate; + } + if (stat_result != LFS_ERR_OK || info.type != LFS_TYPE_REG + || (info.size != sizeof(PageMapRecord) + && info.size != sizeof(LegacyPageMapRecord))) { + continue; + } + + File file = primary.open(path, FILE_O_READ); + if (!file) continue; + if (info.size == sizeof(PageMapRecord)) { + PageMapRecord record = {}; + const int count = file.read(reinterpret_cast(&record), + sizeof(record)); + file.close(); + if (count == sizeof(record) && record.magic == PAGE_MAP_V2_MAGIC + && record.check + == ~(record.magic ^ record.generation ^ record.bad_pages + ^ record.pending_pages) + && validPageMapValues(record.bad_pages, record.pending_pages)) { + candidate.state = PageMapReadState::Valid; + candidate.generation = record.generation; + candidate.bad_pages = record.bad_pages; + candidate.pending_pages = record.pending_pages; + return candidate; + } + } else { + LegacyPageMapRecord record = {}; + const int count = file.read(reinterpret_cast(&record), + sizeof(record)); + file.close(); + if (count == sizeof(record) && record.magic == PAGE_MAP_V1_MAGIC + && record.check + == ~(record.magic ^ record.bad_pages ^ record.pending_pages) + && validPageMapValues(record.bad_pages, record.pending_pages)) { + candidate.state = PageMapReadState::Valid; + candidate.bad_pages = record.bad_pages; + candidate.pending_pages = record.pending_pages; + candidate.legacy = true; + return candidate; + } + } + } + return candidate; +} + +bool newerGeneration(uint32_t candidate, uint32_t reference) { + return static_cast(candidate - reference) > 0; +} + } // namespace using mesh::storage::countInternalExtraFsBadPages; @@ -103,7 +193,7 @@ ResilientInternalExtraFS::ResilientInternalExtraFS( // bootloader leaves this register alone for an ordinary app reset. const uint8_t retained = static_cast(NRF_POWER->GPREGRET2); _boot_marker_at_init = retained; - if (retained == BOOT_SCAN_ALL) { + if (retained == BOOT_SCAN_ALL || retained == BOOT_SCAN_REPEAT) { _boot_scan_requested = true; _boot_scan_forced = true; } else if (retained >= BOOT_SCAN_BAD_PAGE_BASE @@ -132,37 +222,43 @@ bool ResilientInternalExtraFS::loadPageMap(Adafruit_LittleFS& primary) { _map_ready = false; _bad_pages = 0; _pending_pages = 0; - struct lfs_info info; - const int stat_result = lfs_stat(primary._getFS(), PAGE_MAP_PATH, &info); - if (stat_result == LFS_ERR_NOENT) { + _map_generation = 0; + _map_repair_needed = false; + + const PageMapCandidate main = readPageMapCandidate(primary, PAGE_MAP_PATH); + const PageMapCandidate backup = + readPageMapCandidate(primary, PAGE_MAP_BACKUP_PATH); + if (main.state == PageMapReadState::Missing + && backup.state == PageMapReadState::Missing) { _configure_lfs(); _map_ready = true; return true; } - if (stat_result != LFS_ERR_OK || info.type != LFS_TYPE_REG - || info.size != sizeof(PageMapRecord)) return false; - File file = primary.open(PAGE_MAP_PATH, FILE_O_READ); - if (!file) return false; - PageMapRecord record = {}; - const int read_count = file.read(reinterpret_cast(&record), - sizeof(record)); - file.close(); - if (read_count != sizeof(record) || record.magic != PAGE_MAP_MAGIC - || record.check != ~(record.magic ^ record.bad_pages - ^ record.pending_pages) - || (record.bad_pages & ~PAGE_MASK) != 0 - || (record.pending_pages & ~(PAGE_MASK | SCAN_REQUEST)) != 0 - || (record.bad_pages & record.pending_pages & PAGE_MASK) != 0 - || countInternalExtraFsBadPages( - record.bad_pages | (record.pending_pages & PAGE_MASK)) - > MAX_BAD_PAGES) { + const PageMapCandidate* chosen = nullptr; + if (main.state == PageMapReadState::Valid + && backup.state == PageMapReadState::Valid) { + chosen = newerGeneration(backup.generation, main.generation) + ? &backup : &main; + _map_repair_needed = main.legacy || backup.legacy + || main.generation != backup.generation + || main.bad_pages != backup.bad_pages + || main.pending_pages != backup.pending_pages; + } else if (main.state == PageMapReadState::Valid) { + chosen = &main; + _map_repair_needed = true; + } else if (backup.state == PageMapReadState::Valid) { + chosen = &backup; + _map_repair_needed = true; + } else { return false; } - _bad_pages = record.bad_pages; - _pending_pages = record.pending_pages; - _recorded_bad_pages = record.bad_pages; - _recorded_pending_pages = record.pending_pages; + + _bad_pages = chosen->bad_pages; + _pending_pages = chosen->pending_pages; + _map_generation = chosen->generation; + _recorded_bad_pages = _bad_pages; + _recorded_pending_pages = _pending_pages; _configure_lfs(); _map_ready = true; return true; @@ -170,27 +266,47 @@ bool ResilientInternalExtraFS::loadPageMap(Adafruit_LittleFS& primary) { bool ResilientInternalExtraFS::savePageMap(Adafruit_LittleFS& primary) { if (!_map_ready) return false; + const uint32_t generation = _map_generation + 1; const PageMapRecord record = { - PAGE_MAP_MAGIC, _bad_pages, _pending_pages, - ~(PAGE_MAP_MAGIC ^ _bad_pages ^ _pending_pages)}; - mesh::AtomicFileWriter writer(&primary, PAGE_MAP_PATH); - const bool saved = writer - && writer.write(reinterpret_cast(&record), - sizeof(record)) == sizeof(record) - && writer.commit(); + PAGE_MAP_V2_MAGIC, generation, _bad_pages, _pending_pages, + ~(PAGE_MAP_V2_MAGIC ^ generation ^ _bad_pages ^ _pending_pages)}; + const auto write_record = [&primary, &record](const char* path) -> bool { + mesh::AtomicFileWriter writer(&primary, path); + return writer + && writer.write(reinterpret_cast(&record), + sizeof(record)) == sizeof(record) + && writer.commit(); + }; + const bool main_saved = write_record(PAGE_MAP_PATH); + const bool backup_saved = write_record(PAGE_MAP_BACKUP_PATH); + const bool saved = main_saved && backup_saved; if (saved) { + _map_generation = generation; _recorded_bad_pages = _bad_pages; _recorded_pending_pages = _pending_pages; + _map_repair_needed = false; } _stage = saved ? Stage::MapSaved : Stage::MapSaveFailed; return saved; } bool ResilientInternalExtraFS::pageMapNeedsSave() const { - return _bad_pages != _recorded_bad_pages + return _map_repair_needed + || _bad_pages != _recorded_bad_pages || _pending_pages != _recorded_pending_pages; } +void ResilientInternalExtraFS::resetPageMapForDestructiveRecovery() { + _bad_pages = 0; + _pending_pages = 0; + _recorded_bad_pages = 0; + _recorded_pending_pages = 0; + _map_generation = 0; + _map_repair_needed = true; + _map_ready = true; + _configure_lfs(); +} + bool ResilientInternalExtraFS::setBootScanRequest(uint8_t value) { uint8_t sd_enabled = 0; if (mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS) return false; @@ -218,17 +334,19 @@ bool ResilientInternalExtraFS::clearBootScanRequest() { return true; } -bool ResilientInternalExtraFS::requestBootScan() { - if (!_map_ready) return false; +bool ResilientInternalExtraFS::requestBootScan(bool force_full_scan) { + if (!_map_ready && !force_full_scan) return false; // Do not touch the primary filesystem while radio/BLE tasks are running. // The suspect 4 KiB page (if known) fits in one retained byte. A fault hint // first gets a non-destructive mount and traversal at boot; only an explicit // user scan or unrecoverable filesystem triggers the destructive test. uint8_t marker = BOOT_SCAN_ALL; - if ((_pending_pages & PAGE_MASK) != 0) { + if (!force_full_scan && (_pending_pages & PAGE_MASK) != 0) { for (uint8_t page = 0; page < PAGE_COUNT; ++page) { if ((_pending_pages & (1UL << page)) != 0) { - marker = BOOT_SCAN_BAD_PAGE_BASE + page; + marker = (_retained_bad_page == page) + ? BOOT_SCAN_REPEAT + : BOOT_SCAN_BAD_PAGE_BASE + page; break; } } @@ -241,9 +359,16 @@ void ResilientInternalExtraFS::acknowledgeRecoveredBootHint() { // the existing filesystem after a transient runtime flash error. The // hint remains a diagnostic only; it is not a bad-page verdict. if (!_boot_scan_forced) { - clearBootScanRequest(); _pending_pages &= SCAN_REQUEST; - _retained_bad_page = 0xFF; + if (_retained_bad_page < PAGE_COUNT) { + // Power management consumes GPREGRET2 during startup. Re-arm this first + // strike only after the filesystem has mounted and traversed cleanly. A + // later fault on the same page becomes the forced second-strike marker. + _boot_scan_requested = + setBootScanRequest(BOOT_SCAN_BAD_PAGE_BASE + _retained_bad_page); + } else { + clearBootScanRequest(); + } } } @@ -287,11 +412,12 @@ int ResilientInternalExtraFS::commitPage() { retirePending(_cached_page); return LFS_ERR_CORRUPT; } - flash_nrf5x_flush(); + const bool flush_ok = mesh_flash_nrf5x_flush_checked(); // Read actual flash, not flash_nrf5x_read(): that API may return its RAM // cache even when the physical erase/program silently failed. - if (memcmp(reinterpret_cast(address), _page_buffer, - PAGE_SIZE) != 0) { + if (!flush_ok + || memcmp(reinterpret_cast(address), _page_buffer, + PAGE_SIZE) != 0) { retirePending(_cached_page); return LFS_ERR_CORRUPT; } diff --git a/examples/companion_radio/ResilientInternalExtraFS.h b/examples/companion_radio/ResilientInternalExtraFS.h index b0209c3c..3ff9d13e 100644 --- a/examples/companion_radio/ResilientInternalExtraFS.h +++ b/examples/companion_radio/ResilientInternalExtraFS.h @@ -47,7 +47,9 @@ public: bool scanAndRetireBadPages(); bool savePageMap(Adafruit_LittleFS& primary); bool pageMapNeedsSave() const; - bool requestBootScan(); + void resetPageMapForDestructiveRecovery(); + void requirePageMapRewrite() { _map_repair_needed = true; } + bool requestBootScan(bool force_full_scan = false); void acknowledgeRecoveredBootHint(); protected: @@ -59,10 +61,13 @@ private: mesh::storage::INTERNAL_EXTRAFS_PAGE_MASK; static constexpr uint32_t SCAN_REQUEST = 1UL << 31; static constexpr uint8_t MAX_BAD_PAGES = 4; - // GPREGRET2 is retained across a software reset. These values are distinct - // from the OTA/bootloader handoff and power-management reason codes. - static constexpr uint8_t BOOT_SCAN_ALL = 0x9F; - static constexpr uint8_t BOOT_SCAN_BAD_PAGE_BASE = 0x80; + // GPREGRET2 is retained across a software reset. Reserve 0x20..0x38 for + // the 25 physical ExtraFS pages and 0x3E/0x3F for aggregate scans. This is + // deliberately disjoint from OTA staging values, legacy apply results + // 0x90..0x9F and 0xB1..0xBF, and bootloader-update results 0xC1..0xC9. + static constexpr uint8_t BOOT_SCAN_ALL = 0x3F; + static constexpr uint8_t BOOT_SCAN_REPEAT = 0x3E; + static constexpr uint8_t BOOT_SCAN_BAD_PAGE_BASE = 0x20; uint32_t _bad_pages = 0; uint32_t _pending_pages = 0; @@ -72,6 +77,8 @@ private: uint8_t _retained_bad_page = 0xFF; uint32_t _recorded_bad_pages = 0; uint32_t _recorded_pending_pages = 0; + uint32_t _map_generation = 0; + bool _map_repair_needed = false; uint32_t _cached_page = INVALID_PAGE; bool _dirty = false; bool _io_failed = false; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 2eeea8ee..35a89380 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -183,15 +183,8 @@ static bool isNetworkTerminalActive(); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include #if defined(NRF52_PLATFORM) - #include - #include - #if defined(NRF52840_XXAA) - static const uint32_t INTERNAL_PRIMARY_FS_START = 0x000ED000UL; - #else - static const uint32_t INTERNAL_PRIMARY_FS_START = 0x0006D000UL; - #endif - static const uint32_t INTERNAL_PRIMARY_FS_SIZE = - 7UL * FLASH_NRF52_PAGE_SIZE; + #include + #include #endif #if defined(QSPIFLASH) #include @@ -2793,43 +2786,30 @@ void setup() { // InternalFileSystem::begin() auto-formats the entire primary store after a // mount failure. The base mount plus a full raw scan distinguishes a virgin // erased device from nonblank/corrupt identity storage. Only proven-erased - // media may be initialized automatically; format is followed by a required - // base remount because Adafruit_LittleFS::format() does not remount an - // already-unmounted instance. + // media may be initialized automatically. A nonblank failure gets three + // additional mounts three seconds apart before the unusable store is erased + // so the node can still recover without physical access. const mesh::storage::InternalSecondaryFsBootResult primary_fs_boot = - mesh::storage::prepareInternalSecondaryFilesystem( - []() -> bool { + mesh::storage::beginInternalPrimaryFilesystemSafely( + InternalFS, #if defined(EXTRAFS) && !defined(QSPIFLASH) - return InternalFS.Adafruit_LittleFS::begin( - ResilientInternalExtraFS::primaryConfig()); + ResilientInternalExtraFS::primaryConfig() #else - return InternalFS.Adafruit_LittleFS::begin(); + nullptr #endif - }, - []() -> bool { - return mesh::storage::isErasedFlashRange( - INTERNAL_PRIMARY_FS_START, INTERNAL_PRIMARY_FS_SIZE, - [](uint32_t address) -> uint32_t { - return *reinterpret_cast(address); - }); - }, - []() -> bool { - InternalFS.end(); - return InternalFS.format(); - }); - if (primary_fs_boot - == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank - || primary_fs_boot - == mesh::storage::InternalSecondaryFsBootResult::InitializationFailed) { - if (primary_fs_boot - == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank) { + ); + if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + mesh::storage::RamFallbackFileSystem* ram_primary_fs = + mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { MESH_DEBUG_PRINTLN( - "InternalFS: mount failed; preserving nonblank primary storage and blocking startup writes"); + "InternalFS: physical storage unusable; running from volatile RAM filesystem"); + store.useVolatilePrimaryFS(ram_primary_fs->filesystem()); } else { MESH_DEBUG_PRINTLN( - "InternalFS: erased primary storage initialization failed; blocking startup writes"); + "InternalFS: physical storage and RAM fallback initialization failed"); + store.markPrimaryFSUnavailable(); } - store.markPrimaryFSUnavailable(); } #else InternalFS.begin(); @@ -2854,8 +2834,8 @@ void setup() { #else #if defined(EXTRAFS) const bool extra_fs_map_ready = - (primary_fs_boot == mesh::storage::InternalSecondaryFsBootResult::Mounted - || primary_fs_boot == mesh::storage::InternalSecondaryFsBootResult::InitializedBlank) + !store.isVolatilePrimaryFS() + && mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot) && ExtraFS.loadPageMap(InternalFS); const bool extra_fs_geometry_valid = mesh::storage::isExpectedInternalExtraFsGeometry( @@ -2891,6 +2871,15 @@ void setup() { , radio_available ); +#if defined(NRF52_PLATFORM) + if (store.isVolatilePrimaryFS()) { + strncpy(the_mesh.getNodePrefs()->node_name, + mesh::storage::BAD_FILESYSTEM_NODE_NAME, + sizeof(the_mesh.getNodePrefs()->node_name)); + the_mesh.getNodePrefs()->node_name[ + sizeof(the_mesh.getNodePrefs()->node_name) - 1] = 0; + } +#endif #elif defined(RP2040_PLATFORM) LittleFS.begin(); store.begin(); diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index e74cbd7e..1023fdeb 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -11,6 +11,8 @@ #if defined(NRF52_PLATFORM) #include + #include + #include #elif defined(RP2040_PLATFORM) #include #elif defined(ESP32) @@ -39,7 +41,26 @@ void halt() { } void loadOrCreateIdentity() { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + bool volatile_primary_fs = false; + mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; + FILESYSTEM* identity_fs = nullptr; + const auto primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); + if (mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + identity_fs = &InternalFS; + } else { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs == nullptr) { + MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); + board.reboot(); + halt(); + } + identity_fs = &ram_primary_fs->filesystem(); + volatile_primary_fs = true; + } + IdentityStore store(*identity_fs, ""); +#elif defined(STM32_PLATFORM) InternalFS.begin(); IdentityStore store(InternalFS, ""); #elif defined(ESP32) @@ -53,9 +74,28 @@ void loadOrCreateIdentity() { #error "Filesystem not defined" #endif - const bool needs_identity = !store.load("_main", identity) - || mesh::hasReservedIdentityPrefix(identity); - bool identity_ready = true; +#if defined(NRF52_PLATFORM) + IdentityLoadResult identity_load = volatile_primary_fs + ? store.loadResult("_main", identity) + : mesh::storage::loadIdentityWithPrimaryRecovery( + InternalFS, + [&store]() { return store.loadResult("_main", identity); }); + if (identity_load == IdentityLoadResult::Unreadable) { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { + identity_fs = &ram_primary_fs->filesystem(); + store.useFileSystem(*identity_fs); + volatile_primary_fs = true; + identity_load = IdentityLoadResult::Missing; + } + } +#else + const IdentityLoadResult identity_load = store.loadResult("_main", identity); +#endif + const bool needs_identity = identity_load == IdentityLoadResult::Missing + || (identity_load == IdentityLoadResult::Loaded + && mesh::hasReservedIdentityPrefix(identity)); + bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { identity_ready = mesh::generateUsableLocalIdentity(identity, radio_new_identity); if (identity_ready) identity_ready = store.saveWithRetry("_main", identity); @@ -69,6 +109,12 @@ void loadOrCreateIdentity() { board.reboot(); halt(); // Never let setup continue if a platform's reboot returns. } +#if defined(NRF52_PLATFORM) + if (volatile_primary_fs) { + MESH_DEBUG_PRINTLN("Node: %s (volatile RAM storage)", + mesh::storage::BAD_FILESYSTEM_NODE_NAME); + } +#endif } void onSetRadio(float freq, float bw, uint8_t sf, uint8_t cr) { diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 90a5c8f3..9c329de3 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -12,6 +12,10 @@ #if defined(ESP32_PLATFORM) #include #endif +#if defined(NRF52_PLATFORM) + #include + #include +#endif #if defined(ESP32) && MAX_RECENT_REPEATERS > 0 #include #endif @@ -155,7 +159,25 @@ void setup() { fast_rng.begin(radio_driver.getRngSeed()); FILESYSTEM* fs; -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + bool volatile_primary_fs = false; + mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; + const auto primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); + if (mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + fs = &InternalFS; + } else { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs == nullptr) { + MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); + board.reboot(); + return; + } + fs = &ram_primary_fs->filesystem(); + volatile_primary_fs = true; + } + IdentityStore store(*fs, ""); +#elif defined(STM32_PLATFORM) InternalFS.begin(); fs = &InternalFS; IdentityStore store(InternalFS, ""); @@ -171,9 +193,29 @@ void setup() { #else #error "need to define filesystem" #endif - const bool needs_identity = !store.load("_main", the_mesh.self_id) - || mesh::hasReservedIdentityPrefix(the_mesh.self_id); - bool identity_ready = true; +#if defined(NRF52_PLATFORM) + IdentityLoadResult identity_load = volatile_primary_fs + ? store.loadResult("_main", the_mesh.self_id) + : mesh::storage::loadIdentityWithPrimaryRecovery( + InternalFS, + [&store]() { return store.loadResult("_main", the_mesh.self_id); }); + if (identity_load == IdentityLoadResult::Unreadable) { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { + fs = &ram_primary_fs->filesystem(); + store.useFileSystem(*fs); + volatile_primary_fs = true; + identity_load = IdentityLoadResult::Missing; + } + } +#else + const IdentityLoadResult identity_load = + store.loadResult("_main", the_mesh.self_id); +#endif + const bool needs_identity = identity_load == IdentityLoadResult::Missing + || (identity_load == IdentityLoadResult::Loaded + && mesh::hasReservedIdentityPrefix(the_mesh.self_id)); + bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { MESH_DEBUG_PRINTLN("Generating new keypair"); identity_ready = mesh::generateUsableLocalIdentity(the_mesh.self_id, radio_new_identity); @@ -218,6 +260,16 @@ void setup() { the_mesh.begin(fs); +#if defined(NRF52_PLATFORM) + if (volatile_primary_fs) { + strncpy(the_mesh.getNodePrefs()->node_name, + mesh::storage::BAD_FILESYSTEM_NODE_NAME, + sizeof(the_mesh.getNodePrefs()->node_name)); + the_mesh.getNodePrefs()->node_name[ + sizeof(the_mesh.getNodePrefs()->node_name) - 1] = 0; + } +#endif + #ifdef DISPLAY_CLASS if (display_ready) { #ifdef WITH_MQTT_BRIDGE diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index c95c2b25..d1fbde51 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -10,6 +10,10 @@ #if defined(ESP32_PLATFORM) #include #endif +#if defined(NRF52_PLATFORM) + #include + #include +#endif #ifdef ETHERNET_ENABLED #define ETHERNET_CLI_BANNER "MeshCore Room Server CLI" @@ -83,9 +87,23 @@ void setup() { FILESYSTEM* fs; #if defined(NRF52_PLATFORM) - InternalFS.begin(); - fs = &InternalFS; - IdentityStore store(InternalFS, ""); + bool volatile_primary_fs = false; + mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; + const auto primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); + if (mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + fs = &InternalFS; + } else { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs == nullptr) { + MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); + board.reboot(); + return; + } + fs = &ram_primary_fs->filesystem(); + volatile_primary_fs = true; + } + IdentityStore store(*fs, ""); #elif defined(RP2040_PLATFORM) LittleFS.begin(); fs = &LittleFS; @@ -98,9 +116,29 @@ void setup() { #else #error "need to define filesystem" #endif - const bool needs_identity = !store.load("_main", the_mesh.self_id) - || mesh::hasReservedIdentityPrefix(the_mesh.self_id); - bool identity_ready = true; +#if defined(NRF52_PLATFORM) + IdentityLoadResult identity_load = volatile_primary_fs + ? store.loadResult("_main", the_mesh.self_id) + : mesh::storage::loadIdentityWithPrimaryRecovery( + InternalFS, + [&store]() { return store.loadResult("_main", the_mesh.self_id); }); + if (identity_load == IdentityLoadResult::Unreadable) { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { + fs = &ram_primary_fs->filesystem(); + store.useFileSystem(*fs); + volatile_primary_fs = true; + identity_load = IdentityLoadResult::Missing; + } + } +#else + const IdentityLoadResult identity_load = + store.loadResult("_main", the_mesh.self_id); +#endif + const bool needs_identity = identity_load == IdentityLoadResult::Missing + || (identity_load == IdentityLoadResult::Loaded + && mesh::hasReservedIdentityPrefix(the_mesh.self_id)); + bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { identity_ready = mesh::generateUsableLocalIdentity(the_mesh.self_id, radio_new_identity); if (identity_ready) identity_ready = store.saveWithRetry("_main", the_mesh.self_id); @@ -128,6 +166,16 @@ void setup() { the_mesh.begin(fs); +#if defined(NRF52_PLATFORM) + if (volatile_primary_fs) { + strncpy(the_mesh.getNodePrefs()->node_name, + mesh::storage::BAD_FILESYSTEM_NODE_NAME, + sizeof(the_mesh.getNodePrefs()->node_name)); + the_mesh.getNodePrefs()->node_name[ + sizeof(the_mesh.getNodePrefs()->node_name) - 1] = 0; + } +#endif + #ifdef DISPLAY_CLASS if (display_ready) { #ifdef WITH_MQTT_BRIDGE diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index b0f6d239..7b47b606 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -7,6 +7,8 @@ #if defined(NRF52_PLATFORM) #include + #include + #include #elif defined(RP2040_PLATFORM) #include #elif defined(ESP32) @@ -316,11 +318,10 @@ public: float getFreqPref() const { return _prefs.freq; } int8_t getTxPowerPref() const { return _prefs.tx_power_dbm; } - void begin(FILESYSTEM& fs) { + void begin(FILESYSTEM& fs, bool volatile_primary_fs = false) { _fs = &fs; BaseChatMesh::begin(); - _radio_profiles.begin(_fs, _radio, getRTCClock()); #if defined(NRF52_PLATFORM) IdentityStore store(fs, ""); @@ -330,15 +331,44 @@ public: #else IdentityStore store(fs, "/identity"); #endif - const bool needs_identity = !store.load("_main", self_id, _prefs.node_name, sizeof(_prefs.node_name)) - || mesh::hasReservedIdentityPrefix(self_id); // legacy: node_name was from identity file - bool identity_ready = true; + #if defined(NRF52_PLATFORM) + IdentityLoadResult identity_load = volatile_primary_fs + ? store.loadResult("_main", self_id, _prefs.node_name, + sizeof(_prefs.node_name)) + : mesh::storage::loadIdentityWithPrimaryRecovery( + InternalFS, [this, &store]() { + return store.loadResult("_main", self_id, _prefs.node_name, + sizeof(_prefs.node_name)); + }); + if (identity_load == IdentityLoadResult::Unreadable) { + mesh::storage::RamFallbackFileSystem* ram_primary_fs = + mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { + _fs = &ram_primary_fs->filesystem(); + store.useFileSystem(*_fs); + volatile_primary_fs = true; + identity_load = IdentityLoadResult::Missing; + } + } + #else + const IdentityLoadResult identity_load = store.loadResult( + "_main", self_id, _prefs.node_name, sizeof(_prefs.node_name)); + #endif + const bool needs_identity = identity_load == IdentityLoadResult::Missing + || (identity_load == IdentityLoadResult::Loaded + && mesh::hasReservedIdentityPrefix(self_id)); // legacy: node_name was from identity file + bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { // Need way to get some entropy to seed RNG - Serial.println("Press ENTER to generate key:"); - char c = 0; - while (c != '\n') { // wait for ENTER to be pressed - if (Serial.available()) c = Serial.read(); + if (!volatile_primary_fs) { + Serial.println("Press ENTER to generate key:"); + char c = 0; + while (c != '\n') { // wait for ENTER to be pressed + if (Serial.available()) c = Serial.read(); + } + } else { + MESH_DEBUG_PRINTLN( + "Physical filesystem unavailable; generating volatile recovery identity"); } ((StdRNG *)getRNG())->begin(millis()); @@ -360,6 +390,16 @@ public: return; } + #if defined(NRF52_PLATFORM) + if (volatile_primary_fs) { + strncpy(_prefs.node_name, mesh::storage::BAD_FILESYSTEM_NODE_NAME, + sizeof(_prefs.node_name)); + _prefs.node_name[sizeof(_prefs.node_name) - 1] = 0; + } + #endif + + _radio_profiles.begin(_fs, _radio, getRTCClock()); + // load persisted prefs if (_fs->exists("/node_prefs")) { #if defined(RP2040_PLATFORM) @@ -705,8 +745,22 @@ void setup() { fast_rng.begin(radio_driver.getRngSeed()); #if defined(NRF52_PLATFORM) - InternalFS.begin(); - the_mesh.begin(InternalFS); + FILESYSTEM* primary_fs = &InternalFS; + bool volatile_primary_fs = false; + mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; + const auto primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); + if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs == nullptr) { + MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); + board.reboot(); + return; + } + primary_fs = &ram_primary_fs->filesystem(); + volatile_primary_fs = true; + } + the_mesh.begin(*primary_fs, volatile_primary_fs); #elif defined(RP2040_PLATFORM) LittleFS.begin(); the_mesh.begin(LittleFS); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index dc9b289a..1fc212ac 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,5 +1,9 @@ #include "SensorMesh.h" #include +#if defined(NRF52_PLATFORM) + #include + #include +#endif #include #if defined(ESP32_PLATFORM) @@ -98,7 +102,25 @@ void setup() { fast_rng.begin(radio_driver.getRngSeed()); FILESYSTEM* fs; -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + bool volatile_primary_fs = false; + mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; + const auto primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); + if (mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + fs = &InternalFS; + } else { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs == nullptr) { + MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); + board.reboot(); + return; + } + fs = &ram_primary_fs->filesystem(); + volatile_primary_fs = true; + } + IdentityStore store(*fs, ""); +#elif defined(STM32_PLATFORM) InternalFS.begin(); fs = &InternalFS; IdentityStore store(InternalFS, ""); @@ -114,9 +136,29 @@ void setup() { #else #error "need to define filesystem" #endif - const bool needs_identity = !store.load("_main", the_mesh.self_id) - || mesh::hasReservedIdentityPrefix(the_mesh.self_id); - bool identity_ready = true; +#if defined(NRF52_PLATFORM) + IdentityLoadResult identity_load = volatile_primary_fs + ? store.loadResult("_main", the_mesh.self_id) + : mesh::storage::loadIdentityWithPrimaryRecovery( + InternalFS, + [&store]() { return store.loadResult("_main", the_mesh.self_id); }); + if (identity_load == IdentityLoadResult::Unreadable) { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { + fs = &ram_primary_fs->filesystem(); + store.useFileSystem(*fs); + volatile_primary_fs = true; + identity_load = IdentityLoadResult::Missing; + } + } +#else + const IdentityLoadResult identity_load = + store.loadResult("_main", the_mesh.self_id); +#endif + const bool needs_identity = identity_load == IdentityLoadResult::Missing + || (identity_load == IdentityLoadResult::Loaded + && mesh::hasReservedIdentityPrefix(the_mesh.self_id)); + bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { MESH_DEBUG_PRINTLN("Generating new keypair"); identity_ready = mesh::generateUsableLocalIdentity(the_mesh.self_id, radio_new_identity); @@ -143,6 +185,16 @@ void setup() { the_mesh.begin(fs); +#if defined(NRF52_PLATFORM) + if (volatile_primary_fs) { + strncpy(the_mesh.getNodePrefs()->node_name, + mesh::storage::BAD_FILESYSTEM_NODE_NAME, + sizeof(the_mesh.getNodePrefs()->node_name)); + the_mesh.getNodePrefs()->node_name[ + sizeof(the_mesh.getNodePrefs()->node_name) - 1] = 0; + } +#endif + #ifdef DISPLAY_CLASS ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); #endif diff --git a/scripts/nrf52_internal_flash_fix.py b/scripts/nrf52_internal_flash_fix.py index 8d498b90..3f0cfb25 100644 --- a/scripts/nrf52_internal_flash_fix.py +++ b/scripts/nrf52_internal_flash_fix.py @@ -1,10 +1,11 @@ -"""Build-local fix for the nRF52 InternalFS SoftDevice flash wait. +"""Build-local reliability fixes for the nRF52 InternalFS flash backend. GCC 14/LTO can treat the naked SVC wrapper as not writing through its pointer argument. It then folds wait_for_async_flash_op_completion() into an immediate -success return, allowing LittleFS to read or rewrite a page before the async -erase/program has completed. Keep the SoftDevice state volatile and add a -compiler barrier after the SVC call. Do not modify PlatformIO's shared SDK. +success return. The framework cache also discards erase/program failures and +InternalFS always reports sync success. Patch private build copies so completion +is observed, physical readback is volatile, and LittleFS receives LFS_ERR_IO. +Do not modify PlatformIO's shared SDK. """ from pathlib import Path @@ -22,36 +23,197 @@ FIXED_WAIT = """ volatile uint8_t sd_en = 0; if (sd_en) { """ +OLD_FLUSH = """void flash_nrf5x_flush (void) +{ + flash_cache_flush(&_cache); +} +""" +FIXED_FLUSH = """extern bool mesh_flash_cache_take_flush_result(void); -def patched_source(source): - source = source.replace("\r\n", "\n") - if FIXED_WAIT in source and OLD_WAIT not in source: +bool mesh_flash_nrf5x_flush_checked (void) +{ + flash_cache_flush(&_cache); + return mesh_flash_cache_take_flush_result(); +} + +void flash_nrf5x_flush (void) +{ + (void) mesh_flash_nrf5x_flush_checked(); +} +""" + +OLD_VERIFY = """static bool fal_verify (uint32_t addr, void const * buf, uint32_t len) +{ + return 0 == memcmp((void*) addr, buf, len); +} +""" +FIXED_VERIFY = """static bool fal_verify (uint32_t addr, void const * buf, uint32_t len) +{ + volatile uint8_t const * flash = (volatile uint8_t const *) addr; + uint8_t const * expected = (uint8_t const *) buf; + for (uint32_t i = 0; i < len; ++i) { + if (flash[i] != expected[i]) return false; + } + return true; +} +""" + +OLD_CACHE_FLUSH = """void flash_cache_flush (flash_cache_t* fc) +{ + if ( fc->cache_addr == FLASH_CACHE_INVALID_ADDR ) return; + + // skip erase & program if verify() exists, and memory matches + if ( !(fc->verify && fc->verify(fc->cache_addr, fc->cache_buf, FLASH_CACHE_SIZE)) ) + { + // indicator TODO allow to disable flash indicator + ledOn(LED_BUILTIN); + + fc->erase(fc->cache_addr); + fc->program(fc->cache_addr, fc->cache_buf, FLASH_CACHE_SIZE); + + ledOff(LED_BUILTIN); + } + + fc->cache_addr = FLASH_CACHE_INVALID_ADDR; +} +""" +FIXED_CACHE_FLUSH = """static bool mesh_flash_cache_flush_ok = true; + +bool mesh_flash_cache_take_flush_result(void) +{ + bool const result = mesh_flash_cache_flush_ok; + mesh_flash_cache_flush_ok = true; + return result; +} + +void flash_cache_flush (flash_cache_t* fc) +{ + if ( fc->cache_addr == FLASH_CACHE_INVALID_ADDR ) return; + + // skip erase & program if verify() exists, and memory matches + if ( !(fc->verify && fc->verify(fc->cache_addr, fc->cache_buf, FLASH_CACHE_SIZE)) ) + { + // indicator TODO allow to disable flash indicator + ledOn(LED_BUILTIN); + + bool ok = fc->erase(fc->cache_addr); + if ( ok ) ok = fc->program(fc->cache_addr, fc->cache_buf, + FLASH_CACHE_SIZE) == FLASH_CACHE_SIZE; + if ( ok && fc->verify ) { + ok = fc->verify(fc->cache_addr, fc->cache_buf, FLASH_CACHE_SIZE); + } + if ( !ok ) mesh_flash_cache_flush_ok = false; + + ledOff(LED_BUILTIN); + } + + fc->cache_addr = FLASH_CACHE_INVALID_ADDR; +} +""" + +OLD_INTERNAL_READ = """ VERIFY( flash_nrf5x_read(buffer, addr, size) > 0, -1); +""" +FIXED_INTERNAL_READ = """ VERIFY( flash_nrf5x_read(buffer, addr, size) == (int) size, LFS_ERR_IO); +""" +OLD_INTERNAL_PROG = """ VERIFY( flash_nrf5x_write(addr, buffer, size), -1) +""" +FIXED_INTERNAL_PROG = """ VERIFY( flash_nrf5x_write(addr, buffer, size) == (int) size, LFS_ERR_IO) +""" +OLD_INTERNAL_ERASE_WRITE = """ flash_nrf5x_write8(addr + i, 0xFF); +""" +FIXED_INTERNAL_ERASE_WRITE = """ VERIFY( flash_nrf5x_write8(addr + i, 0xFF) == 1, LFS_ERR_IO); +""" +OLD_INTERNAL_SYNC = """static int _internal_flash_sync (const struct lfs_config *c) +{ + (void) c; + flash_nrf5x_flush(); + return 0; +} +""" +FIXED_INTERNAL_SYNC = """extern \"C\" bool mesh_flash_nrf5x_flush_checked(void); + +static int _internal_flash_sync (const struct lfs_config *c) +{ + (void) c; + return mesh_flash_nrf5x_flush_checked() ? 0 : LFS_ERR_IO; +} +""" + + +def _replace_once(source, old, new, description): + if new in source and old not in source: return source - if source.count(OLD_WAIT) != 1: + if source.count(old) != 1: raise RuntimeError( - "nRF52 flash fix: unrecognized InternalFS flash driver; review " - "the framework update before building (shared SDK not modified)" + "nRF52 flash fix: unrecognized " + description + "; review the " + "framework update before building (shared SDK not modified)" ) - return source.replace(OLD_WAIT, FIXED_WAIT) + return source.replace(old, new) -def replace_driver(build_env, node): +def patched_driver_source(source): + source = source.replace("\r\n", "\n") + source = _replace_once(source, OLD_WAIT, FIXED_WAIT, "InternalFS flash driver") + source = _replace_once(source, OLD_FLUSH, FIXED_FLUSH, "InternalFS flush API") + return _replace_once(source, OLD_VERIFY, FIXED_VERIFY, + "InternalFS physical verification") + + +def patched_cache_source(source): + source = source.replace("\r\n", "\n") + return _replace_once(source, OLD_CACHE_FLUSH, FIXED_CACHE_FLUSH, + "InternalFS flash cache") + + +def patched_internal_fs_source(source): + source = source.replace("\r\n", "\n") + source = _replace_once(source, OLD_INTERNAL_READ, FIXED_INTERNAL_READ, + "InternalFS read callback") + source = _replace_once(source, OLD_INTERNAL_PROG, FIXED_INTERNAL_PROG, + "InternalFS program callback") + source = _replace_once(source, OLD_INTERNAL_ERASE_WRITE, + FIXED_INTERNAL_ERASE_WRITE, + "InternalFS erase callback") + return _replace_once(source, OLD_INTERNAL_SYNC, FIXED_INTERNAL_SYNC, + "InternalFS sync callback") + + +# Backward-compatible name used by the regression test and any local tooling. +patched_source = patched_driver_source + + +def replace_framework_source(build_env, node): source = Path(node.srcnode().get_abspath()) - patched = patched_source(source.read_text(encoding="utf-8")) + patcher = { + "flash_nrf5x.c": patched_driver_source, + "flash_cache.c": patched_cache_source, + "InternalFileSystem.cpp": patched_internal_fs_source, + }.get(source.name) + if patcher is None: + raise RuntimeError("nRF52 flash fix: unexpected source " + source.name) + patched = patcher(source.read_text(encoding="utf-8")) build_env.AppendUnique(CPPPATH=[str(source.parent)]) destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-flash" / source.name destination.parent.mkdir(parents=True, exist_ok=True) if not destination.exists() or destination.read_text(encoding="utf-8") != patched: destination.write_text(patched, encoding="utf-8") - print("nRF52 flash: build-local SoftDevice completion wait fix enabled") + print("nRF52 flash: build-local verified flush fix enabled for " + source.name) return build_env.File(str(destination)) def install(build_env): build_env.AddBuildMiddleware( - replace_driver, + replace_framework_source, "*flash_nrf5x.c", ) + build_env.AddBuildMiddleware( + replace_framework_source, + "*flash_cache.c", + ) + build_env.AddBuildMiddleware( + replace_framework_source, + "*InternalFileSystem.cpp", + ) if "Import" in globals(): diff --git a/src/helpers/AtomicFileWriter.h b/src/helpers/AtomicFileWriter.h index 93610472..1d777350 100644 --- a/src/helpers/AtomicFileWriter.h +++ b/src/helpers/AtomicFileWriter.h @@ -18,6 +18,7 @@ namespace mesh { */ class AtomicFileWriter { static const size_t TEMP_PATH_CAPACITY = 96; + static const uint8_t VALIDATION_ATTEMPTS = 3; FILESYSTEM* _fs; const char* _target_path; @@ -31,7 +32,7 @@ class AtomicFileWriter { static uint32_t updateCRC32(uint32_t crc, const uint8_t* data, size_t len); void removeTempFile(); - bool validateTempFile(); + bool validateFile(const char* path); public: AtomicFileWriter(FILESYSTEM* fs, const char* target_path); @@ -76,28 +77,31 @@ void AtomicFileWriter::removeTempFile() { } } -bool AtomicFileWriter::validateTempFile() { - File verify(*_fs); - if (!verify.open(_temp_path, FILE_O_READ)) return false; +bool AtomicFileWriter::validateFile(const char* path) { + for (uint8_t attempt = 0; attempt < VALIDATION_ATTEMPTS; ++attempt) { + File verify(*_fs); + if (!verify.open(path, FILE_O_READ)) continue; - bool valid = verify.size() == _bytes_written; - uint32_t read_crc = 0xFFFFFFFFUL; - size_t remaining = _bytes_written; - uint8_t buf[64]; + bool valid = verify.size() == _bytes_written; + uint32_t read_crc = 0xFFFFFFFFUL; + size_t remaining = _bytes_written; + uint8_t buf[64]; - while (valid && remaining > 0) { - size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); - int count = verify.read(buf, (uint16_t)chunk); - if (count != (int)chunk) { - valid = false; - break; + while (valid && remaining > 0) { + size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); + int count = verify.read(buf, (uint16_t)chunk); + if (count != (int)chunk) { + valid = false; + break; + } + read_crc = updateCRC32(read_crc, buf, chunk); + remaining -= chunk; } - read_crc = updateCRC32(read_crc, buf, chunk); - remaining -= chunk; - } - verify.close(); - return valid && remaining == 0 && read_crc == _crc; + verify.close(); + if (valid && remaining == 0 && read_crc == _crc) return true; + } + return false; } AtomicFileWriter::AtomicFileWriter(FILESYSTEM* fs, const char* target_path) @@ -166,8 +170,12 @@ bool AtomicFileWriter::commit(bool content_valid) { _opened = false; } - if (success) success = validateTempFile(); + if (success) success = validateFile(_temp_path); if (success) success = _fs->rename(_temp_path, _target_path); + // A lower layer can acknowledge a metadata update before the physical page + // is durable. Validate the public filename too; callers must never clear a + // dirty flag or announce success while the old/corrupt file is still live. + if (success) success = validateFile(_target_path); if (!success) removeTempFile(); _finished = true; diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index baafa0f8..1ba4a9aa 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -24,18 +24,30 @@ bool IdentityStore::recover(const char* name) { } bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) { - return load(name, id, nullptr, 0); + return loadResult(name, id) == IdentityLoadResult::Loaded; } bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz) { + return loadResult(name, id, display_name, max_name_sz) + == IdentityLoadResult::Loaded; +} + +IdentityLoadResult IdentityStore::loadResult( + const char *name, mesh::LocalIdentity& id) { + return loadResult(name, id, nullptr, 0); +} + +IdentityLoadResult IdentityStore::loadResult( + const char *name, mesh::LocalIdentity& id, char display_name[], + int max_name_sz) { char filename[40]; if (snprintf(filename, sizeof(filename), "%s/%s.id", _dir, name) - >= (int)sizeof(filename)) return false; + >= (int)sizeof(filename)) return IdentityLoadResult::Unreadable; for (unsigned attempt = 0; attempt < IO_ATTEMPTS; ++attempt) { if (!recover(name)) continue; bool present = false; if (!mesh::filePresence(_fs, filename, present)) continue; - if (!present) return false; + if (!present) return IdentityLoadResult::Missing; #if defined(RP2040_PLATFORM) File file = _fs->open(filename, "r"); #else @@ -62,10 +74,12 @@ bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display } file.close(); id = loaded; - return true; + return IdentityLoadResult::Loaded; } - // Callers may provision a replacement after bounded recovery is exhausted. - return false; + // An existing file which cannot be read is materially different from a + // fresh device. Startup callers must preserve it instead of replacing the + // radio's private key after a transient or localized flash failure. + return IdentityLoadResult::Unreadable; } bool IdentityStore::saveWithRetry(const char* name, const mesh::LocalIdentity& id) { diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index 2c079bc6..a7c4fa76 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -1,5 +1,7 @@ #pragma once +#include + #if defined(ESP32) || defined(RP2040_PLATFORM) #include #define FILESYSTEM fs::FS @@ -11,18 +13,28 @@ #endif #include +enum class IdentityLoadResult : uint8_t { + Loaded, + Missing, + Unreadable, +}; + class IdentityStore { FILESYSTEM* _fs; const char* _dir; public: static constexpr unsigned IO_ATTEMPTS = 3; IdentityStore(FILESYSTEM& fs, const char* dir): _fs(&fs), _dir(dir) { } + void useFileSystem(FILESYSTEM& fs) { _fs = &fs; } void begin() { if (_dir && _dir[0] == '/') { _fs->mkdir(_dir); } } // Recover an interrupted non-replacing rename before deciding this is a // fresh identity. False means the previous image must remain protected. bool recover(const char* name); + IdentityLoadResult loadResult(const char *name, mesh::LocalIdentity& id); + IdentityLoadResult loadResult(const char *name, mesh::LocalIdentity& id, + char display_name[], int max_name_sz); bool load(const char *name, mesh::LocalIdentity& id); bool load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz); bool save(const char *name, const mesh::LocalIdentity& id); diff --git a/src/helpers/nrf52/InternalFlashStatus.h b/src/helpers/nrf52/InternalFlashStatus.h new file mode 100644 index 00000000..11f80509 --- /dev/null +++ b/src/helpers/nrf52/InternalFlashStatus.h @@ -0,0 +1,7 @@ +#pragma once + +#if defined(NRF52_PLATFORM) + +extern "C" bool mesh_flash_nrf5x_flush_checked(void); + +#endif diff --git a/src/helpers/nrf52/InternalPrimaryFsBoot.h b/src/helpers/nrf52/InternalPrimaryFsBoot.h new file mode 100644 index 00000000..63de677f --- /dev/null +++ b/src/helpers/nrf52/InternalPrimaryFsBoot.h @@ -0,0 +1,112 @@ +#pragma once + +#if defined(NRF52_PLATFORM) + +#include +#include +#include +#include + +namespace mesh { +namespace storage { + +#if defined(NRF52840_XXAA) +static const uint32_t INTERNAL_PRIMARY_FS_START = 0xED000UL; +#else +static const uint32_t INTERNAL_PRIMARY_FS_START = 0x6D000UL; +#endif +static const uint32_t INTERNAL_PRIMARY_FS_SIZE = + 7UL * FLASH_NRF52_PAGE_SIZE; +static const uint8_t INTERNAL_PRIMARY_FS_MOUNT_ATTEMPTS = 3; + +// InternalFileSystem::begin() formats after one failed mount. Identity storage +// instead gets several non-destructive mount attempts and is formatted only +// when every word in the complete reserved range is still erased. +inline InternalSecondaryFsBootResult beginInternalPrimaryFilesystemSafely( + InternalFileSystem& fs, struct lfs_config* config = nullptr) { + const auto mount_with_retries = [&fs, config]() -> bool { + for (uint8_t attempt = 0; attempt < INTERNAL_PRIMARY_FS_MOUNT_ATTEMPTS; + ++attempt) { + if (attempt != 0) fs.end(); + const bool mounted = config == nullptr + ? fs.Adafruit_LittleFS::begin() + : fs.Adafruit_LittleFS::begin(config); + if (mounted) return true; + } + return false; + }; + + InternalSecondaryFsBootResult result = prepareInternalSecondaryFilesystem( + mount_with_retries, + []() -> bool { + return isErasedFlashRange( + INTERNAL_PRIMARY_FS_START, INTERNAL_PRIMARY_FS_SIZE, + [](uint32_t address) -> uint32_t { + return *reinterpret_cast(address); + }); + }, + [&fs]() -> bool { + fs.end(); + return fs.format(); + }); + if (result == InternalSecondaryFsBootResult::Mounted + || result == InternalSecondaryFsBootResult::InitializedBlank) { + return result; + } + + // A node which can never leave startup is not useful. Give transient flash + // or SoftDevice contention three widely spaced chances to clear before the + // explicitly authorized last resort: erase the unusable primary store. + for (uint8_t retry = 0; retry < 3; ++retry) { + delay(3000); + fs.end(); + const bool mounted = config == nullptr + ? fs.Adafruit_LittleFS::begin() + : fs.Adafruit_LittleFS::begin(config); + if (mounted) return InternalSecondaryFsBootResult::Mounted; + } + + fs.end(); + if (!fs.format() || !mount_with_retries()) { + return InternalSecondaryFsBootResult::InitializationFailed; + } + return InternalSecondaryFsBootResult::ReinitializedUnreadable; +} + +inline bool internalPrimaryFilesystemReady( + InternalSecondaryFsBootResult result) { + return result == InternalSecondaryFsBootResult::Mounted + || result == InternalSecondaryFsBootResult::InitializedBlank + || result == InternalSecondaryFsBootResult::ReinitializedUnreadable; +} + +template +IdentityLoadResult loadIdentityWithPrimaryRecovery( + InternalFileSystem& fs, LoadIdentity load_identity, + struct lfs_config* config = nullptr) { + IdentityLoadResult result = load_identity(); + if (result != IdentityLoadResult::Unreadable) return result; + + for (uint8_t retry = 0; retry < 3; ++retry) { + delay(3000); + result = load_identity(); + if (result != IdentityLoadResult::Unreadable) return result; + } + + fs.end(); + if (!fs.format()) return IdentityLoadResult::Unreadable; + for (uint8_t attempt = 0; attempt < INTERNAL_PRIMARY_FS_MOUNT_ATTEMPTS; + ++attempt) { + if (attempt != 0) fs.end(); + const bool mounted = config == nullptr + ? fs.Adafruit_LittleFS::begin() + : fs.Adafruit_LittleFS::begin(config); + if (mounted) return load_identity(); + } + return IdentityLoadResult::Unreadable; +} + +} // namespace storage +} // namespace mesh + +#endif // NRF52_PLATFORM diff --git a/src/helpers/nrf52/InternalSecondaryFsRepair.h b/src/helpers/nrf52/InternalSecondaryFsRepair.h index c2b53496..c903f9f4 100644 --- a/src/helpers/nrf52/InternalSecondaryFsRepair.h +++ b/src/helpers/nrf52/InternalSecondaryFsRepair.h @@ -38,6 +38,7 @@ bool isErasedFlashRange(uint32_t start, uint32_t size, ReadWord read_word) { enum class InternalSecondaryFsBootResult { Mounted, InitializedBlank, + ReinitializedUnreadable, PreservedNonBlank, InitializationFailed, }; diff --git a/src/helpers/nrf52/RamFallbackFileSystem.h b/src/helpers/nrf52/RamFallbackFileSystem.h new file mode 100644 index 00000000..24933b70 --- /dev/null +++ b/src/helpers/nrf52/RamFallbackFileSystem.h @@ -0,0 +1,92 @@ +#pragma once + +#if defined(NRF52_PLATFORM) + +#include +#include +#include +#include + +namespace mesh { +namespace storage { + +static constexpr char BAD_FILESYSTEM_NODE_NAME[] = "bad file system"; + +// Emergency-only volatile storage. It is allocated only after physical +// primary flash has failed every retry and a destructive reinitialization. +// Eight KiB is enough for identity/preferences and basic node operation while +// keeping the normal boot's RAM footprint unchanged. +class RamFallbackFileSystem { + static constexpr uint32_t BLOCK_SIZE = 128; + static constexpr uint32_t STORAGE_SIZE = 8UL * 1024UL; + + alignas(4) uint8_t _storage[STORAGE_SIZE]; + struct lfs_config _config; + Adafruit_LittleFS _filesystem; + + static RamFallbackFileSystem* owner(const struct lfs_config* config) { + return static_cast(config->context); + } + + static bool validRange(lfs_block_t block, lfs_off_t off, lfs_size_t size) { + return block < STORAGE_SIZE / BLOCK_SIZE && off <= BLOCK_SIZE + && size <= BLOCK_SIZE - off; + } + + static int read(const struct lfs_config* config, lfs_block_t block, + lfs_off_t off, void* buffer, lfs_size_t size) { + if (!validRange(block, off, size)) return LFS_ERR_IO; + memcpy(buffer, owner(config)->_storage + block * BLOCK_SIZE + off, size); + return 0; + } + + static int program(const struct lfs_config* config, lfs_block_t block, + lfs_off_t off, const void* buffer, lfs_size_t size) { + if (!validRange(block, off, size)) return LFS_ERR_IO; + uint8_t* destination = owner(config)->_storage + block * BLOCK_SIZE + off; + const uint8_t* source = static_cast(buffer); + for (lfs_size_t i = 0; i < size; ++i) destination[i] &= source[i]; + return 0; + } + + static int erase(const struct lfs_config* config, lfs_block_t block) { + if (!validRange(block, 0, BLOCK_SIZE)) return LFS_ERR_IO; + memset(owner(config)->_storage + block * BLOCK_SIZE, 0xFF, BLOCK_SIZE); + return 0; + } + + static int sync(const struct lfs_config*) { return 0; } + +public: + RamFallbackFileSystem() : _config{}, _filesystem(&_config) { + memset(_storage, 0xFF, sizeof(_storage)); + _config.context = this; + _config.read = read; + _config.prog = program; + _config.erase = erase; + _config.sync = sync; + _config.read_size = 16; + _config.prog_size = 16; + _config.block_size = BLOCK_SIZE; + _config.block_count = STORAGE_SIZE / BLOCK_SIZE; + _config.lookahead = 64; + } + + bool begin() { + return _filesystem.format() && _filesystem.begin(); + } + + Adafruit_LittleFS& filesystem() { return _filesystem; } +}; + +inline RamFallbackFileSystem* createRamFallbackFileSystem() { + RamFallbackFileSystem* fallback = new (std::nothrow) RamFallbackFileSystem(); + if (fallback != nullptr && fallback->begin()) return fallback; + delete fallback; + return nullptr; +} + +} // namespace storage +} // namespace mesh + +#endif // NRF52_PLATFORM diff --git a/test/test_atomic_file_writer/test_atomic_file_writer.cpp b/test/test_atomic_file_writer/test_atomic_file_writer.cpp index 049b08f8..25c87e2d 100644 --- a/test/test_atomic_file_writer/test_atomic_file_writer.cpp +++ b/test/test_atomic_file_writer/test_atomic_file_writer.cpp @@ -36,6 +36,8 @@ public: size_t max_write = std::numeric_limits::max(); bool fail_read_open = false; bool fail_rename = false; + bool false_success_rename = false; + unsigned read_open_failures_remaining = 0; unsigned rename_calls = 0; bool exists(const char* path) const { @@ -49,6 +51,7 @@ public: bool rename(const char* from, const char* to) { rename_calls++; if (fail_rename) return false; + if (false_success_rename) return true; auto source = files.find(from); if (source == files.end()) return false; files[to] = source->second; @@ -69,6 +72,10 @@ bool FakeFile::open(const char* path, uint8_t mode) { _path = path; _write_mode = mode == FILE_O_WRITE; if (!_write_mode && (_fs->fail_read_open || !_fs->exists(path))) return false; + if (!_write_mode && _fs->read_open_failures_remaining > 0) { + _fs->read_open_failures_remaining--; + return false; + } if (_write_mode) { auto& contents = _fs->files[_path]; _position = contents.size(); @@ -188,6 +195,34 @@ TEST(AtomicFileWriter, RenameFailurePreservesLiveFileAndCleansTemp) { EXPECT_EQ(fs.rename_calls, 1u); } +TEST(AtomicFileWriter, FalseSuccessRenameIsDetectedByLiveReadback) { + FakeFilesystem fs; + fs.files["/prefs"] = bytes("old"); + fs.false_success_rename = true; + + mesh::AtomicFileWriter writer(&fs, "/prefs"); + const uint8_t replacement[] = {'n', 'e', 'w'}; + ASSERT_EQ(writer.write(replacement, sizeof(replacement)), sizeof(replacement)); + + EXPECT_FALSE(writer.commit()); + EXPECT_EQ(fs.files["/prefs"], bytes("old")); + EXPECT_FALSE(fs.exists("/prefs.tmp")); + EXPECT_EQ(fs.rename_calls, 1u); +} + +TEST(AtomicFileWriter, ValidationRetriesTransientReadOpenFailures) { + FakeFilesystem fs; + fs.files["/prefs"] = bytes("old"); + fs.read_open_failures_remaining = 2; + + mesh::AtomicFileWriter writer(&fs, "/prefs"); + const uint8_t replacement[] = {'n', 'e', 'w'}; + ASSERT_EQ(writer.write(replacement, sizeof(replacement)), sizeof(replacement)); + + EXPECT_TRUE(writer.commit()); + EXPECT_EQ(fs.files["/prefs"], bytes("new")); +} + TEST(AtomicFileWriter, AbandonedWriteAndStaleTempAreCleaned) { FakeFilesystem fs; fs.files["/prefs"] = bytes("old"); diff --git a/test/test_identity_and_settings_recovery.py b/test/test_identity_and_settings_recovery.py index 6c7d0b6d..272f3966 100644 --- a/test/test_identity_and_settings_recovery.py +++ b/test/test_identity_and_settings_recovery.py @@ -56,6 +56,8 @@ class IdentityAndSettingsRecovery(unittest.TestCase): for signature in ('bool IdentityStore::recover(', 'bool IdentityStore::load(const char *name, mesh::LocalIdentity& id)', 'bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz)', + 'IdentityLoadResult IdentityStore::loadResult(\n const char *name, mesh::LocalIdentity& id)', + 'IdentityLoadResult IdentityStore::loadResult(\n const char *name, mesh::LocalIdentity& id, char display_name[]', 'bool IdentityStore::saveWithRetry(', 'bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id)', 'bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char display_name[])'): @@ -87,6 +89,10 @@ int main() { if (display) assert(strcmp(name, "saved name") == 0); } else { assert(live.pub_key[0] == 9 && strcmp(name, "unchanged") == 0); + const auto state = display + ? store.loadResult("main", live, name, sizeof(name)) + : store.loadResult("main", live); + assert(state == IdentityLoadResult::Loaded); assert(store.saveWithRetry("main", live)); } } @@ -160,6 +166,7 @@ int main() { with self.subTest(role=role): source = (ROOT / 'examples' / role / 'main.cpp').read_text() self.assertIn('if (identity_ready) identity_ready = store.saveWithRetry(', source) + self.assertIn('IdentityLoadResult::Unreadable', source) failed = extract_braced(source, 'if (!identity_ready)') self.assertIn('board.reboot();', failed) self.assertIn('halt();' if role == 'kiss_modem' else 'return;', failed) diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index 85c8e15c..432c66c1 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -875,12 +875,12 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertIn("_identity_creation_blocked = true;", unmounted_branch) self.assertIn("_contact_load_incomplete = true;", unmounted_branch) self.assertIn("return;", unmounted_branch) - self.assertIn("_primary_storage_unavailable = true;", primary_failure_branch) - self.assertIn("_identity_creation_blocked = true;", primary_failure_branch) - self.assertIn("_contact_load_incomplete = true;", primary_failure_branch) - self.assertIn("return;", primary_failure_branch) + self.assertGreaterEqual(primary_failure_branch.count("delay(3000);"), 2) + self.assertIn("_fs->format()", primary_failure_branch) + self.assertIn("createRamFallbackFileSystem()", primary_failure_branch) + self.assertIn("useVolatilePrimaryFS", primary_failure_branch) self.assertNotIn("recoverPrimaryFilesystem", store_begin) - self.assertNotIn("->format(", store_begin) + self.assertIn("IdentityLoadResult::Unreadable", store_begin) secondary_failure = store_begin.index( "if (_fsExtra != nullptr && !validateLfsFilesystem(_fsExtra))" ) @@ -905,46 +905,36 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): setup = function_body(MAIN.read_text(encoding="utf-8"), "void setup()") main = MAIN.read_text(encoding="utf-8") + primary_helper = ( + ROOT / "src/helpers/nrf52/InternalPrimaryFsBoot.h" + ).read_text(encoding="utf-8") self.assertIn( - "static const uint32_t INTERNAL_PRIMARY_FS_START = 0x000ED000UL;", - main, + "static const uint32_t INTERNAL_PRIMARY_FS_START = 0xED000UL;", + primary_helper, ) self.assertIn( - "static const uint32_t INTERNAL_PRIMARY_FS_START = 0x0006D000UL;", - main, + "static const uint32_t INTERNAL_PRIMARY_FS_START = 0x6D000UL;", + primary_helper, ) - self.assertIn("7UL * FLASH_NRF52_PAGE_SIZE", main) + self.assertIn("7UL * FLASH_NRF52_PAGE_SIZE", primary_helper) primary_policy = setup.index( "InternalSecondaryFsBootResult primary_fs_boot" ) - primary_mount = setup.index( - "InternalFS.Adafruit_LittleFS::begin()", primary_policy - ) - blank_scan = setup.index("isErasedFlashRange(", primary_mount) - primary_format = setup.index("InternalFS.format()", blank_scan) - quarantine = setup.index("store.markPrimaryFSUnavailable()", primary_format) - store_begin = setup.index("store.begin()", quarantine) - self.assertLess(primary_policy, primary_mount) - self.assertLess(primary_mount, blank_scan) - self.assertLess(blank_scan, primary_format) - self.assertLess(primary_format, quarantine) - self.assertLess(quarantine, store_begin) - primary_policy_body = setup[primary_policy:quarantine] - self.assertIn("INTERNAL_PRIMARY_FS_START", primary_policy_body) - self.assertIn("INTERNAL_PRIMARY_FS_SIZE", primary_policy_body) - self.assertIn( - "InternalSecondaryFsBootResult::PreservedNonBlank", - primary_policy_body, - ) - self.assertIn( - "InternalSecondaryFsBootResult::InitializationFailed", - primary_policy_body, - ) + fallback = setup.index("createRamFallbackFileSystem()", primary_policy) + volatile_store = setup.index("store.useVolatilePrimaryFS", fallback) + store_begin = setup.index("store.begin()", volatile_store) + self.assertLess(primary_policy, fallback) + self.assertLess(fallback, volatile_store) + self.assertLess(volatile_store, store_begin) + self.assertIn("beginInternalPrimaryFilesystemSafely", setup) + self.assertGreaterEqual(primary_helper.count("delay(3000);"), 2) + self.assertIn("fs.format()", primary_helper) + self.assertIn("ReinitializedUnreadable", primary_helper) # Never call the core override in the nRF path: it auto-erases a # nonblank filesystem after any mount failure. The sole occurrence is # the separately scoped STM32 fallback. self.assertEqual(setup.count("InternalFS.begin();"), 1) - self.assertGreater(setup.index("#else\n InternalFS.begin();", primary_policy), primary_format) + self.assertGreater(setup.index("#else\n InternalFS.begin();", primary_policy), fallback) self.assertIn("store.disableSecondaryFS(true);", setup) secondary_geometry = setup.index( "const bool extra_fs_geometry_valid =" diff --git a/test/test_nrf52_extrafs_page_map.py b/test/test_nrf52_extrafs_page_map.py index 2a6adf24..8657163c 100644 --- a/test/test_nrf52_extrafs_page_map.py +++ b/test/test_nrf52_extrafs_page_map.py @@ -69,16 +69,28 @@ int main() { store = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() device = (ROOT / "examples/companion_radio/ResilientInternalExtraFS.cpp").read_text() + device_header = ( + ROOT / "examples/companion_radio/ResilientInternalExtraFS.h" + ).read_text() self.assertIn("reinitializeInternalExtraFS(true)", store) self.assertIn("scan_physical_pages && !extra->scanAndRetireBadPages()", store) - self.assertIn("extra->requestBootScan()", store) + self.assertIn("extra->requestBootScan(true)", store) self.assertIn("setBootScanRequest(marker)", device) self.assertIn("BOOT_SCAN_ALL", device) + self.assertIn("BOOT_SCAN_BAD_PAGE_BASE = 0x20", device_header) + self.assertIn("BOOT_SCAN_REPEAT = 0x3E", device_header) + self.assertIn("BOOT_SCAN_ALL = 0x3F", device_header) + self.assertNotIn("BOOT_SCAN_REPEAT_BASE", device_header) + page_markers = set(range(0x20, 0x20 + 25)) + recovery_markers = page_markers | {0x3E, 0x3F} + ota_markers = {0x51, 0x53, 0xA6, 0xD4, 0xED} + ota_markers.update(range(0x90, 0xA0)) + ota_markers.update(range(0xB1, 0xC0)) + ota_markers.update(range(0xC1, 0xCA)) + self.assertFalse(recovery_markers & ota_markers) self.assertIn("uint32_t bad = _bad_pages;", device) self.assertNotIn("_bad_pages | (_pending_pages & PAGE_MASK)", device) - self.assertIn("return _boot_scan_forced ||", ( - ROOT / "examples/companion_radio/ResilientInternalExtraFS.h" - ).read_text()) + self.assertIn("return _boot_scan_forced ||", device_header) self.assertIn("extra->acknowledgeRecoveredBootHint()", store) self.assertIn("extra->pageMapNeedsSave()", store) scan_command = mesh.split("void MyMesh::scanInternalExtraFS(Stream& output)", 1)[1] diff --git a/test/test_nrf52_internal_flash_fix.py b/test/test_nrf52_internal_flash_fix.py index d8059998..2dd884fe 100644 --- a/test/test_nrf52_internal_flash_fix.py +++ b/test/test_nrf52_internal_flash_fix.py @@ -14,13 +14,32 @@ spec.loader.exec_module(module) class Nrf52InternalFlashFixTest(unittest.TestCase): def test_wait_cannot_be_optimized_into_immediate_success(self): - source = "before\n" + module.OLD_WAIT + " do_wait();\n }\nafter\n" + source = ("before\n" + module.OLD_WAIT + " do_wait();\n }\n" + + module.OLD_FLUSH + module.OLD_VERIFY + "after\n") patched = module.patched_source(source) self.assertIn("volatile uint8_t sd_en", patched) self.assertIn('''__asm volatile ("" ::: "memory");''', patched) self.assertIn("if (sd_en)", patched) + self.assertIn("mesh_flash_nrf5x_flush_checked", patched) + self.assertIn("volatile uint8_t const * flash", patched) self.assertEqual(module.patched_source(patched), patched) + def test_cache_failures_are_sticky_until_checked_sync(self): + patched = module.patched_cache_source(module.OLD_CACHE_FLUSH) + self.assertIn("mesh_flash_cache_take_flush_result", patched) + self.assertIn("mesh_flash_cache_flush_ok = false", patched) + self.assertIn("fc->program", patched) + self.assertIn("fc->verify", patched) + self.assertEqual(module.patched_cache_source(patched), patched) + + def test_internalfs_propagates_checked_flush_failure(self): + source = (module.OLD_INTERNAL_READ + module.OLD_INTERNAL_PROG + + module.OLD_INTERNAL_ERASE_WRITE + module.OLD_INTERNAL_SYNC) + patched = module.patched_internal_fs_source(source) + self.assertIn("== (int) size", patched) + self.assertIn("mesh_flash_nrf5x_flush_checked() ? 0 : LFS_ERR_IO", patched) + self.assertEqual(module.patched_internal_fs_source(patched), patched) + def test_unrecognized_framework_fails_closed(self): with self.assertRaises(RuntimeError): module.patched_source("changed SDK layout") @@ -29,6 +48,8 @@ class Nrf52InternalFlashFixTest(unittest.TestCase): ini = (ROOT / "platformio.ini").read_text(encoding="utf-8") self.assertIn("pre:scripts/nrf52_internal_flash_fix.py", ini) self.assertIn('"*flash_nrf5x.c"', SCRIPT.read_text()) + self.assertIn('"*flash_cache.c"', SCRIPT.read_text()) + self.assertIn('"*InternalFileSystem.cpp"', SCRIPT.read_text()) # The hardware-in-loop images intentionally bypass nrf52_base, but # still use the same framework and must not regress to its unsafe SVC # flash-completion wait.