diff --git a/release-notes/beta_26.txt b/release-notes/beta_26.txt new file mode 100644 index 00000000..544202dd --- /dev/null +++ b/release-notes/beta_26.txt @@ -0,0 +1,8 @@ +# beta_26 (TEST) — storage-reliability fix on top of the current stable (beta_25). +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Fixed --- +Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down. + +# --- Improved --- +On devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). That frees the small internal flash and puts the frequently-rewritten files on the more reliable card. Your existing contacts move to the card automatically the first time; your identity and settings stay in internal flash, so the node is unchanged. diff --git a/src/DataStore.cpp b/src/DataStore.cpp index de509432..821d81d8 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -3,6 +3,7 @@ #include "DataStore.h" #if defined(ESP32) #include +#include "helpers/esp32/WdtHeavyGuard.h" // suspend core-0 idle WDT during the (SPIFFS-GC-prone) contact write #endif #if defined(EXTRAFS) || defined(QSPIFLASH) @@ -70,6 +71,38 @@ void DataStore::begin() { #else // init 'blob store' support _fs->mkdir("/bl"); + #if defined(ESP32) + // One-time migration: when a secondary FS is active (an SD card is present) + // but contacts/channels still live on the primary FS from an earlier build, + // copy them onto the card ONCE and delete the primary copies. This preserves + // the user's contact list while getting the frequently-rewritten files off a + // small/near-full SPIFFS — whose garbage collection can otherwise stall the + // loop task for seconds during saveContacts and trip the task watchdog + // (the beta_25 reboot loop). Identity + prefs stay on _fs and never move. + // No-op once the card already holds the files, and a safe no-op on boards + // whose primary-FS metadata can't confirm the source exists (e.g. P4 FFat). + if (_fsExtra) { + static const char* const kMoveFiles[] = { "/contacts3", "/channels2" }; + for (const char* nm : kMoveFiles) { + if (_fsExtra->exists(nm)) continue; // already on the card + if (!_fs->exists(nm)) continue; // nothing to move + File in = openRead(_fs, nm); + File out = openWrite(_fsExtra, nm); + bool ok = in && out; + if (ok) { + uint8_t buf[256]; + int n; + while ((n = in.read(buf, sizeof(buf))) > 0) { + if (out.write(buf, (size_t)n) != (size_t)n) { ok = false; break; } + } + } + if (in) in.close(); + if (out) out.close(); + if (ok) _fs->remove(nm); // reclaim SPIFFS space + else if (_fsExtra->exists(nm)) _fsExtra->remove(nm); // don't leave a partial copy + } + } + #endif #endif } @@ -382,6 +415,17 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3"); } void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { +#if defined(ESP32) + // A full/fragmenting contacts write on SPIFFS can trigger a multi-second GC + // pass that disables the flash cache and starves core 0's idle task, tripping + // the task watchdog (the beta_25 reboot loop, confirmed by coredump: loopTask + // stuck in spiffs_gc_clean under this exact call). Suspend that watchdog for + // the write so a bounded-but-slow pass stalls the UI instead of rebooting. The + // ref-count is shared with the UI history/backup guards, so a backup import + // that calls saveContacts stays balanced. On SD-routed devices this is cheap + // (FAT has no such GC); it matters most for card-less SPIFFS devices. + WdtHeavyGuard _wdt; +#endif File file = openWrite(_getContactsChannelsFS(), "/contacts3"); if (file) { uint32_t idx = 0; diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h new file mode 100644 index 00000000..f7fbe4c3 --- /dev/null +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -0,0 +1,39 @@ +#pragma once +// ----------------------------------------------------------------------------- +// WdtHeavyGuard — suspend core 0's IDLE-task watchdog around a bounded-but-slow +// flash burst. +// +// On ESP32 a fragmenting SPIFFS write can trigger garbage collection: a +// multi-second flash operation that disables the CPU cache and starves the idle +// task long enough to trip the TASK watchdog. Seen in a coredump as an abort +// from task_wdt_isr while the loop task was inside spiffs_gc_clean during +// DataStore::saveContacts. Bracket such writes with a WdtHeavyGuard so a +// bounded-but-slow GC pass stalls the UI briefly instead of rebooting. +// +// Ref-counted and SHARED across translation units — the touch-UI history/backup +// saves (UITask.cpp) AND the core contact save (DataStore::saveContacts) use the +// same count. That matters because a backup import runs under a guard and itself +// calls saveContacts: one balanced count means the inner guard never re-arms the +// watchdog while the outer import is still writing. The counter lives in an +// inline function's static, so there is a single instance program-wide without a +// dedicated .cpp (and no ordering/link surprises). +// +// IMPORTANT: touch ONLY core 0's IDLE-task WDT. In the Arduino S3 build only core +// 0's idle task is subscribed to the task watchdog (CHECK_IDLE_TASK_CPU1 is off), +// so disableCore1WDT() merely fails while enableCore1WDT() *newly subscribes* +// IDLE1 — arming a core-1 watchdog that did not exist before, which a later slow +// flash burst then trips. Leaving core 1 alone keeps the Arduino default +// (loopTask not WDT-watched), so a slow write just stalls the UI briefly. +// ----------------------------------------------------------------------------- +#if defined(ESP32) +#include // disableCore0WDT / enableCore0WDT + +inline int& _wdtHeavyDepth() { static int d = 0; return d; } +inline void wdtHeavyBegin() { int& d = _wdtHeavyDepth(); if (d++ == 0) disableCore0WDT(); } +inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) enableCore0WDT(); } +#else +inline void wdtHeavyBegin() {} +inline void wdtHeavyEnd() {} +#endif + +struct WdtHeavyGuard { WdtHeavyGuard() { wdtHeavyBegin(); } ~WdtHeavyGuard() { wdtHeavyEnd(); } }; diff --git a/src/main.cpp b/src/main.cpp index 6b7cfc05..ff0d23d5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -362,23 +362,45 @@ void setup() { SPIFFS.exists("/identity/_main.id")); bool fresh_install = !use_sd_pref && !setup_done && !spiffs_has_data; - bool want_sd = !spiffs_ok // no usable SPIFFS partition -> must use SD - || use_sd_pref // user opted in - || fresh_install; // brand-new device: try SD first + // Move the WHOLE store (identity/prefs/contacts) to SD:/meshcomod when the + // device has no usable SPIFFS, the user opted in, or it's a brand-new device. + bool want_full_sd = !spiffs_ok || use_sd_pref || fresh_install; + SPIClass* _spi = tdeckSharedSPI(); - if (want_sd && _spi) { - for (int a = 0; a < 4 && !sd_storage; ++a) { // short mount ladder (cold cards) + bool sd_mounted = false; + if (_spi) { + // Try to mount the card on EVERY boot now (not just the full-adoption case): + // even a device that keeps identity on SPIFFS wants its churn-heavy + // contacts/channels on the card. Full 4-try ladder for the cold-card + // first-run adoption; a shorter 2-try for the contacts-overflow case so a + // card-less device only pays ~260 ms. + int tries = want_full_sd ? 4 : 2; + for (int a = 0; a < tries && !sd_mounted; ++a) { // short mount ladder (cold cards) SD.end(); delay(a == 0 ? 40 : 220); - if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE) { - sd_storage = store.useSdStorage(); - } + if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE) + sd_mounted = true; } - // On a genuine first run, persist the auto-pick so the "Store data on SD" - // toggle reflects it and the choice sticks on every later boot. - if (fresh_install && sd_storage && !use_sd_pref) { - Preferences _p; if (_p.begin("touch", false)) { _p.putBool("use_sd", true); _p.end(); } - Serial.println("[BOOT] first run + SD card present -> data defaults to SD"); + } + if (sd_mounted) { + if (want_full_sd) { + sd_storage = store.useSdStorage(); + // On a genuine first run, persist the auto-pick so the "Store data on SD" + // toggle reflects it and the choice sticks on every later boot. + if (fresh_install && sd_storage && !use_sd_pref) { + Preferences _p; if (_p.begin("touch", false)) { _p.putBool("use_sd", true); _p.end(); } + Serial.println("[BOOT] first run + SD card present -> data defaults to SD"); + } + } else { + // Upgraded device: identity + prefs stay on SPIFFS (no node-identity + // change, safe if the card is later pulled), but route the frequently + // rewritten contacts + channels to the card. On a near-full 3.375 MB + // SPIFFS every 5-second saveContacts triggers a multi-second GC pass + // that starves the loop task and trips the task watchdog (the beta_25 + // reboot loop). DataStore::begin() migrates the existing SPIFFS copies + // to the card once, so the contact list is preserved. + store.setSecondaryFS(&SD); + Serial.println("[BOOT] contacts/channels -> SD card (identity/prefs stay on SPIFFS)"); } } } diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index f826eaf8..eeed6904 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -101,6 +101,7 @@ #if defined(ESP32) #include #include // NVS-or-SD prefs backend (Launcher-safe) + #include "helpers/esp32/WdtHeavyGuard.h" // shared ref-counted core-0 WDT suspend (history/backup saves + core saveContacts) // QUOTED on purpose: the vendored core lib ships a STALE copy of this header in // its include path, so an angle include picks that up and misses accessors we // add here (e.g. the signal-probe prefs). Quotes force the local src/ copy. @@ -7444,24 +7445,11 @@ static void doExportBackupFile(const char* fname); // write a backup (SD if a // so a bounded-but-slow GC pass can't reset the device. The same pattern the // file-manager format/paste paths already use. Ref-counted so a nested guard // (import -> persistHistoryNow -> saveThreadsToStorage/saveMsgsToStorage) doesn't re-enable early. -static int s_wdt_heavy_depth = 0; -// IMPORTANT: touch ONLY core 0's IDLE-task WDT here. In the Arduino S3 build -// only core 0's idle task is subscribed to the task watchdog -// (CHECK_IDLE_TASK_CPU1 is off), so disableCore1WDT() merely fails ("Failed to -// remove Core 1 IDLE task from WDT") while enableCore1WDT() *newly subscribes* -// IDLE1 — arming a core-1 watchdog that didn't exist before. A later -// multi-second UI-thread flash burst (SPIFFS GC during a history save) then -// starves IDLE1 and reboots (task_wdt: IDLE1). i.e. the old guard CAUSED the -// very reboot it was meant to prevent. Leaving core 1 alone restores the -// Arduino default (loopTask not WDT-watched), so the slow write just stalls -// the UI briefly instead of rebooting. -static inline void wdtHeavyBegin() { - if (s_wdt_heavy_depth++ == 0) { disableCore0WDT(); } -} -static inline void wdtHeavyEnd() { - if (s_wdt_heavy_depth > 0 && --s_wdt_heavy_depth == 0) { enableCore0WDT(); } -} -struct WdtHeavyGuard { WdtHeavyGuard() { wdtHeavyBegin(); } ~WdtHeavyGuard() { wdtHeavyEnd(); } }; +// wdtHeavyBegin/wdtHeavyEnd + WdtHeavyGuard now live in the shared header +// helpers/esp32/WdtHeavyGuard.h (included near the top of this file) so the core +// DataStore::saveContacts shares the SAME ref-count as these UI history/backup +// saves: a backup import that itself calls saveContacts stays balanced and never +// re-arms the watchdog early. The core-0-only rationale is documented there. // --- Settings row label helper -------------------------------------------- // The settings detail pages lay rows out with a manual y-cursor and used to