From 610a88e8d564b9282a73428d64030edb8743ada8 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 2 Sep 2026 11:41:38 -0700 Subject: [PATCH] Fix nRF52 contact persistence and ExtraFS recovery --- examples/companion_radio/AbstractUITask.h | 17 +- examples/companion_radio/DataStore.cpp | 374 ++++++++++++++++-- examples/companion_radio/DataStore.h | 12 +- examples/companion_radio/MyMesh.cpp | 276 ++++++++++--- examples/companion_radio/MyMesh.h | 13 +- examples/companion_radio/main.cpp | 47 ++- examples/companion_radio/ui-new/UITask.cpp | 1 + examples/companion_radio/ui-orig/UITask.cpp | 1 + examples/companion_radio/ui-tiny/UITask.cpp | 1 + examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_room_server/MyMesh.cpp | 1 + examples/simple_sensor/SensorMesh.cpp | 1 + src/Mesh.cpp | 21 +- src/Mesh.h | 7 + src/helpers/esp32/WebConfigServer.cpp | 6 +- src/helpers/nrf52/InternalSecondaryFsRepair.h | 64 +++ src/helpers/ota/OtaApply.cpp | 10 +- src/helpers/ota/OtaApply.h | 5 +- src/helpers/ota/OtaCli.cpp | 11 +- test/test_companion_contact_sleep_contract.py | 258 ++++++++++++ .../test_internal_secondary_fs_repair.cpp | 146 +++++++ test/test_nrf52_extrafs_contract.py | 365 +++++++++++++++++ test/test_ota_dev_reboot_guard.py | 66 ++++ 23 files changed, 1591 insertions(+), 113 deletions(-) create mode 100644 src/helpers/nrf52/InternalSecondaryFsRepair.h create mode 100644 test/test_companion_contact_sleep_contract.py create mode 100644 test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp create mode 100644 test/test_nrf52_extrafs_contract.py create mode 100644 test/test_ota_dev_reboot_guard.py diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 91c963b6..354b8442 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -26,16 +26,30 @@ enum class UIEventType { ack }; +class UIShutdownGuard { +public: + virtual ~UIShutdownGuard() = default; + virtual bool prepareForUiShutdown() = 0; +}; + class AbstractUITask { protected: mesh::MainBoard* _board; MultiSerialInterface* _interfaceManager; + UIShutdownGuard* _shutdownGuard; bool _connected; - AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager) : _board(board), _interfaceManager(interfaceManager) { + AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager) + : _board(board), _interfaceManager(interfaceManager), + _shutdownGuard(nullptr) { _connected = false; } + bool prepareForShutdown() { + return _shutdownGuard == nullptr + || _shutdownGuard->prepareForUiShutdown(); + } + bool isDisplayAutoOffDue(unsigned long configured_deadline, unsigned long configured_timeout_millis) const { unsigned long deadline = configured_deadline; @@ -58,6 +72,7 @@ protected: } public: + void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; } void setHasConnection(bool connected) { _connected = connected; } bool hasConnection() const { return _connected; } bool hasBluetoothConnection() const { return _interfaceManager->isBluetoothConnected(); } diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index b12b39fe..f3f8b336 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -4,6 +4,9 @@ #if defined(NRF52_PLATFORM) #include +#if defined(EXTRAFS) && !defined(QSPIFLASH) +#include +#endif #endif // Linked presence of this symbol is the authoritative signal that this firmware actually mounts the @@ -11,6 +14,7 @@ // reserved range even though nrf52_base defines EXTRAFS globally. #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) extern "C" __attribute__((used)) const uint8_t g_meshcore_internal_extrafs = 1u; +extern "C" uint32_t __flash_arduino_end[]; #endif #if defined(EXTRAFS) || defined(QSPIFLASH) @@ -19,7 +23,8 @@ extern "C" __attribute__((used)) const uint8_t g_meshcore_internal_extrafs = 1u; #define MAX_BLOBRECS 20 #endif -DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr), _clock(&clock), +DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr), + _configuredFsExtra(nullptr), _clock(&clock), #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) identity_store(fs, "") #elif defined(RP2040_PLATFORM) @@ -31,7 +36,8 @@ DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra } #if defined(EXTRAFS) || defined(QSPIFLASH) -DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(&fsExtra), _clock(&clock), +DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(&fsExtra), + _configuredFsExtra(&fsExtra), _clock(&clock), #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) identity_store(fs, "") #elif defined(RP2040_PLATFORM) @@ -75,9 +81,10 @@ void DataStore::begin() { primary_ready = recoverPrimaryFilesystem(_fs); } if (_fsExtra != nullptr && !validateLfsFilesystem(_fsExtra)) { - // Do not erase removable/external storage automatically. Keep it intact - // for recovery and boot from the known-good internal filesystem. - MESH_DEBUG_PRINTLN("DataStore: secondary LittleFS metadata is corrupt; using internal storage"); + // A traversal-failed filesystem may still be recoverable offline. Keep + // both removable QSPI and managed on-chip ExtraFS intact until an explicit + // local repair/factory-reset operation authorizes erasing it. + MESH_DEBUG_PRINTLN("DataStore: secondary LittleFS metadata is corrupt; preserving it and using primary storage"); _fsExtra = nullptr; } if (primary_ready) cleanupAtomicTempFiles(_fs); @@ -85,7 +92,9 @@ void DataStore::begin() { resetContactPageState(); #endif #if defined(EXTRAFS) || defined(QSPIFLASH) - if (_fsExtra != nullptr) migrateToSecondaryFS(); + if (_fsExtra != nullptr && !migrateToSecondaryFS()) { + MESH_DEBUG_PRINTLN("DataStore: one or more secondary filesystem migrations remain pending"); + } #endif checkAdvBlobFile(); #else @@ -163,7 +172,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"}; + "/contacts3.tmp", "/contacts4.mig.tmp", "/adv_blobs.tmp", + "/.extrafs.mig.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]); } @@ -181,6 +191,62 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) { if (fs->exists(path)) fs->remove(path); } } + +#if defined(EXTRAFS) && !defined(QSPIFLASH) +bool DataStore::reinitializeInternalExtraFS() { + if (_configuredFsExtra == nullptr) return false; + + CustomLFS* extra = static_cast(_configuredFsExtra); + if (!mesh::storage::isExpectedInternalExtraFsGeometry( + extra->getFlashAddr(), extra->getFlashSize(), + extra->getBlockSize()) + || !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; + } + + const mesh::storage::InternalSecondaryFsRepairResult result = + mesh::storage::repairInternalSecondaryFilesystem( + [extra]() -> bool { + // Avoid CustomLFS::formatRegion(): it probes corrupt metadata with + // open("/") before deciding whether to unmount. end() is safe for + // both mounted and unmounted Adafruit LittleFS instances. + extra->end(); + return extra->format(); + }, + [extra]() -> bool { + // Call the base mount explicitly. CustomLFS::begin() would erase + // the complete region again if this mount failed. + return extra->Adafruit_LittleFS::begin(); + }, + [this]() -> bool { + return validateLfsFilesystem(_configuredFsExtra); + }); + if (result != mesh::storage::InternalSecondaryFsRepairResult::Repaired) { + _fsExtra = nullptr; + switch (result) { + case mesh::storage::InternalSecondaryFsRepairResult::FormatFailed: + MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair format failed"); + break; + case mesh::storage::InternalSecondaryFsRepairResult::MountFailed: + MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair mount failed"); + break; + case mesh::storage::InternalSecondaryFsRepairResult::ValidationFailed: + MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair validation failed"); + break; + default: + break; + } + return false; + } + + _fsExtra = _configuredFsExtra; + MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repaired and reactivated"); + return true; +} +#endif #endif uint32_t DataStore::getStorageUsedKb() const { @@ -252,11 +318,17 @@ bool DataStore::formatFileSystem() { #if defined(NRF52_PLATFORM) resetContactPageState(); #endif - if (_fsExtra == nullptr) { - return _fs->format(); - } else { - return _fs->format() && _fsExtra->format(); - } + const bool primary_success = _fs->format(); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + // 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(); +#else + const bool secondary_success = _fsExtra == nullptr || _fsExtra->format(); +#endif + return primary_success && secondary_success; #elif defined(RP2040_PLATFORM) return LittleFS.format(); #elif defined(ESP32) @@ -268,6 +340,35 @@ bool DataStore::formatFileSystem() { #endif } +bool DataStore::repairInternalExtraFS() { +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + // A healthy active ExtraFS must never be erased. Re-running the explicit + // command is still useful, though: it retries any verified migration which + // failed after a previously repaired filesystem was activated. + if (_fsExtra != nullptr) { + CustomLFS* extra = static_cast(_fsExtra); + if (_fsExtra != _configuredFsExtra + || !mesh::storage::isExpectedInternalExtraFsGeometry( + extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize()) + || !mesh::storage::isInternalExtraFsReservedByApplication( + (uint32_t)(uintptr_t)__flash_arduino_end)) { + MESH_DEBUG_PRINTLN("DataStore: refusing internal ExtraFS migration with unexpected geometry"); + return false; + } + cleanupAtomicTempFiles(_fsExtra); + return migrateToSecondaryFS(); + } + if (!reinitializeInternalExtraFS()) return false; + + cleanupAtomicTempFiles(_fsExtra); + // The fallback primary remains intact until each destination file has been + // copied, read back byte-for-byte, and committed by migrateToSecondaryFS(). + return migrateToSecondaryFS(); +#else + return false; +#endif +} + bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) { return identity_store.load("_main", identity); } @@ -528,6 +629,9 @@ static bool deserializeContactRecord( #if defined(NRF52_PLATFORM) static const char* CONTACT_MIGRATION_MARKER = "/contacts4.mig"; +static const char* SECONDARY_MIGRATION_JOURNAL = "/.extrafs.mig"; +static const uint8_t SECONDARY_MIGRATION_PENDING = 1; +static const uint8_t SECONDARY_MIGRATION_COMMITTED = 2; static void makeContactPagePath(uint8_t page, char path[24]) { snprintf(path, 24, "/contacts4_%02u", (unsigned)page); @@ -1025,19 +1129,19 @@ void DataStore::loadChannels(DataStoreHost* host) { } } -void DataStore::saveChannels(DataStoreHost* host) { +bool DataStore::saveChannels(DataStoreHost* host) { #if defined(NRF52_PLATFORM) mesh::AtomicFileWriter file(_getContactsChannelsFS(), "/channels2"); #else File file = openWrite(_getContactsChannelsFS(), "/channels2"); #endif + bool success = (bool)file; if (file) { uint8_t channel_idx = 0; ChannelDetails ch; uint8_t unused[4]; memset(unused, 0, 4); - bool success = true; while (success && host->getChannelForSave(channel_idx, ch)) { success = (file.write(unused, 4) == 4); success = success && (file.write((uint8_t *)ch.name, 32) == 32); @@ -1047,11 +1151,13 @@ void DataStore::saveChannels(DataStoreHost* host) { channel_idx++; } #if defined(NRF52_PLATFORM) - if (!file.commit(success)) MESH_DEBUG_PRINTLN("DataStore: atomic channels write failed"); + success = file.commit(success); + if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic channels write failed"); #else file.close(); #endif } + return success; } #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -1103,11 +1209,13 @@ void DataStore::checkAdvBlobFile() { #endif } -void DataStore::migrateToSecondaryFS() { - if (_fsExtra == nullptr) return; +bool DataStore::migrateToSecondaryFS() { + if (_fsExtra == nullptr) return false; - // Implemented below through verified copy transactions. Source files are - // removed only after the destination has been read back successfully. + // Implemented below through verified copy transactions. On nRF52, all + // primary contact/channel files are copied before an atomic journal commit; + // no source is removed before that commit makes the complete secondary + // snapshot authoritative across a reset. #if defined(NRF52_PLATFORM) // /adv_blobs is a reconstructable cache retired by checkAdvBlobFile(); do // not spend time and temporary space atomically copying it first. @@ -1122,13 +1230,15 @@ void DataStore::migrateToSecondaryFS() { const char* path) -> bool { File left = openRead(left_fs, path); File right = openRead(right_fs, path); - if (!left || !right || left.size() != right.size()) { + const uint32_t expected_size = left ? left.size() : 0; + if (!left || !right || expected_size != right.size()) { if (left) left.close(); if (right) right.close(); return false; } uint8_t left_buf[64], right_buf[64]; bool equal = true; + uint32_t compared = 0; while (equal) { int left_count = left.read(left_buf, sizeof(left_buf)); int right_count = right.read(right_buf, sizeof(right_buf)); @@ -1140,19 +1250,21 @@ void DataStore::migrateToSecondaryFS() { break; } else if (memcmp(left_buf, right_buf, left_count) != 0) { equal = false; + } else { + compared += (uint32_t)left_count; } } left.close(); right.close(); - return equal; + return equal && compared == expected_size; }; - auto migrate = [this, &filesEqual](FILESYSTEM* source_fs, FILESYSTEM* dest_fs, - const char* path) -> bool { + auto copy = [this, &filesEqual](FILESYSTEM* source_fs, FILESYSTEM* dest_fs, + const char* path) -> bool { if (!source_fs->exists(path)) return true; if (dest_fs->exists(path)) { if (filesEqual(source_fs, dest_fs, path)) { - return source_fs->remove(path); + return true; } MESH_DEBUG_PRINTLN("DataStore: migration conflict for %s; preserving both copies", path); return false; @@ -1212,30 +1324,208 @@ void DataStore::migrateToSecondaryFS() { MESH_DEBUG_PRINTLN("DataStore: verified migration failed for %s", path); return false; } - return source_fs->remove(path); + return true; + }; + + auto move = [©](FILESYSTEM* source_fs, FILESYSTEM* dest_fs, + const char* path) -> bool { + if (!source_fs->exists(path)) return true; + return copy(source_fs, dest_fs, path) && source_fs->remove(path); + }; + + auto migratePrimarySources = [this, ©, &filesEqual]() -> bool { + bool success = true; + for (size_t i = 0; i < sizeof(to_primary) / sizeof(to_primary[0]); i++) { + const char* path = to_primary[i]; + if (!_fsExtra->exists(path)) continue; + + if (_fs->exists(path)) { + if (!filesEqual(_fsExtra, _fs, path)) { + // Identity/preferences are canonical on primary. A differing legacy + // secondary copy is useful forensic data, but it must not prevent + // contact/channel migration or make secondary storage disappear. + MESH_DEBUG_PRINTLN("DataStore: preserving conflicting legacy secondary %s; primary remains authoritative", path); + continue; + } + } else if (!copy(_fsExtra, _fs, path)) { + // Without any primary copy MyMesh would generate a replacement + // identity or persist default preferences, so this is the only + // primary-source condition which must block the storage handoff. + success = false; + continue; + } + + // At this point primary has an exact verified copy. Failure to retire a + // duplicate is harmless and can be retried on a later boot. + if (!_fsExtra->remove(path)) { + MESH_DEBUG_PRINTLN("DataStore: could not retire verified legacy secondary %s", path); + } + } + return success; }; - for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) { - migrate(_fs, _fsExtra, to_secondary[i]); - } - // Also move the bounded nRF v4 page/bucket files. This matters after a boot - // where external QSPI was unavailable and the store deliberately fell back - // to internal flash. #if defined(NRF52_PLATFORM) - for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { - char path[24]; - makeContactPagePath(page, path); - migrate(_fs, _fsExtra, path); + // Identity and preferences are always read from the primary filesystem. + // Recover legacy/test-layout copies before the contact/channel transaction + // can take an early fallback return; otherwise MyMesh may generate a new + // identity or persist default preferences while the real copy is stranded + // on the now-disabled secondary filesystem. + if (!migratePrimarySources()) { + MESH_DEBUG_PRINTLN("DataStore: primary identity/preferences migration failed; using primary storage"); + _fsExtra = nullptr; + return false; } - for (uint8_t bucket = 0; bucket < 10; bucket++) { - char path[20]; - snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket); - migrate(_fs, _fsExtra, path); + + enum class MigrationJournalState : uint8_t { + None, + Pending, + Committed, + Invalid, + }; + static const uint8_t journal_magic[] = {'M', 'C', 'X', 'F', 1}; + + auto readJournal = [this]() -> MigrationJournalState { + if (!_fsExtra->exists(SECONDARY_MIGRATION_JOURNAL)) { + return MigrationJournalState::None; + } + File journal = openRead(_fsExtra, SECONDARY_MIGRATION_JOURNAL); + uint8_t payload[sizeof(journal_magic) + 1]; + const bool valid_size = journal && journal.size() == sizeof(payload); + const int count = valid_size ? journal.read(payload, sizeof(payload)) : 0; + if (journal) journal.close(); + if (count != (int)sizeof(payload) + || memcmp(payload, journal_magic, sizeof(journal_magic)) != 0) { + return MigrationJournalState::Invalid; + } + if (payload[sizeof(journal_magic)] == SECONDARY_MIGRATION_PENDING) { + return MigrationJournalState::Pending; + } + if (payload[sizeof(journal_magic)] == SECONDARY_MIGRATION_COMMITTED) { + return MigrationJournalState::Committed; + } + return MigrationJournalState::Invalid; + }; + + auto writeJournal = [this](uint8_t state) -> bool { + uint8_t payload[sizeof(journal_magic) + 1]; + memcpy(payload, journal_magic, sizeof(journal_magic)); + payload[sizeof(journal_magic)] = state; + mesh::AtomicFileWriter journal(_fsExtra, SECONDARY_MIGRATION_JOURNAL); + return journal + && journal.write(payload, sizeof(payload)) == sizeof(payload) + && journal.commit(); + }; + + auto hasSecondarySources = [this]() -> bool { + for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) { + if (_fs->exists(to_secondary[i])) return true; + } + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + if (_fs->exists(path)) return true; + } + for (uint8_t bucket = 0; bucket < 10; bucket++) { + char path[20]; + snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket); + if (_fs->exists(path)) return true; + } + return false; + }; + + auto copySecondarySources = [this, ©]() -> bool { + bool success = true; + for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) { + if (!copy(_fs, _fsExtra, to_secondary[i])) success = false; + } + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + if (!copy(_fs, _fsExtra, path)) success = false; + } + for (uint8_t bucket = 0; bucket < 10; bucket++) { + char path[20]; + snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket); + if (!copy(_fs, _fsExtra, path)) success = false; + } + return success; + }; + + auto retireSecondarySources = [this]() -> bool { + bool success = true; + auto retire = [this, &success](const char* path) { + if (!_fs->exists(path)) return; + // The committed journal is the authority switch: every destination was + // already verified before it was written. Do not compare again here, + // because the secondary may legitimately advance while cleanup retries. + if (!_fs->remove(path)) success = false; + }; + for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) { + retire(to_secondary[i]); + } + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + retire(path); + } + for (uint8_t bucket = 0; bucket < 10; bucket++) { + char path[20]; + snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket); + retire(path); + } + return success; + }; + + MigrationJournalState journal_state = readJournal(); + if (journal_state == MigrationJournalState::Invalid) { + MESH_DEBUG_PRINTLN("DataStore: invalid ExtraFS migration journal; using primary storage"); + _fsExtra = nullptr; + return false; } + + if (journal_state != MigrationJournalState::Committed + && (journal_state != MigrationJournalState::None || hasSecondarySources())) { + if (journal_state == MigrationJournalState::None + && !writeJournal(SECONDARY_MIGRATION_PENDING)) { + MESH_DEBUG_PRINTLN("DataStore: could not start ExtraFS migration transaction"); + _fsExtra = nullptr; + return false; + } + if (!copySecondarySources() + || !writeJournal(SECONDARY_MIGRATION_COMMITTED)) { + // Pending means no source has been retired, so primary remains a complete + // fallback even if the destination contains harmless partial copies. + MESH_DEBUG_PRINTLN("DataStore: ExtraFS migration copy incomplete; using primary storage"); + _fsExtra = nullptr; + return false; + } + journal_state = MigrationJournalState::Committed; + } + + if (journal_state == MigrationJournalState::Committed) { + if (!retireSecondarySources()) { + // The committed secondary contains the full verified snapshot. Keep it + // active and keep the journal so cleanup can resume without copying a + // stale primary source over newer secondary data. + MESH_DEBUG_PRINTLN("DataStore: ExtraFS migration committed; source cleanup remains pending"); + return false; + } + if (_fsExtra->exists(SECONDARY_MIGRATION_JOURNAL) + && !_fsExtra->remove(SECONDARY_MIGRATION_JOURNAL)) { + MESH_DEBUG_PRINTLN("DataStore: ExtraFS migration journal cleanup failed"); + return false; + } + } +#else + bool success = true; + for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) { + if (!move(_fs, _fsExtra, to_secondary[i])) success = false; + } + if (!success) return false; + return migratePrimarySources(); #endif - for (size_t i = 0; i < sizeof(to_primary) / sizeof(to_primary[0]); i++) { - migrate(_fsExtra, _fs, to_primary[i]); - } + + return true; } #if defined(NRF52_PLATFORM) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 9bfec94c..adc1a2ef 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -18,6 +18,10 @@ public: class DataStore { FILESYSTEM* _fs; FILESYSTEM* _fsExtra; + // Keep the configured secondary even when normal I/O falls back to the + // primary filesystem. An explicit local repair/factory reset must still be + // able to address the inactive on-chip ExtraFS. + FILESYSTEM* _configuredFsExtra; mesh::RTCClock* _clock; IdentityStore identity_store; @@ -35,6 +39,9 @@ class DataStore { bool (*filter)(const ContactInfo& c)); bool truncateLegacyContacts(uint16_t remaining_contacts); void resetContactPageState(); +#if defined(EXTRAFS) && !defined(QSPIFLASH) + bool reinitializeInternalExtraFS(); +#endif #endif void loadPrefsInt(const char *filename, CompanionNodePrefs& prefs, double& node_lat, double& node_lon); @@ -47,6 +54,7 @@ public: DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock); void begin(); bool formatFileSystem(); + bool repairInternalExtraFS(); FILESYSTEM* getPrimaryFS() const { return _fs; } FILESYSTEM* getSecondaryFS() const { return _fsExtra; } void disableSecondaryFS() { _fsExtra = nullptr; } @@ -62,8 +70,8 @@ public: bool flushContactWrites(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); bool hasPendingContactWrites() const; void loadChannels(DataStoreHost* host); - void saveChannels(DataStoreHost* host); - void migrateToSecondaryFS(); + bool saveChannels(DataStoreHost* host); + bool migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len); bool deleteBlobByKey(const uint8_t key[], int key_len); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f03ad1a6..8e6c9334 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -1508,6 +1509,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe sign_data_reply_route = NULL; sign_data_deadline = 0; dirty_contacts_expiry = 0; + dirty_contacts_failures = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); send_unscoped = false; @@ -1574,6 +1576,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _webconfig = nullptr; _wc_mqtt_dirty = false; #endif + if (_ui != nullptr) _ui->setShutdownGuard(this); } void MyMesh::begin(bool has_display, bool radio_available) { @@ -1724,7 +1727,8 @@ void MyMesh::begin(bool has_display, bool radio_available) { resetContacts(); _store->loadContacts(this); if (_store->hasPendingContactWrites()) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline( + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); } updateGpsTelemetryPolicy(); bootstrapRTCfromContacts(); @@ -2876,7 +2880,11 @@ bool MyMesh::isWebConfigWiFiRecoveryActive() const { } void MyMesh::rebootNow() { - board.reboot(); + if (flushContactsBeforeReboot()) { + board.reboot(); + } else { + MESH_DEBUG_PRINTLN("Reboot refused: pending contacts could not be saved"); + } } void MyMesh::onConfigBatchStart() { @@ -4008,14 +4016,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeOKFrame(); } } else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) { - // Non-nRF stores use the legacy monolithic file and therefore do not - // report dirty pages. The lazy-write timer is still proof that RAM holds - // newer contact data which must be persisted before rebooting. - if (dirty_contacts_expiry || _store->hasPendingContactWrites()) { - if (!_store->flushContactWrites(this, save_filter)) { - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - return; - } + if (!flushContactsBeforeReboot()) { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + return; } board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { @@ -4042,6 +4045,10 @@ void MyMesh::handleCmdFrame(size_t len) { #if ENABLE_PRIVATE_KEY_IMPORT if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key + } else if (!flushContactsBeforeReboot()) { + // loadContacts() resets the dirty-page map, so never replace the + // identity and reload while newer contact state exists only in RAM. + writeErrFrame(ERR_CODE_FILE_IO_ERROR); } else { mesh::LocalIdentity identity; identity.readFrom(&cmd_frame[1], 64); @@ -4051,7 +4058,14 @@ void MyMesh::handleCmdFrame(size_t len) { // re-load contacts, to invalidate ecdh shared_secrets stopContactsIterator(); resetContacts(); + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); _store->loadContacts(this); + if (_store->hasPendingContactWrites()) { + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, dirty_contacts_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); + } updateGpsTelemetryPolicy(); } else { writeErrFrame(ERR_CODE_FILE_IO_ERROR); @@ -4819,22 +4833,111 @@ static bool save_filter(const ContactInfo& c) { void MyMesh::saveContacts() { const bool success = _store->saveContacts(this, save_filter); - dirty_contacts_expiry = (!success || _store->hasPendingContactWrites()) - ? futureMillis(1000) : 0; + if (!success) { + scheduleContactWriteRetry(); + } else if (_store->hasPendingContactWrites()) { + dirty_contacts_failures = 0; + dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline( + futureMillis(CONTACT_PAGE_WRITE_GAP)); + } else { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); + } +} + +void MyMesh::scheduleContactWriteRetry() { + const uint32_t retry_delay = mesh::recordLazyPersistenceSaveFailure( + dirty_contacts_failures, LAZY_CONTACTS_WRITE_DELAY, + mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS); + mesh::completeLazyPersistenceSave( + dirty_contacts_expiry, false, futureMillis(retry_delay)); + MESH_DEBUG_PRINTLN("Contact persistence failed; retry in %lu ms", + (unsigned long)retry_delay); +} + +bool MyMesh::isContactWriteDue() const { + // Read the clock once. Two separate reads can straddle the exact deadline + // and make both equality and the library's strict-after test return false. + const uint32_t now = _ms->getMillis(); + return dirty_contacts_expiry != 0 + && (int32_t)(now - dirty_contacts_expiry) >= 0; +} + +bool MyMesh::flushContactsBeforeReboot() { + // Non-nRF stores use the legacy monolithic file and therefore do not report + // dirty pages. The lazy-write deadline still proves that RAM may be newer. + if (!dirty_contacts_expiry && !_store->hasPendingContactWrites()) return true; + if (!_store->flushContactWrites(this, save_filter)) { + scheduleContactWriteRetry(); + return false; + } + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); + return true; +} + +bool MyMesh::prepareForOtaReboot() { + // Once a write has failed, honor its bounded retry deadline instead of + // letting the armed OTA apply hammer the same filesystem every loop. + if (dirty_contacts_failures != 0 && dirty_contacts_expiry != 0 + && !isContactWriteDue()) { + return false; + } + return flushContactsBeforeReboot(); +} + +bool MyMesh::prepareForUiShutdown() { + return flushContactsBeforeReboot(); } void MyMesh::scheduleContactWrite(const ContactInfo& contact) { if (_store->markContactDirty(contact)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, dirty_contacts_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); } } void MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact) { if (_store->releaseContact(contact)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, dirty_contacts_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); } } +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) +void MyMesh::repairInternalExtraFS(Stream& output) { + // Always run the store-level operation. When the 100 KiB ExtraFS is already + // active this is a non-destructive retry of any incomplete migration, not a + // format. Do not overwrite it from RAM until every authoritative source file + // has been copied, verified, and retired from primary storage. + if (!_store->repairInternalExtraFS()) { + output.println(" Error: internal ExtraFS repair/migration failed; retry repair extrafs"); + return; + } + + const bool contacts_saved = _store->saveContacts(this, save_filter); + const bool channels_saved = saveChannels(); + const bool capacity_ready = _store->getStorageTotalKb() == 100; + if (contacts_saved && channels_saved && capacity_ready + && !_store->hasPendingContactWrites()) { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); + output.printf(" > internal ExtraFS repaired (%lu KiB); contacts and channels rebuilt\n", + (unsigned long)_store->getStorageTotalKb()); + } else { + if (!contacts_saved || _store->hasPendingContactWrites()) { + scheduleContactWriteRetry(); + } else { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); + } + output.println(" Error: ExtraFS is active, but data rebuild/verification failed; retry repair extrafs"); + } +} +#endif + bool MyMesh::applyAndSaveFemRxGain(bool enabled) { if (!board.canControlLoRaFemLna()) return false; @@ -6347,6 +6450,10 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strcmp(command, "reboot") == 0) { terminalOutput().print(" OK - rebooting in 1 second\r\n"); _scheduled_reboot_at = futureMillis(1000); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + } else if (strcmp(command, "repair extrafs") == 0) { + repairInternalExtraFS(terminalOutput()); +#endif } else if (strcmp(command, "ver") == 0) { terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n", FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE); @@ -6421,6 +6528,9 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" public \r\n"); terminalOutput().print(" channels\r\n"); terminalOutput().print(" channel \r\n"); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + terminalOutput().print(" repair extrafs (erases/rebuilds internal ExtraFS)\r\n"); +#endif #if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI #if COMPANION_FEATURE_TEMP_RADIO terminalOutput().print(" tempradio [freq,bw,sf,cr,minutes]\r\n"); @@ -6631,14 +6741,33 @@ void MyMesh::checkCLIRescueCmd() { if (handleCommand(cli_command, 0, reply_buf)) { // command was handled, print reply output output.print(" "); output.print(reply_buf); output.println(); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + } else if (strcmp(cli_command, "repair extrafs") == 0) { + repairInternalExtraFS(output); +#endif } else if (strcmp(cli_command, "rebuild") == 0) { bool success = _store->formatFileSystem(); if (success) { - _store->saveMainIdentity(self_id); - savePrefs(); - saveContacts(); - saveChannels(); - output.println(" > erase and rebuild done"); + const bool identity_saved = _store->saveMainIdentity(self_id); + const bool prefs_saved = savePrefs(); + const bool contacts_saved = _store->saveContacts(this, save_filter); + const bool channels_saved = saveChannels(); + const bool contacts_complete = + contacts_saved && !_store->hasPendingContactWrites(); + + if (contacts_complete) { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); + } else { + scheduleContactWriteRetry(); + } + + if (identity_saved && prefs_saved && contacts_complete + && channels_saved) { + output.println(" > erase and rebuild done"); + } else { + output.println(" Error: erase succeeded, but data rebuild failed; retry rebuild"); + } } else { output.println(" Error: erase failed"); } @@ -6717,24 +6846,33 @@ void MyMesh::checkCLIRescueCmd() { return; } - // log file content as hex - File file = _store->openRead(path); - if (is_fs2 == true) { - file = _store->openRead(_store->getSecondaryFS(), path); - } - if(file){ + // A corrupt internal ExtraFS is deliberately quarantined until the + // explicit repair command runs. Never pass that null secondary pointer + // into the raw filesystem helpers while it is unavailable. + FILESYSTEM* selected_fs = is_fs2 + ? _store->getSecondaryFS() + : _store->getPrimaryFS(); + if (selected_fs == nullptr) { + output.println("ExtraFS is unavailable; run repair extrafs first"); + } else { + // log file content as hex + File file = _store->openRead(selected_fs, path); + if(file){ - // get file content - int file_size = file.available(); - uint8_t buffer[file_size]; - file.read(buffer, file_size); + // Stream the dump in bounded chunks. An ExtraFS file can be much + // larger than the nRF52 task stack, so never allocate file.size() + // bytes on the stack here. + uint8_t buffer[64]; + while (file.available() > 0) { + const int count = file.read(buffer, sizeof(buffer)); + if (count <= 0) break; + mesh::Utils::printHex(output, buffer, count); + } + output.print("\n"); - // print hex - mesh::Utils::printHex(output, buffer, file_size); - output.print("\n"); - - file.close(); + file.close(); + } } } else if (memcmp(cli_command, "rm ", 3) == 0) { @@ -6754,24 +6892,32 @@ void MyMesh::checkCLIRescueCmd() { } // remove file - bool removed; - if (is_fs2) { - MESH_DEBUG_PRINTLN("Removing file from ExtraFS: %s", path); - removed = _store->removeFile(_store->getSecondaryFS(), path); + FILESYSTEM* selected_fs = is_fs2 + ? _store->getSecondaryFS() + : _store->getPrimaryFS(); + if (selected_fs == nullptr) { + output.println("ExtraFS is unavailable; run repair extrafs first"); } else { - MESH_DEBUG_PRINTLN("Removing file from UserData: %s", path); - removed = _store->removeFile(path); - } - if(removed){ - output.println("File removed"); - } else { - output.println("Failed to remove file"); + if (is_fs2) { + MESH_DEBUG_PRINTLN("Removing file from ExtraFS: %s", path); + } else { + MESH_DEBUG_PRINTLN("Removing file from UserData: %s", path); + } + if (_store->removeFile(selected_fs, path)) { + output.println("File removed"); + } else { + output.println("Failed to remove file"); + } } } } else if (strcmp(cli_command, "reboot") == 0) { - board.reboot(); // doesn't return + if (flushContactsBeforeReboot()) { + board.reboot(); // doesn't return + } else { + output.println(" Error: pending contacts could not be saved; reboot cancelled"); + } } else { output.println(" Error: unknown command"); } @@ -6817,9 +6963,14 @@ void MyMesh::loop() { if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection(); #endif if (_scheduled_reboot_at != 0 - && millisHasNowPassed(_scheduled_reboot_at)) { + && (_scheduled_reboot_at == _ms->getMillis() + || millisHasNowPassed(_scheduled_reboot_at))) { _scheduled_reboot_at = 0; - board.reboot(); + if (flushContactsBeforeReboot()) { + board.reboot(); + } else { + MESH_DEBUG_PRINTLN("Scheduled reboot cancelled: pending contacts could not be saved"); + } return; } #if COMPANION_FEATURE_TEMP_RADIO @@ -6874,12 +7025,17 @@ void MyMesh::loop() { servicePendingRadioParamApply(); // is there are pending dirty contacts write needed? - if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { + if (isContactWriteDue()) { const bool success = _store->serviceContactWrites(this, save_filter); - if (!success || _store->hasPendingContactWrites()) { - dirty_contacts_expiry = futureMillis(success ? CONTACT_PAGE_WRITE_GAP : 1000); + if (!success) { + scheduleContactWriteRetry(); + } else if (_store->hasPendingContactWrites()) { + dirty_contacts_failures = 0; + dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline( + futureMillis(CONTACT_PAGE_WRITE_GAP)); } else { - dirty_contacts_expiry = 0; + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, dirty_contacts_failures); } } @@ -6908,16 +7064,30 @@ bool MyMesh::hasPendingWork() const { if (_radio_available && (radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor())) return true; +#if defined(NRF52_PLATFORM) + // nRF52 sleep is event-driven, so a future lazy-write deadline has no + // guaranteed interrupt to wake the loop when it becomes due. Keep polling + // through the initial write. After an actual storage failure, allow sleep + // during the bounded backoff; the next BLE/radio/GPIO event services an + // overdue retry without losing the dirty-page state. + const bool contact_write_needs_polling = dirty_contacts_expiry != 0 + && (dirty_contacts_failures == 0 + || isContactWriteDue()); +#else + const bool contact_write_needs_polling = isContactWriteDue(); +#endif return (_serial != NULL && _serial->hasPendingIO()) || (_iter_started && _serial != NULL && _serial->isConnected()) + || hasPendingOtaApply() || command_radio_apply_pending + || _scheduled_reboot_at != 0 || pending_serial_reply_route != NULL || binary_trace_pending || sign_data != NULL || hasQueuedWorkDue() || hasRetryWorkDue() || (saved_radio_apply_pending && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) - || (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry)) + || contact_write_needs_polling || (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) #if COMPANION_FEATURE_TEMP_RADIO diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b517334b..005f0524 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -117,7 +117,7 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; -class MyMesh : public BaseChatMesh, public DataStoreHost +class MyMesh : public BaseChatMesh, public DataStoreHost, public UIShutdownGuard #ifdef ENABLE_USB_INTERFACE , public ContactVisitor #endif @@ -414,10 +414,18 @@ private: #endif // helpers, short-cuts - void saveChannels() { _store->saveChannels(this); } + bool saveChannels() { return _store->saveChannels(this); } void saveContacts(); + void scheduleContactWriteRetry(); + bool isContactWriteDue() const; + bool flushContactsBeforeReboot(); + bool prepareForOtaReboot() override; + bool prepareForUiShutdown() override; void scheduleContactWrite(const ContactInfo& contact); void scheduleContactWriteAfterRelease(const ContactInfo& contact); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + void repairInternalExtraFS(Stream& output); +#endif DataStore* _store; CompanionNodePrefs _prefs; @@ -513,6 +521,7 @@ private: BaseSerialInterface* sign_data_reply_route; unsigned long sign_data_deadline; unsigned long dirty_contacts_expiry; + uint8_t dirty_contacts_failures; TransportKey send_scope; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f1683e2b..1a78c5fb 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -162,6 +162,8 @@ static bool isNetworkTerminalActive(); #else #if defined(EXTRAFS) #include + #include + extern "C" uint32_t __flash_arduino_end[]; CustomLFS ExtraFS(0xD4000, 0x19000, 128); DataStore store(InternalFS, ExtraFS, rtc_clock); #else @@ -2090,7 +2092,50 @@ void setup() { } #else #if defined(EXTRAFS) - ExtraFS.begin(); + const bool extra_fs_geometry_valid = + mesh::storage::isExpectedInternalExtraFsGeometry( + ExtraFS.getFlashAddr(), ExtraFS.getFlashSize(), + ExtraFS.getBlockSize()) + && mesh::storage::isInternalExtraFsReservedByApplication( + (uint32_t)(uintptr_t)__flash_arduino_end); + mesh::storage::InternalSecondaryFsBootResult extra_fs_boot = + mesh::storage::InternalSecondaryFsBootResult::InitializationFailed; + if (extra_fs_geometry_valid) { + extra_fs_boot = mesh::storage::prepareInternalSecondaryFilesystem( + []() -> bool { + // Bypass CustomLFS::begin(), whose mount-failure path erases + // nonblank media before DataStore can quarantine it. + return ExtraFS.Adafruit_LittleFS::begin(); + }, + []() -> bool { + const volatile uint32_t* word = + reinterpret_cast( + mesh::storage::INTERNAL_EXTRAFS_START); + for (uint32_t offset = 0; + offset < mesh::storage::INTERNAL_EXTRAFS_SIZE; + offset += sizeof(uint32_t), word++) { + if (*word != 0xFFFFFFFFUL) return false; + } + return true; + }, + []() -> bool { + ExtraFS.end(); + return ExtraFS.format(); + }); + } + if (!extra_fs_geometry_valid + || extra_fs_boot == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank + || extra_fs_boot == mesh::storage::InternalSecondaryFsBootResult::InitializationFailed) { + if (!extra_fs_geometry_valid) { + MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS geometry is not 100 KiB; refusing to mount or erase it"); + } else if (extra_fs_boot + == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank) { + MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS mount failed; preserving nonblank media and using primary storage"); + } else { + MESH_DEBUG_PRINTLN("CustomLFS: blank internal ExtraFS initialization failed; using primary storage"); + } + store.disableSecondaryFS(); + } #endif #endif store.begin(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b8b45267..c752700f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1706,6 +1706,7 @@ void UITask::servicePairingState() { hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ + if (!prepareForShutdown()) return; #ifdef PIN_BUZZER /* note: we have a choice here - diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index c4bbea80..bb771ec6 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -440,6 +440,7 @@ void UITask::userLedHandler() { hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ + if (!prepareForShutdown()) return; #ifdef PIN_BUZZER /* note: we have a choice here - diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index d6e497c8..dc8f9c5c 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -618,6 +618,7 @@ void UITask::servicePairingState() { hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ + if (!prepareForShutdown()) return; #ifdef PIN_BUZZER /* note: we have a choice here - diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0c78209c..8d9f6eb6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -12566,6 +12566,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { // To check if there is pending work bool MyMesh::hasPendingWork() const { + if (hasPendingOtaApply()) return true; if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return true; #if defined(WITH_BRIDGE) const AbstractBridge* active_bridge = activeBridge(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index bbd03f66..aebf1a90 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2678,6 +2678,7 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { // To check if there is pending work bool MyMesh::hasPendingWork() const { + if (hasPendingOtaApply()) return true; #if defined(WITH_BRIDGE) if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 8cc7d0db..d21a6441 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1358,6 +1358,7 @@ uint32_t SensorMesh::limitSleepToMillisTimer(unsigned long timestamp, } bool SensorMesh::hasPendingWork() const { + if (hasPendingOtaApply()) return true; if (_cli.hasActiveUserGpioTimer()) return true; if (radio_driver.isWatchdogObserving()) return true; if (radio_driver.isCalibratingNoiseFloor()) return true; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 89f0a9b6..6b3364d3 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -277,6 +277,14 @@ void Mesh::loop() { #endif } +bool Mesh::hasPendingOtaApply() const { +#if defined(ENABLE_OTA) && !defined(OTA_SEEDER_ONLY) + return ota::ota_ctx().apply_pending; +#else + return false; +#endif +} + void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { if (_waiting_direct_retry_count != 0 && millisHasNowPassed(_next_direct_retry_timeout)) { @@ -335,8 +343,17 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { oc.apply_hard = futureMillis(15000); } else if (millisHasNowPassed(oc.apply_at) && (_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) { - if (oc.bootloader_apply_pending) ota::ota_reboot_to_bootloader_update(); - else ota::ota_reboot_to_apply(); + if (!prepareForOtaReboot()) { + // A role-specific persistence flush failed or is in bounded + // backoff. Keep the verified apply armed without hammering storage + // on every pass through the event loop. + oc.apply_at = futureMillis(1000); + oc.apply_hard = futureMillis(15000); + } else if (oc.bootloader_apply_pending) { + ota::ota_reboot_to_bootloader_update(); + } else { + ota::ota_reboot_to_apply(); + } } } } diff --git a/src/Mesh.h b/src/Mesh.h index e74ebd9a..1e951c54 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -190,6 +190,13 @@ class Mesh : public Dispatcher { DispatcherAction forwardMultipartDirect(Packet* pkt); protected: + // Roles with lazily persisted state can veto an OTA reset until that state + // is durable. The OTA apply remains armed and is retried later. + virtual bool prepareForOtaReboot() { return true; } + // Power-saving roles must stay runnable while the deferred OTA reboot grace + // period (or a persistence retry) is pending. On nRF52, board.sleep() has no + // timer wake and could otherwise strand an already-approved update. + bool hasPendingOtaApply() const; DispatcherAction onRecvPacket(Packet* pkt) override; void onTracePacketQueuedForSend(Packet* packet) override; void onSendComplete(Packet* packet) override; diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 080523ef..659e9b58 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -1143,10 +1143,14 @@ void WebConfigServer::tick(uint32_t now) { } if (WebConfigBatch::rebootDue(_reboot_at, now)) { + // rebootNow() can return when its pre-reboot persistence flush fails. + // Consume this request first so a full filesystem cannot turn tick() into + // a tight loop of repeated flush attempts. + _reboot_at = 0; mesh::usbLoggingPort().printf( "WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback"); - _cb->rebootNow(); // does not return + _cb->rebootNow(); } if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) { diff --git a/src/helpers/nrf52/InternalSecondaryFsRepair.h b/src/helpers/nrf52/InternalSecondaryFsRepair.h new file mode 100644 index 00000000..f9e04a71 --- /dev/null +++ b/src/helpers/nrf52/InternalSecondaryFsRepair.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +namespace mesh { +namespace storage { + +static const uint32_t INTERNAL_EXTRAFS_START = 0xD4000UL; +static const uint32_t INTERNAL_EXTRAFS_SIZE = 0x19000UL; +static const uint32_t INTERNAL_EXTRAFS_BLOCK_SIZE = 128UL; + +inline bool isExpectedInternalExtraFsGeometry(uint32_t start, uint32_t size, + uint32_t block_size) { + return start == INTERNAL_EXTRAFS_START + && size == INTERNAL_EXTRAFS_SIZE + && block_size == INTERNAL_EXTRAFS_BLOCK_SIZE; +} + +inline bool isInternalExtraFsReservedByApplication(uint32_t application_end) { + return application_end <= INTERNAL_EXTRAFS_START; +} + +enum class InternalSecondaryFsBootResult { + Mounted, + InitializedBlank, + PreservedNonBlank, + InitializationFailed, +}; + +// CustomLFS::begin() automatically formats after a failed mount. Use the base +// non-formatting mount through this policy instead: an erased factory region +// may be initialized, while nonblank media is preserved for explicit repair. +template +InternalSecondaryFsBootResult prepareInternalSecondaryFilesystem( + Mount mount, IsBlank is_blank, Format format) { + if (mount()) return InternalSecondaryFsBootResult::Mounted; + if (!is_blank()) return InternalSecondaryFsBootResult::PreservedNonBlank; + if (!format() || !mount()) { + return InternalSecondaryFsBootResult::InitializationFailed; + } + return InternalSecondaryFsBootResult::InitializedBlank; +} + +enum class InternalSecondaryFsRepairResult { + Repaired, + FormatFailed, + MountFailed, + ValidationFailed, +}; + +// Repair is deliberately separate from the normal boot path. A filesystem +// which mounts but fails a full metadata traversal may still be forensically +// recoverable, so erasing it requires an explicit local operator action. +template +InternalSecondaryFsRepairResult repairInternalSecondaryFilesystem( + Format format, Mount mount, Validate validate) { + if (!format()) return InternalSecondaryFsRepairResult::FormatFailed; + if (!mount()) return InternalSecondaryFsRepairResult::MountFailed; + if (!validate()) return InternalSecondaryFsRepairResult::ValidationFailed; + return InternalSecondaryFsRepairResult::Repaired; +} + +} // namespace storage +} // namespace mesh diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index d17dde3d..5a33b4c5 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -87,12 +87,10 @@ bool ota_apply_verify_slot(ApplyState& st) { return st.slot_ok; } -bool ota_apply_commit() { +bool ota_apply_arm() { const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr); if (!p) return false; - if (esp_ota_set_boot_partition(p) != ESP_OK) return false; - esp_restart(); // does not return - return true; + return esp_ota_set_boot_partition(p) == ESP_OK; } // --- detools callback context ----------------------------------------------------------------- @@ -457,7 +455,7 @@ static bool parse_inplace_patch_dims(const uint8_t* payload, uint32_t payload_le bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; } bool ota_apply_verify_slot(ApplyState&) { return false; } -bool ota_apply_commit() { return false; } +bool ota_apply_arm() { return false; } bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "use ota_apply_mota_nrf52"); return false; } void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return) @@ -1092,7 +1090,7 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; } bool ota_apply_verify_slot(ApplyState&) { return false; } -bool ota_apply_commit() { return false; } +bool ota_apply_arm() { return false; } bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; } bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; } bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, const uint8_t*, diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 7b1625ac..0423fb78 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -54,7 +54,10 @@ bool ota_apply_slot_info(uint32_t* addr, uint32_t* size); // the bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowlist& allow, ApplyState& st); // parse + verify signature bool ota_apply_verify_slot(ApplyState& st); // hash the slot vs image_hash -bool ota_apply_commit(); // set-boot + reboot (no return) +// Select the verified A/B slot for the next boot without rebooting inline. The +// caller must arm OtaContext::apply_pending so Mesh can drain the command reply +// and run its role-specific persistence guard before ota_reboot_to_apply(). +bool ota_apply_arm(); // Apply an ESP32 A/B `.mota` (CODEC_DETOOLS_SEQUENTIAL delta or CODEC_FULL image) and arm the inactive // slot; the caller reboots after the confirmation reply. `msg` (>=80 bytes) receives a human-readable diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index f3f8a84d..bae1b490 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -1160,8 +1160,15 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { sprintf(reply, "slot image_hash %s (size=%u)", ok ? "MATCH" : "MISMATCH", (unsigned)c.apply_st.image_size); } else if (strncmp(sub, "commit", 6) == 0) { if (!c.apply_st.slot_ok) { strcpy(reply, "ERR run 'ota dev apply verify' first (slot must match)"); return true; } - ota_apply_commit(); // set boot partition + reboot; no return - strcpy(reply, "ERR commit failed (no A/B slot?)"); + if (c.apply_pending) { strcpy(reply, "ERR update is already armed; reboot is pending"); return true; } + if (!ota_apply_arm()) { strcpy(reply, "ERR commit failed (no A/B slot?)"); return true; } + // Use the same deferred path as ordinary installs: it lets this reply + // drain, then Mesh invokes prepareForOtaReboot() before the reset. + c.bootloader_apply_pending = false; + c.apply_at = 0; + c.apply_hard = 0; + c.apply_pending = true; + strcpy(reply, "OK slot armed; guarded reboot pending"); } else { strcpy(reply, "ERR ota dev apply (slot|manifest|verify|commit)"); } diff --git a/test/test_companion_contact_sleep_contract.py b/test/test_companion_contact_sleep_contract.py new file mode 100644 index 00000000..c3d28b1f --- /dev/null +++ b/test/test_companion_contact_sleep_contract.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Integration guards for Companion contact writes across event-only sleep.""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + opening_brace = source.index("{", start) + depth = 0 + for position in range(opening_brace, len(source)): + if source[position] == "{": + depth += 1 + elif source[position] == "}": + depth -= 1 + if depth == 0: + return source[opening_brace + 1 : position] + raise AssertionError(f"unterminated function: {signature}") + + +class CompanionContactSleepContractTest(unittest.TestCase): + def test_initial_write_polls_but_failed_backoff_can_sleep(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + body = function_body(mesh, "bool MyMesh::hasPendingWork() const") + + nrf_start = body.index("#if defined(NRF52_PLATFORM)") + other_start = body.index("#else", nrf_start) + conditional_end = body.index("#endif", other_start) + nrf = body[nrf_start:other_start] + other = body[other_start:conditional_end] + + self.assertRegex( + nrf, + r"contact_write_needs_polling\s*=\s*" + r"dirty_contacts_expiry\s*!=\s*0\s*" + r"&&\s*\(dirty_contacts_failures\s*==\s*0\s*" + r"\|\|\s*isContactWriteDue\s*\(\s*\)\)", + ) + self.assertRegex( + other, + r"contact_write_needs_polling\s*=\s*isContactWriteDue\s*\(\s*\)", + ) + self.assertRegex(body, r"\|\|\s*contact_write_needs_polling") + + def test_contact_write_is_serviced_only_after_its_deadline(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + body = function_body(mesh, "void MyMesh::loop()") + + self.assertIn("if (isContactWriteDue())", body) + self.assertIn("_store->serviceContactWrites(this, save_filter)", body) + + due = function_body(mesh, "bool MyMesh::isContactWriteDue() const") + self.assertEqual(due.count("_ms->getMillis()"), 1) + self.assertIn("(int32_t)(now - dirty_contacts_expiry) >= 0", due) + + def test_repeated_mutations_do_not_postpone_first_write(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + + for signature in ( + "void MyMesh::scheduleContactWrite(const ContactInfo& contact)", + "void MyMesh::scheduleContactWriteAfterRelease(" + "const ContactInfo& contact)", + ): + body = function_body(mesh, signature) + self.assertIn("mesh::scheduleLazyPersistenceMutation(", body) + self.assertIn("dirty_contacts_failures", body) + self.assertIn("futureMillis(LAZY_CONTACTS_WRITE_DELAY)", body) + self.assertNotRegex( + body, + r"dirty_contacts_expiry\s*=\s*" + r"futureMillis\s*\(\s*LAZY_CONTACTS_WRITE_DELAY\s*\)", + ) + + def test_every_direct_contact_rearm_preserves_zero_sentinel(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + begin = function_body(mesh, "void MyMesh::begin(") + save = function_body(mesh, "void MyMesh::saveContacts()") + loop = function_body(mesh, "void MyMesh::loop()") + + for body in (begin, save, loop): + self.assertIn("mesh::nonzeroLazyPersistenceDeadline(", body) + + self.assertIn("futureMillis(CONTACT_PAGE_WRITE_GAP)", loop) + + def test_failed_writes_back_off_until_a_success_resets_state(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + save = function_body(mesh, "void MyMesh::saveContacts()") + loop = function_body(mesh, "void MyMesh::loop()") + retry = function_body(mesh, "void MyMesh::scheduleContactWriteRetry()") + + self.assertIn("uint8_t dirty_contacts_failures;", header) + self.assertIn("mesh::recordLazyPersistenceSaveFailure(", retry) + self.assertIn( + "mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS", retry + ) + self.assertIn("LAZY_CONTACTS_WRITE_DELAY", retry) + self.assertIn("mesh::completeLazyPersistenceSave(", retry) + self.assertIn("retry in %lu ms", retry) + for body in (save, loop): + self.assertIn("scheduleContactWriteRetry();", body) + self.assertIn("mesh::resetLazyPersistenceAfterSuccess(", body) + + def test_main_loop_gates_sleep_on_pending_work(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + sleep_start = main.index("bool can_sleep =") + sleep_end = main.index("if (can_sleep)", sleep_start) + sleep_gate = main[sleep_start:sleep_end] + + self.assertIn("!the_mesh.hasPendingWork()", sleep_gate) + + nrf52 = (ROOT / "src/helpers/NRF52Board.cpp").read_text() + body = function_body(nrf52, "void NRF52Board::sleep(uint32_t secs)") + self.assertIn("event-driven sleep instead of timed sleep", body) + self.assertIn("sd_app_evt_wait();", body) + + def test_all_runtime_reboots_flush_contacts_first(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + self.assertIn("bool flushContactsBeforeReboot();", header) + + flush = function_body(mesh, "bool MyMesh::flushContactsBeforeReboot()") + self.assertIn("_store->flushContactWrites(this, save_filter)", flush) + self.assertIn("scheduleContactWriteRetry();", flush) + self.assertIn("mesh::resetLazyPersistenceAfterSuccess(", flush) + + framed = function_body(mesh, "void MyMesh::handleCmdFrame(") + reboot_at = framed.index("cmd_frame[0] == CMD_REBOOT") + framed_reboot = framed[reboot_at : framed.index( + "cmd_frame[0] == CMD_GET_BATT_AND_STORAGE", reboot_at + )] + self.assertIn("flushContactsBeforeReboot()", framed_reboot) + + rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()") + rescue_reboot = rescue[rescue.index('strcmp(cli_command, "reboot")') :] + self.assertIn("flushContactsBeforeReboot()", rescue_reboot) + + callback = function_body(mesh, "void MyMesh::rebootNow()") + self.assertIn("flushContactsBeforeReboot()", callback) + + loop = function_body(mesh, "void MyMesh::loop()") + scheduled = loop[: loop.index("#if COMPANION_FEATURE_TEMP_RADIO")] + self.assertIn("flushContactsBeforeReboot()", scheduled) + + pending = function_body(mesh, "bool MyMesh::hasPendingWork() const") + self.assertIn("|| _scheduled_reboot_at != 0", pending) + + def test_ota_apply_reboot_uses_companion_persistence_hook(self): + mesh_base_header = (ROOT / "src/Mesh.h").read_text() + mesh_base = (ROOT / "src/Mesh.cpp").read_text() + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + + self.assertIn( + "virtual bool prepareForOtaReboot() { return true; }", + mesh_base_header, + ) + maintenance = function_body( + mesh_base, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()" + ) + guard_at = maintenance.index("prepareForOtaReboot()") + apply_at = maintenance.index("ota::ota_reboot_to_apply()") + self.assertLess(guard_at, apply_at) + self.assertIn("oc.apply_at = futureMillis(1000)", maintenance) + + self.assertIn("bool prepareForOtaReboot() override;", header) + guard = function_body(mesh, "bool MyMesh::prepareForOtaReboot()") + self.assertIn("dirty_contacts_failures != 0", guard) + self.assertIn("!isContactWriteDue()", guard) + self.assertIn("flushContactsBeforeReboot()", guard) + + def test_deferred_ota_apply_prevents_event_only_sleep(self): + mesh_base_header = (ROOT / "src/Mesh.h").read_text() + mesh_base = (ROOT / "src/Mesh.cpp").read_text() + self.assertIn("bool hasPendingOtaApply() const;", mesh_base_header) + + pending = function_body(mesh_base, "bool Mesh::hasPendingOtaApply() const") + self.assertIn("defined(ENABLE_OTA)", pending) + self.assertIn("!defined(OTA_SEEDER_ONLY)", pending) + self.assertIn("ota::ota_ctx().apply_pending", pending) + + role_sources = ( + ROOT / "examples/companion_radio/MyMesh.cpp", + ROOT / "examples/simple_sensor/SensorMesh.cpp", + ROOT / "examples/simple_room_server/MyMesh.cpp", + ROOT / "examples/simple_repeater/MyMesh.cpp", + ) + for role_source in role_sources: + role = role_source.read_text() + signature = ( + "bool SensorMesh::hasPendingWork() const" + if role_source.name == "SensorMesh.cpp" + else "bool MyMesh::hasPendingWork() const" + ) + has_pending_work = function_body(role, signature) + self.assertIn("hasPendingOtaApply()", has_pending_work, role_source) + + def test_every_companion_ui_shutdown_uses_persistence_guard(self): + abstract = ( + ROOT / "examples/companion_radio/AbstractUITask.h" + ).read_text() + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + self.assertIn("public UIShutdownGuard", header) + self.assertIn("virtual bool prepareForUiShutdown() = 0;", abstract) + self.assertIn("_ui->setShutdownGuard(this)", mesh) + callback = function_body(mesh, "bool MyMesh::prepareForUiShutdown()") + self.assertIn("flushContactsBeforeReboot()", callback) + + for ui_name in ("ui-orig", "ui-new", "ui-tiny"): + ui = ( + ROOT / "examples/companion_radio" / ui_name / "UITask.cpp" + ).read_text() + shutdown = function_body(ui, "void UITask::shutdown(bool restart)") + guard_at = shutdown.index("if (!prepareForShutdown()) return;") + reboot_at = shutdown.index("_board->reboot()") + poweroff_at = shutdown.index("_board->powerOff()") + self.assertLess(guard_at, reboot_at, ui_name) + self.assertLess(guard_at, poweroff_at, ui_name) + + def test_identity_import_rearms_page_repairs(self): + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + framed = function_body(mesh, "void MyMesh::handleCmdFrame(") + import_at = framed.index("cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY") + import_end = framed.index("cmd_frame[0] == CMD_SEND_RAW_DATA", import_at) + identity_import = framed[import_at:import_end] + flush_at = identity_import.index("flushContactsBeforeReboot()") + save_identity_at = identity_import.index("_store->saveMainIdentity(") + load_at = identity_import.index("_store->loadContacts(this)") + reset_at = identity_import.index( + "mesh::resetLazyPersistenceAfterSuccess(", save_identity_at + ) + rearm_at = identity_import.index( + "mesh::scheduleLazyPersistenceMutation(", load_at + ) + self.assertLess(flush_at, save_identity_at) + self.assertLess(reset_at, load_at) + self.assertLess(load_at, rearm_at) + self.assertIn("_store->hasPendingContactWrites()", identity_import) + + def test_webconfig_reboot_request_is_one_shot_if_flush_fails(self): + webconfig = ( + ROOT / "src/helpers/esp32/WebConfigServer.cpp" + ).read_text() + tick = function_body(webconfig, "void WebConfigServer::tick(") + due_at = tick.index("WebConfigBatch::rebootDue(") + callback_at = tick.index("_cb->rebootNow()", due_at) + clear_at = tick.index("_reboot_at = 0", due_at) + self.assertLess(clear_at, callback_at) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp b/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp new file mode 100644 index 00000000..07c7d90f --- /dev/null +++ b/test/test_internal_secondary_fs_repair/test_internal_secondary_fs_repair.cpp @@ -0,0 +1,146 @@ +#include + +#include + +using mesh::storage::InternalSecondaryFsRepairResult; +using mesh::storage::InternalSecondaryFsBootResult; +using mesh::storage::isInternalExtraFsReservedByApplication; +using mesh::storage::isExpectedInternalExtraFsGeometry; +using mesh::storage::prepareInternalSecondaryFilesystem; +using mesh::storage::repairInternalSecondaryFilesystem; + +TEST(InternalSecondaryFsBoot, RequiresExact100KiBGeometry) { + EXPECT_TRUE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19000, 128)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xED000, 0x7000, 128)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x7000, 128)); + EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19000, 4096)); + EXPECT_TRUE(isInternalExtraFsReservedByApplication(0xD4000)); + EXPECT_TRUE(isInternalExtraFsReservedByApplication(0xC0000)); + EXPECT_FALSE(isInternalExtraFsReservedByApplication(0xD4001)); + EXPECT_FALSE(isInternalExtraFsReservedByApplication(0xED000)); +} + +TEST(InternalSecondaryFsBoot, HealthyFilesystemMountsWithoutOtherAccess) { + int blank_calls = 0; + int format_calls = 0; + int mount_calls = 0; + const auto result = prepareInternalSecondaryFilesystem( + [&]() { mount_calls++; return true; }, + [&]() { blank_calls++; return false; }, + [&]() { format_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsBootResult::Mounted); + EXPECT_EQ(mount_calls, 1); + EXPECT_EQ(blank_calls, 0); + EXPECT_EQ(format_calls, 0); +} + +TEST(InternalSecondaryFsBoot, FailedMountPreservesNonblankMedia) { + int format_calls = 0; + const auto result = prepareInternalSecondaryFilesystem( + []() { return false; }, + []() { return false; }, + [&]() { format_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsBootResult::PreservedNonBlank); + EXPECT_EQ(format_calls, 0); +} + +TEST(InternalSecondaryFsBoot, BlankMediaIsFormattedThenMounted) { + int mount_calls = 0; + int format_calls = 0; + const auto result = prepareInternalSecondaryFilesystem( + [&]() { return ++mount_calls == 2; }, + []() { return true; }, + [&]() { format_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsBootResult::InitializedBlank); + EXPECT_EQ(mount_calls, 2); + EXPECT_EQ(format_calls, 1); +} + +TEST(InternalSecondaryFsBoot, BlankMediaFormatFailureStaysDisabled) { + int mount_calls = 0; + const auto result = prepareInternalSecondaryFilesystem( + [&]() { mount_calls++; return false; }, + []() { return true; }, + []() { return false; }); + + EXPECT_EQ(result, InternalSecondaryFsBootResult::InitializationFailed); + EXPECT_EQ(mount_calls, 1); +} + +TEST(InternalSecondaryFsBoot, BlankMediaRemountFailureStaysDisabled) { + int mount_calls = 0; + const auto result = prepareInternalSecondaryFilesystem( + [&]() { mount_calls++; return false; }, + []() { return true; }, + []() { return true; }); + + EXPECT_EQ(result, InternalSecondaryFsBootResult::InitializationFailed); + EXPECT_EQ(mount_calls, 2); +} + +TEST(InternalSecondaryFsRepair, RunsFormatMountAndValidationInOrder) { + int sequence = 0; + int formatted_at = 0; + int mounted_at = 0; + int validated_at = 0; + + const auto result = repairInternalSecondaryFilesystem( + [&]() { + formatted_at = ++sequence; + return true; + }, + [&]() { + mounted_at = ++sequence; + return true; + }, + [&]() { + validated_at = ++sequence; + return true; + }); + + EXPECT_EQ(result, InternalSecondaryFsRepairResult::Repaired); + EXPECT_EQ(formatted_at, 1); + EXPECT_EQ(mounted_at, 2); + EXPECT_EQ(validated_at, 3); +} + +TEST(InternalSecondaryFsRepair, FormatFailureStopsBeforeMount) { + int mount_calls = 0; + int validation_calls = 0; + const auto result = repairInternalSecondaryFilesystem( + []() { return false; }, + [&]() { mount_calls++; return true; }, + [&]() { validation_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsRepairResult::FormatFailed); + EXPECT_EQ(mount_calls, 0); + EXPECT_EQ(validation_calls, 0); +} + +TEST(InternalSecondaryFsRepair, MountFailureStopsBeforeValidation) { + int validation_calls = 0; + const auto result = repairInternalSecondaryFilesystem( + []() { return true; }, + []() { return false; }, + [&]() { validation_calls++; return true; }); + + EXPECT_EQ(result, InternalSecondaryFsRepairResult::MountFailed); + EXPECT_EQ(validation_calls, 0); +} + +TEST(InternalSecondaryFsRepair, RejectsFilesystemWhichRemainsInvalid) { + const auto result = repairInternalSecondaryFilesystem( + []() { return true; }, + []() { return true; }, + []() { return false; }); + + EXPECT_EQ(result, InternalSecondaryFsRepairResult::ValidationFailed); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py new file mode 100644 index 00000000..7f4f66cc --- /dev/null +++ b/test/test_nrf52_extrafs_contract.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Contracts for the nRF52 Companion 100 KiB internal ExtraFS path.""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PROFILE = ROOT / "variants" / "t1000-e" / "platformio.ini" +LINKER = ROOT / "boards" / "nrf52840_s140_v7_extrafs.ld" +MAIN = ROOT / "examples" / "companion_radio" / "main.cpp" +STORE = ROOT / "examples" / "companion_radio" / "DataStore.cpp" +STORE_HEADER = ROOT / "examples" / "companion_radio" / "DataStore.h" +MESH = ROOT / "examples" / "companion_radio" / "MyMesh.cpp" + + +def section(source: str, name: str) -> str: + start = source.index(f"[env:{name}]") + end = source.find("\n[", start + 1) + return source[start:] if end < 0 else source[start:end] + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + opening = source.index("{", start) + depth = 0 + for position in range(opening, len(source)): + if source[position] == "{": + depth += 1 + elif source[position] == "}": + depth -= 1 + if depth == 0: + return source[opening + 1 : position] + raise AssertionError(f"unterminated function: {signature}") + + +class Nrf52ExtraFsContractTest(unittest.TestCase): + def test_t1000_usb_and_ble_reserve_exact_100k_region(self): + profile = PROFILE.read_text(encoding="utf-8") + for environment in ( + "t1000e_companion_radio_usb", + "t1000e_companion_radio_ble", + ): + body = section(profile, environment) + self.assertIn( + "board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld", + body, + ) + self.assertIn("board_upload.maximum_size = 708608", body) + + linker = LINKER.read_text(encoding="utf-8") + self.assertRegex( + linker, + r"FLASH\s*\(rx\)\s*:\s*ORIGIN\s*=\s*0x27000,\s*" + r"LENGTH\s*=\s*0xD4000\s*-\s*0x27000", + ) + + main = MAIN.read_text(encoding="utf-8") + geometry = re.search( + r"CustomLFS\s+ExtraFS\s*\(\s*(0x[0-9A-Fa-f]+)\s*,\s*" + r"(0x[0-9A-Fa-f]+)\s*,\s*128\s*\)", + main, + ) + self.assertIsNotNone(geometry) + start = int(geometry.group(1), 16) + size = int(geometry.group(2), 16) + self.assertEqual(start, 0xD4000) + self.assertEqual(size, 100 * 1024) + self.assertEqual(start + size, 0xED000) + + def test_boot_mount_is_non_destructive_and_checks_runtime_geometry(self): + main = MAIN.read_text(encoding="utf-8") + setup = function_body(main, "void setup()") + self.assertIn("isExpectedInternalExtraFsGeometry(", setup) + self.assertIn("isInternalExtraFsReservedByApplication(", setup) + self.assertIn("uint32_t __flash_arduino_end[]", main) + self.assertIn("(uintptr_t)__flash_arduino_end", setup) + self.assertIn("prepareInternalSecondaryFilesystem(", setup) + self.assertIn( + "ExtraFS.Adafruit_LittleFS::begin()", setup + ) + self.assertNotIn("ExtraFS.begin()", setup) + self.assertIn("PreservedNonBlank", setup) + self.assertIn("store.disableSecondaryFS();", setup) + self.assertLess( + setup.index("prepareInternalSecondaryFilesystem("), + setup.index("store.begin()"), + ) + + def test_corrupt_secondary_is_preserved_until_explicit_repair(self): + store = STORE.read_text(encoding="utf-8") + begin = function_body(store, "void DataStore::begin()") + invalid = re.search( + r"if\s*\(_fsExtra\s*!=\s*nullptr\s*&&\s*" + r"!validateLfsFilesystem\(_fsExtra\)\)\s*\{(.*?)\}", + begin, + re.DOTALL, + ) + self.assertIsNotNone(invalid) + self.assertIn("_fsExtra = nullptr;", invalid.group(1)) + self.assertNotIn("format", invalid.group(1)) + self.assertNotIn("reinitializeInternalExtraFS", invalid.group(1)) + + header = STORE_HEADER.read_text(encoding="utf-8") + self.assertIn("FILESYSTEM* _configuredFsExtra;", header) + extra_constructor = re.search( + r"DataStore::DataStore\(FILESYSTEM& fs, FILESYSTEM& fsExtra,.*?\{", + store, + re.DOTALL, + ) + self.assertIsNotNone(extra_constructor) + self.assertIn("_configuredFsExtra(&fsExtra)", extra_constructor.group(0)) + + def test_repair_is_internal_only_and_revalidates_before_activation(self): + store = STORE.read_text(encoding="utf-8") + repair = function_body(store, "bool DataStore::repairInternalExtraFS()") + self.assertRegex( + repair, + r"#if defined\(NRF52_PLATFORM\) && defined\(EXTRAFS\) " + r"&& !defined\(QSPIFLASH\)", + ) + self.assertIn("reinitializeInternalExtraFS()", repair) + self.assertIn("return migrateToSecondaryFS();", repair) + + active_retry = repair[ + repair.index("if (_fsExtra != nullptr)") : + repair.index("if (!reinitializeInternalExtraFS())") + ] + self.assertIn("return migrateToSecondaryFS();", active_retry) + self.assertNotIn("format()", active_retry) + + reinitialize = function_body( + store, "bool DataStore::reinitializeInternalExtraFS()" + ) + geometry_at = reinitialize.index("isExpectedInternalExtraFsGeometry(") + self.assertIn("isInternalExtraFsReservedByApplication(", reinitialize) + self.assertIn("uint32_t __flash_arduino_end[]", store) + self.assertIn("(uintptr_t)__flash_arduino_end", reinitialize) + unmount_at = reinitialize.index("extra->end()") + format_at = reinitialize.index("extra->format()") + mount_at = reinitialize.index( + "extra->Adafruit_LittleFS::begin()" + ) + validate_at = reinitialize.index( + "validateLfsFilesystem(_configuredFsExtra)" + ) + activate_at = reinitialize.index("_fsExtra = _configuredFsExtra;") + self.assertLess(geometry_at, unmount_at) + self.assertLess(unmount_at, format_at) + self.assertLess(format_at, mount_at) + self.assertLess(mount_at, validate_at) + self.assertLess(validate_at, activate_at) + + def test_migration_failures_block_rebuild_and_can_be_retried(self): + store = STORE.read_text(encoding="utf-8") + header = STORE_HEADER.read_text(encoding="utf-8") + migration = function_body(store, "bool DataStore::migrateToSecondaryFS()") + self.assertIn("bool migrateToSecondaryFS();", header) + self.assertIn('SECONDARY_MIGRATION_JOURNAL = "/.extrafs.mig"', store) + pending_at = migration.index( + "writeJournal(SECONDARY_MIGRATION_PENDING)" + ) + copy_at = migration.index("copySecondarySources()", pending_at) + committed_at = migration.index( + "writeJournal(SECONDARY_MIGRATION_COMMITTED)", copy_at + ) + retire_at = migration.index("retireSecondarySources()", committed_at) + journal_remove_at = migration.index( + "_fsExtra->remove(SECONDARY_MIGRATION_JOURNAL)", retire_at + ) + self.assertLess(pending_at, copy_at) + self.assertLess(copy_at, committed_at) + self.assertLess(committed_at, retire_at) + self.assertLess(retire_at, journal_remove_at) + copy_failure_end = migration.index( + "journal_state = MigrationJournalState::Committed", committed_at + ) + self.assertIn( + "_fsExtra = nullptr;", migration[copy_at:copy_failure_end] + ) + copy_helper = function_body(migration, "auto copy =") + self.assertNotIn("source_fs->remove(path)", copy_helper) + self.assertIn("return success;", migration) + self.assertIn("compared == expected_size", migration) + + mesh = MESH.read_text(encoding="utf-8") + repair = function_body(mesh, "void MyMesh::repairInternalExtraFS(") + migrate_at = repair.index("_store->repairInternalExtraFS()") + return_at = repair.index("return;", migrate_at) + contacts_at = repair.index("_store->saveContacts(this, save_filter)") + self.assertLess(migrate_at, return_at) + self.assertLess(return_at, contacts_at) + self.assertNotIn("already_ready", repair) + + def test_legacy_primary_files_move_before_contact_journal_can_fall_back(self): + store = STORE.read_text(encoding="utf-8") + migration = function_body(store, "bool DataStore::migrateToSecondaryFS()") + + # Older ExtraFS test builds could leave identity and preferences only + # on the secondary filesystem. Recover those first: every journal + # failure below disables/falls back from that secondary filesystem. + self.assertIn( + 'static const char* to_primary[] = {"/_main.id", "/new_prefs"};', + migration, + ) + primary_move_at = migration.index("if (!migratePrimarySources())") + journal_flow_at = migration.index( + "MigrationJournalState journal_state = readJournal()" + ) + self.assertLess(primary_move_at, journal_flow_at) + + # A primary-present conflict is nonfatal: primary is canonical and the + # differing secondary copy is preserved for recovery. Only failure to + # create a missing primary copy may block the handoff. + copy_helper = function_body(migration, "auto copy =") + primary_helper = function_body( + migration, "auto migratePrimarySources =" + ) + self.assertNotIn("source_fs->remove(path)", copy_helper) + self.assertLess( + copy_helper.rindex("filesEqual(source_fs, dest_fs, path)"), + copy_helper.rindex("return true;"), + ) + conflict_at = primary_helper.index( + "if (!filesEqual(_fsExtra, _fs, path))" + ) + missing_copy_at = primary_helper.index( + "else if (!copy(_fsExtra, _fs, path))" + ) + remove_at = primary_helper.index("_fsExtra->remove(path)") + self.assertLess(conflict_at, missing_copy_at) + self.assertLess(missing_copy_at, remove_at) + conflict_branch = primary_helper[ + conflict_at:missing_copy_at + ] + self.assertIn("continue;", conflict_branch) + self.assertNotIn("success = false", conflict_branch) + missing_copy_branch = primary_helper[ + missing_copy_at:remove_at + ] + self.assertIn("success = false;", missing_copy_branch) + retire_branch = primary_helper[remove_at:] + self.assertNotIn("success = false;", retire_branch) + self.assertIn( + "preserving conflicting legacy secondary", primary_helper + ) + + def test_local_rescue_repair_rebuilds_live_contacts_and_channels(self): + mesh = MESH.read_text(encoding="utf-8") + repair = function_body(mesh, "void MyMesh::repairInternalExtraFS(") + self.assertIn("_store->repairInternalExtraFS()", repair) + self.assertIn("_store->saveContacts(this, save_filter)", repair) + self.assertIn("const bool channels_saved = saveChannels();", repair) + self.assertIn("_store->getStorageTotalKb() == 100", repair) + self.assertIn("getStorageTotalKb()", repair) + + rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()") + command_at = rescue.index('strcmp(cli_command, "repair extrafs")') + rebuild_at = rescue.index('strcmp(cli_command, "rebuild")') + repair_path = rescue[command_at:rebuild_at] + self.assertIn("repairInternalExtraFS(output);", repair_path) + + terminal = function_body(mesh, "void MyMesh::handleTerminalCommand(") + self.assertIn('strcmp(command, "repair extrafs")', terminal) + self.assertIn("repairInternalExtraFS(terminalOutput());", terminal) + self.assertIn( + "repair extrafs (erases/rebuilds internal ExtraFS)", terminal + ) + + def test_rescue_rebuild_reports_success_only_after_every_save_succeeds(self): + mesh = MESH.read_text(encoding="utf-8") + rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()") + rebuild_at = rescue.index('strcmp(cli_command, "rebuild")') + erase_at = rescue.index('strcmp(cli_command, "erase")', rebuild_at) + rebuild = rescue[rebuild_at:erase_at] + + expected_saves = ( + ("identity_saved", "_store->saveMainIdentity(self_id)"), + ("prefs_saved", "savePrefs()"), + ("contacts_saved", "_store->saveContacts(this, save_filter)"), + ("channels_saved", "saveChannels()"), + ) + for result, call in expected_saves: + self.assertRegex( + rebuild, + rf"(?:const\s+)?bool\s+{result}\s*=\s*" + rf"{re.escape(call)}\s*;", + ) + + # A failed or still-pending contact rebuild must retain the lazy-write + # retry path instead of claiming that the destructive rebuild is done. + self.assertRegex( + rebuild, + r"(?:const\s+)?bool\s+contacts_complete\s*=\s*" + r"contacts_saved\s*&&\s*" + r"!_store->hasPendingContactWrites\(\)\s*;", + ) + self.assertIsNotNone( + re.search( + r"if\s*\(\s*contacts_complete\s*\)\s*\{.*?\}\s*" + r"else\s*\{\s*scheduleContactWriteRetry\(\)\s*;", + rebuild, + re.DOTALL, + ) + ) + + success_text = 'output.println(" > erase and rebuild done")' + success_at = rebuild.index(success_text) + success_prefix = rebuild[:success_at] + guard_at = success_prefix.rfind("if (") + brace_at = success_prefix.rfind("{") + self.assertGreaterEqual(guard_at, 0) + self.assertGreater(brace_at, guard_at) + success_guard = success_prefix[guard_at:brace_at] + for result in ("identity_saved", "prefs_saved", "channels_saved"): + self.assertIn(result, success_guard) + self.assertIn("contacts_complete", success_guard) + self.assertNotIn("||", success_guard) + + # The positive message must follow all save attempts; the branch must + # also retain an explicit error response for a partial rebuild. + for _, call in expected_saves: + self.assertLess(rebuild.index(call), success_at) + self.assertIn("Error:", rebuild[success_at:]) + + def test_quarantined_extrafs_rescue_file_commands_are_null_safe(self): + mesh = MESH.read_text(encoding="utf-8") + rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()") + unavailable = ( + 'ExtraFS is unavailable; run repair extrafs first' + ) + + cat_start = rescue.index('memcmp(cli_command, "cat", 3)') + rm_start = rescue.index('memcmp(cli_command, "rm ", 3)', cat_start) + reboot_start = rescue.index( + 'strcmp(cli_command, "reboot")', rm_start + ) + cat_path = rescue[cat_start:rm_start] + rm_path = rescue[rm_start:reboot_start] + + for command_path in (cat_path, rm_path): + secondary_at = command_path.index( + "_store->getSecondaryFS()" + ) + null_guard_at = command_path.index( + "if (selected_fs == nullptr)", secondary_at + ) + access_at = min( + position + for operation in ( + "_store->openRead(selected_fs, path)", + "_store->removeFile(selected_fs, path)", + ) + if (position := command_path.find(operation)) >= 0 + ) + self.assertLess(null_guard_at, access_at) + self.assertIn(unavailable, command_path) + + self.assertIn("uint8_t buffer[64];", cat_path) + self.assertNotRegex(cat_path, r"uint8_t\s+buffer\s*\[\s*file_size\s*\]") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_dev_reboot_guard.py b/test/test_ota_dev_reboot_guard.py new file mode 100644 index 00000000..3d117ff0 --- /dev/null +++ b/test/test_ota_dev_reboot_guard.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Static contract for the low-level ESP32 OTA commit reboot handoff.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + opening_brace = source.index("{", start) + depth = 0 + for position in range(opening_brace, len(source)): + if source[position] == "{": + depth += 1 + elif source[position] == "}": + depth -= 1 + if depth == 0: + return source[opening_brace + 1 : position] + raise AssertionError(f"unterminated function: {signature}") + + +class OtaDevRebootGuardContractTest(unittest.TestCase): + def test_slot_arm_never_reboots_inline(self): + header = (ROOT / "src/helpers/ota/OtaApply.h").read_text() + apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text() + + self.assertIn("bool ota_apply_arm();", header) + arm = function_body(apply, "bool ota_apply_arm()") + self.assertIn("esp_ota_set_boot_partition", arm) + self.assertNotIn("esp_restart", arm) + self.assertNotIn("ota_apply_commit", apply) + + def test_dev_commit_arms_the_guarded_mesh_reboot(self): + cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + mesh = (ROOT / "src/Mesh.cpp").read_text() + + dev = function_body( + cli, + "static bool handle_dev(const char* d, char* reply, OtaContext& c) {", + ) + commit_at = dev.index('strncmp(sub, "commit", 6)') + commit = dev[commit_at : dev.index("} else {", commit_at)] + arm_at = commit.index("ota_apply_arm()") + pending_at = commit.index("c.apply_pending = true") + self.assertLess(arm_at, pending_at) + self.assertIn("c.bootloader_apply_pending = false", commit) + self.assertIn("c.apply_at = 0", commit) + self.assertIn("c.apply_hard = 0", commit) + self.assertNotIn("ota_reboot_to_apply", commit) + self.assertNotIn("esp_restart", commit) + + self.assertIn("bool apply_pending = false;", context) + maintenance = function_body( + mesh, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()" + ) + guard_at = maintenance.index("prepareForOtaReboot()") + reboot_at = maintenance.index("ota::ota_reboot_to_apply()") + self.assertLess(guard_at, reboot_at) + + +if __name__ == "__main__": + unittest.main()