From 06aa1ac2f7bd66c0cec1e1bc82fdf8b4f88ffcd2 Mon Sep 17 00:00:00 2001 From: Nick <2506116+k5njm@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:00:51 -0500 Subject: [PATCH 1/5] feat: silent-restart defrag on section-build double failure + BLE debug flags - EpubReaderActivity: when a section build fails even after the BLE teardown/retry, silentRestartToReader() as last-resort heap defrag, guarded by bootWasSilentRestart() to prevent reboot loops - main/SilentRestart.h: expose bootWasSilentRestart() - platformio.ini: enable FREEINK_BLE_HID_REPORT_DEBUG in env:default --- platformio.ini | 1 + src/SilentRestart.h | 4 ++++ src/activities/reader/EpubReaderActivity.cpp | 11 +++++++++++ src/main.cpp | 7 +++++++ 4 files changed, 23 insertions(+) diff --git a/platformio.ini b/platformio.ini index de8e2317..71f9bb9b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -95,6 +95,7 @@ build_flags = -DLOG_LEVEL=2 ; Set log level to debug for development builds -DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE) -DFREEINK_BLE_HID_SCAN_DEBUG=1 ; verbose BLE scan lifecycle/advertisement logs for bring-up + -DFREEINK_BLE_HID_REPORT_DEBUG=1 ; raw HID report hex dumps + report-map hints (bring-up) [env:gh_release] diff --git a/src/SilentRestart.h b/src/SilentRestart.h index f94345c5..7c7099f5 100644 --- a/src/SilentRestart.h +++ b/src/SilentRestart.h @@ -6,3 +6,7 @@ void silentRestart(); // home screen void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath) +// True when this boot itself came from a silent restart. Callers that restart +// as a last-resort defrag must check this so a failure that survives the +// restart degrades to an error instead of a reboot loop. +bool bootWasSilentRestart(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3ae5365b..a983d241 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -33,6 +33,7 @@ #include "QrDisplayActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" +#include "SilentRestart.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/BookmarkUtil.h" @@ -883,6 +884,16 @@ void EpubReaderActivity::render(RenderLock&& lock) { bleinput::showConnectingUntilLinked(renderer, mappedInput); requestGhostCleanup(); } + if (!built && !bootWasSilentRestart()) { + // Even with BLE freed, the build can fail when this session's parse churn has + // fragmented the heap beyond in-place recovery. A silent restart is the only + // real defrag on this heap (no compaction); it resumes into this book and + // rebuilds the section on a fresh heap. Guarded by bootWasSilentRestart() so a + // build that fails again after the restart degrades to the error popup below + // instead of reboot-looping. + LOG_ERR("ERS", "Section build failed after BLE recovery; silent restart to defrag heap"); + silentRestartToReader(); + } if (!built) { LOG_ERR("ERS", "Failed to persist page data to SD"); section.reset(); diff --git a/src/main.cpp b/src/main.cpp index 3622413c..5bad1891 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -128,6 +128,12 @@ enum class BootResume : uint8_t { QuickResume, // wake from a quick-resume deep sleep (SD flag; survives power loss) }; +// Latched in setup() from the read-and-clear of the RTC flag, so the reboot-loop +// guard in bootWasSilentRestart() has the answer for the whole session. +static bool bootWasSilentRestartFlag = false; + +bool bootWasSilentRestart() { return bootWasSilentRestartFlag; } + // Latched true once enterDeepSleep() commits to sleeping, before it tears down // the current activity. WiFi activities call silentRestart() in onExit() to // clear heap fragmentation on the way out, but deep sleep is a full chip reset @@ -330,6 +336,7 @@ void setup() { (isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_READER) ? silentRebootTarget : 0; silentRebootMagic = 0; silentRebootTarget = 0; + bootWasSilentRestartFlag = isSilentReboot; gpio.begin(); powerManager.begin(); From 74a0969cc6e3eeb575aac8b6f952f6ae5ba64943 Mon Sep 17 00:00:00 2001 From: Nick <2506116+k5njm@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:24:29 -0500 Subject: [PATCH 2/5] fix: pre-flight heap floor before section builds; no inline BLE restart Layout code (line-break DP arrays, CSS lookups, glyph buffers) allocates via std::vector/std::string and abort()s on OOM under -fno-exceptions -- it cannot fail cleanly mid-build. Field crashes (X4, BLE resident): builds entered at ~11 KB free and aborted in ParsedText:: computeLineBreaks; an inline BLE restart after recovery re-starved the render and abort()ed in FontCacheManager's scanText_.reserve at ~8 KB. - BUILD_MIN_FREE_HEAP (40 KB): pre-flight before the build; below the floor go straight to recovery instead of attempting a doomed build - Recovery no longer restarts NimBLE inline; the lifecycle stays paused until the render completes (scoped unpause guard), then the main-loop lifecycle restarts BLE behind its own heap gate --- src/activities/reader/EpubReaderActivity.cpp | 42 ++++++++++++++------ src/activities/reader/EpubReaderActivity.h | 8 ++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index a983d241..3e235bdd 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -795,6 +795,15 @@ void EpubReaderActivity::render(RenderLock&& lock) { return; } + // The build-recovery path below frees the BLE stack and pauses the main-loop lifecycle + // so it can't restart NimBLE while this render is still allocating (glyph prewarm, scan + // strings -- an inline restart re-starved exactly that and abort()ed). Unpause only + // when the render fully completes, on every exit path; the main-loop lifecycle then + // brings BLE back and shows its reconnect popup. + struct LifecycleUnpause { + ~LifecycleUnpause() { bleinput::setLifecyclePaused(false); } + } lifecycleUnpause; + const auto showPendingSyncSaveError = [this]() { if (!pendingSyncSaveError) return; pendingSyncSaveError = false; @@ -865,24 +874,33 @@ void EpubReaderActivity::render(RenderLock&& lock) { SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn); }; - bool built = buildSection(); + // The layout code (line-break DP arrays, CSS lookups, glyph buffers) allocates freely + // and abort()s on OOM under -fno-exceptions, so a starved heap must be handled BEFORE + // the build: pre-flight the floor and take the recovery path up front instead of + // crashing mid-parse. Field data: builds succeed at ~46 KB free with BLE resident; + // abort() observed at ~11 KB free. + const bool heapTooLow = ESP.getFreeHeap() < BUILD_MIN_FREE_HEAP; + if (heapTooLow) { + LOG_ERR("ERS", "Pre-build heap %u below floor %u; entering build recovery", (unsigned)ESP.getFreeHeap(), + (unsigned)BUILD_MIN_FREE_HEAP); + } + + bool built = !heapTooLow && buildSection(); if (!built && SETTINGS.bluetoothEnabled) { // Building a section needs a large contiguous inflate (deflate) window that the // resident NimBLE stack fragments out of existence (~16 KB max block with BT on). - // Free the BLE stack, build, then restore it. The chapter is cached afterwards, so - // this recovery runs at most once per uncached chapter; BT reconnects in a few s. - LOG_INF("ERS", "Section build failed with Bluetooth on; freeing BLE RAM and retrying"); + // Free the BLE stack and retry. The chapter is cached afterwards, so this recovery + // runs at most once per uncached chapter. + LOG_INF("ERS", "Section build needs heap; freeing BLE RAM and retrying"); bleinput::setLifecyclePaused(true); bleinput::stop(); built = buildSection(); - const bool bleOk = bleinput::ensureStarted(); - bleinput::setLifecyclePaused(false); - LOG_INF("ERS", "BLE restart after build: begin=%d", bleOk); - // Hold the "BT Connecting..." popup until the remote re-links, then force the - // page render below onto the ghost-cleanup (HALF) path so the popup clears - // without ghosting the grayscale page. - bleinput::showConnectingUntilLinked(renderer, mappedInput); - requestGhostCleanup(); + // Deliberately do NOT restart BLE inline: this render still has its own allocations + // to make (glyph prewarm, scan strings), and an inline NimBLE restart re-starves + // it -- field crash: std::string::reserve(2048) abort()ed at ~8 KB free right + // after an inline restart. The lifecycle stays paused until this render fully + // completes (unpause guard at the top of render()); the main-loop lifecycle then + // restarts BLE behind its own heap gate and shows the reconnect popup. } if (!built && !bootWasSilentRestart()) { // Even with BLE freed, the build can fail when this session's parse churn has diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 20dee8be..c4fad8d9 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -56,6 +56,14 @@ class EpubReaderActivity final : public Activity { SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {}; int footnoteDepth = 0; + // Heap floor for entering a section build. The layout code allocates freely (line-break DP + // arrays sized by word count, CSS rule lookups, glyph buffers) and under -fno-exceptions an + // OOM there abort()s the firmware instead of failing cleanly -- so a starved heap must be + // handled *before* the build, not after. Field data: builds succeed at ~46 KB free with BLE + // resident; abort() observed at ~11 KB free. CSS styling already degrades below 48 KB + // (MIN_FREE_HEAP_FOR_CSS), so 40 KB trades a few early BLE teardowns for not crashing. + static constexpr size_t BUILD_MIN_FREE_HEAP = 40 * 1024; + void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar() const; From 551d29744a92e03fbd69b1caaffff9858cca537d Mon Sep 17 00:00:00 2001 From: Nick <2506116+k5njm@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:56:10 -0500 Subject: [PATCH 3/5] fix: stop BLE lifecycle oscillation after build recovery The 70 KB restart floor was arithmetic nonsense: NimBLE takes ~57 KB, so a restart at 70 KB free left ~13 KB -- below the 40 KB build pre-flight -- so the next chapter build instantly re-entered recovery and tore BLE back down. Field symptom: endless 'BT Connecting...' popup + redraw loop. - Single conservative floor: 100 KB (57 KB stack + 40 KB build headroom) - 30 s cool-down after any recovery teardown before the lifecycle may restart BLE, so a marginal heap can never flap; the 'BT paused (low memory)' popup shows instead and reading continues without the remote --- src/main.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 5bad1891..d071de37 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -503,7 +503,41 @@ void setup() { void updateBluetoothLifecycle() { const bool wanted = SETTINGS.bluetoothEnabled && activityManager.bluetoothShouldBeActive() && WiFi.getMode() == WIFI_MODE_NULL; + // Track recovery teardowns: while the reader's build recovery holds the lifecycle + // paused it has taken BLE down deliberately. After that, hold a cool-down before any + // restart -- an immediate restart re-enters the exact heap state that just failed and + // the lifecycle becomes an oscillator (restart -> connect popup -> build fails -> + // teardown -> restart...), observed in the field as a "BT Connecting..." loop. + static uint32_t recoveryTeardownMs = 0; + if (bleinput::lifecyclePaused()) recoveryTeardownMs = millis(); if (wanted && !BleHid.isRunning() && bleinput::lifecyclePaused()) return; + static constexpr uint32_t BLE_RESTART_COOLDOWN_MS = 30 * 1000; + const bool inCooldown = recoveryTeardownMs != 0 && millis() - recoveryTeardownMs < BLE_RESTART_COOLDOWN_MS; + // Heap gate: NimBLE needs ~57 KB, and the section-build pre-flight needs 40 KB free + // AFTER the stack is up -- so anything below ~100 KB just re-enters build recovery + // and tears BLE straight back down. (A first cut used 70 KB for restarts; 70-57=13 KB + // left for builds, guaranteeing the oscillation above.) Defer and retry next loop; + // the build path's silent-restart defrag yields ~118 KB and passes this gate. + static constexpr size_t BLE_START_MIN_FREE_HEAP = 100 * 1024; + static bool deferralAnnounced = false; + if (wanted && !BleHid.isRunning() && (inCooldown || ESP.getFreeHeap() < BLE_START_MIN_FREE_HEAP)) { + static uint32_t lastGateLogMs = 0; + if (millis() - lastGateLogMs > 10000) { + lastGateLogMs = millis(); + LOG_INF("BLELC", "start deferred: heap %u floor %u cooldown=%d", ESP.getFreeHeap(), + (unsigned)BLE_START_MIN_FREE_HEAP, inCooldown ? 1 : 0); + } + // Tell the reader once per deferral episode that the remote is paused -- otherwise + // the only symptom is a remote that silently stopped working. Draws into the + // existing framebuffer (no heap); the next page render clears it via ghost cleanup. + if (!deferralAnnounced && activityManager.isReaderActivity() && BleHid.pairedCount() > 0) { + deferralAnnounced = true; + RenderLock renderLock; + GUI.drawPopup(renderer, tr(STR_BT_PAUSED_LOW_MEM_POPUP)); + activityManager.requestGhostCleanup(); + } + return; + } if (wanted && !BleHid.isRunning()) { LOG_INF("BLELC", "start requested enabled=%u reader=%d settings=%d wifi=%d paired=%u heap=%u maxAlloc=%u", SETTINGS.bluetoothEnabled, activityManager.isReaderActivity(), activityManager.currentKeepsBluetoothAlive(), @@ -515,6 +549,8 @@ void updateBluetoothLifecycle() { } LOG_INF("BLELC", "started paired=%u heap=%u maxAlloc=%u", BleHid.pairedCount(), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + recoveryTeardownMs = 0; // stack is back; clear the cool-down + deferralAnnounced = false; // re-announce if a later episode defers again HalPowerManager::Lock powerLock; const bool showReconnectPopup = activityManager.isReaderActivity() && !activityManager.currentKeepsBluetoothAlive() && BleHid.pairedCount() > 0; From 793685e76b0df75aa2759f9c5a56fa353e3fb9fd Mon Sep 17 00:00:00 2001 From: Nick <2506116+k5njm@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:09:28 -0500 Subject: [PATCH 4/5] feat: user path to resume BT from low-memory pause via reader menu toggle Field session: after a recovery teardown the heap settles at ~72 KB -- below the 100 KB start floor -- and never recovers on its own (the defrag silent-restart only fires when a build FAILS, and builds succeed now). BT stayed paused forever, the menu toggle claimed ON, and toggling off/on changed nothing. - Menu label tells the truth: ON / PAUSED (enabled but stack down) / OFF (new STR_STATE_PAUSED string) - Toggling BT on below the heap floor silent-restarts into the current book: the fresh boot's ~118 KB passes the gate and BT auto-starts on resume. Explicit user intent is the right trigger for the defrag. - Hoist the floor to bleinput::kStartMinFreeHeap, shared by the lifecycle gate and the toggle --- lib/I18n/translations/english.yaml | 2 ++ src/BleInput.h | 6 ++++++ .../reader/EpubReaderMenuActivity.cpp | 20 +++++++++++++++++-- src/main.cpp | 10 +++++----- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 38ad2652..a5f4949c 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -299,6 +299,8 @@ STR_BT_NO_PAIRED: "No paired devices" STR_BT_MAP_BUTTONS: "Map Remote Buttons" STR_BT_PRESS_REMOTE: "Press a button on your remote" STR_BT_CONNECTING_POPUP: "BT Connecting..." +STR_BT_PAUSED_LOW_MEM_POPUP: "BT paused (low memory)" +STR_STATE_PAUSED: "PAUSED" STR_BT_PAGE_FORWARD: "Page Forward" STR_BT_PAGE_BACK: "Page Back" STR_BT_FORGET_PROMPT: "Hold Confirm to forget" diff --git a/src/BleInput.h b/src/BleInput.h index 535822b9..85ec7858 100644 --- a/src/BleInput.h +++ b/src/BleInput.h @@ -25,6 +25,12 @@ namespace bleinput { // Advertised central name shown to peripherals during pairing. inline constexpr const char* kHostName = "CrossPoint"; +// Heap floor for starting the NimBLE stack (~57 KB) while leaving the reader's +// section-build pre-flight (40 KB) satisfiable afterwards. Shared by the main-loop +// lifecycle gate and the reader menu's toggle (which offers a defrag restart when a +// user turns BT on below the floor). +inline constexpr size_t kStartMinFreeHeap = 100 * 1024; + // Start the BLE HID host (idempotent). Returns false if BLE is compiled out or // NimBLE init failed. Safe to call repeatedly. bool ensureStarted(); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index dac06714..4d24b3e4 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -2,9 +2,12 @@ #include #include +#include +#include "BleInput.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" +#include "SilentRestart.h" #include "components/UITheme.h" #include "fontIds.h" @@ -84,6 +87,15 @@ void EpubReaderMenuActivity::loop() { // so start/stop has a single owner. SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1; SETTINGS.saveToFile(); + // Turning BT on below the lifecycle's heap floor would otherwise sit in PAUSED + // until the heap happens to recover -- which a long session's fragmentation never + // gives back. The user asked for BT *now*: silent-restart into this book to + // defrag (fresh boot is ~118 KB free, comfortably above the floor), and BT + // auto-starts on the way back in. + if (SETTINGS.bluetoothEnabled && !BleHid.isRunning() && ESP.getFreeHeap() < bleinput::kStartMinFreeHeap) { + LOG_INF("ERM", "BT enabled below heap floor (%u); silent restart to defrag", ESP.getFreeHeap()); + silentRestartToReader(); + } requestUpdate(); return; } @@ -138,8 +150,12 @@ void EpubReaderMenuActivity::render(RenderLock&&) { // Render current page turn value on the right edge of the content area. return pageTurnLabels[selectedPageTurnOption]; } else if (value == MenuAction::TOGGLE_BLUETOOTH) { - // Render current Bluetooth on/off state on the right edge. - return SETTINGS.bluetoothEnabled ? tr(STR_STATE_ON) : tr(STR_STATE_OFF); + // Render current Bluetooth state on the right edge. "Enabled but the stack + // isn't running" is the low-memory deferral -- show PAUSED, not a lie. + if (SETTINGS.bluetoothEnabled) { + return BleHid.isRunning() ? tr(STR_STATE_ON) : tr(STR_STATE_PAUSED); + } + return tr(STR_STATE_OFF); } else { return ""; } diff --git a/src/main.cpp b/src/main.cpp index d071de37..66ca4c61 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -514,18 +514,18 @@ void updateBluetoothLifecycle() { static constexpr uint32_t BLE_RESTART_COOLDOWN_MS = 30 * 1000; const bool inCooldown = recoveryTeardownMs != 0 && millis() - recoveryTeardownMs < BLE_RESTART_COOLDOWN_MS; // Heap gate: NimBLE needs ~57 KB, and the section-build pre-flight needs 40 KB free - // AFTER the stack is up -- so anything below ~100 KB just re-enters build recovery + // AFTER the stack is up -- so anything below the floor just re-enters build recovery // and tears BLE straight back down. (A first cut used 70 KB for restarts; 70-57=13 KB // left for builds, guaranteeing the oscillation above.) Defer and retry next loop; - // the build path's silent-restart defrag yields ~118 KB and passes this gate. - static constexpr size_t BLE_START_MIN_FREE_HEAP = 100 * 1024; + // the reader menu's toggle offers a defrag restart, and the build path's silent + // restart also yields ~118 KB and passes this gate. static bool deferralAnnounced = false; - if (wanted && !BleHid.isRunning() && (inCooldown || ESP.getFreeHeap() < BLE_START_MIN_FREE_HEAP)) { + if (wanted && !BleHid.isRunning() && (inCooldown || ESP.getFreeHeap() < bleinput::kStartMinFreeHeap)) { static uint32_t lastGateLogMs = 0; if (millis() - lastGateLogMs > 10000) { lastGateLogMs = millis(); LOG_INF("BLELC", "start deferred: heap %u floor %u cooldown=%d", ESP.getFreeHeap(), - (unsigned)BLE_START_MIN_FREE_HEAP, inCooldown ? 1 : 0); + (unsigned)bleinput::kStartMinFreeHeap, inCooldown ? 1 : 0); } // Tell the reader once per deferral episode that the remote is paused -- otherwise // the only symptom is a remote that silently stopped working. Draws into the From ad3138983ba714fd787ce78e47b625fdf36ea08d Mon Sep 17 00:00:00 2001 From: Nick <2506116+k5njm@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:17:50 -0500 Subject: [PATCH 5/5] fix: clear the BT-paused popup with an immediate redraw (toast semantics) The popup lingered until the next page turn. E-ink has no free timers -- clearing costs a refresh whenever it happens -- so request the redraw right away: the popup shows for the ~2 s page re-render, then clears. --- src/main.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 66ca4c61..69f00118 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -532,9 +532,15 @@ void updateBluetoothLifecycle() { // existing framebuffer (no heap); the next page render clears it via ghost cleanup. if (!deferralAnnounced && activityManager.isReaderActivity() && BleHid.pairedCount() > 0) { deferralAnnounced = true; - RenderLock renderLock; - GUI.drawPopup(renderer, tr(STR_BT_PAUSED_LOW_MEM_POPUP)); - activityManager.requestGhostCleanup(); + { + RenderLock renderLock; + GUI.drawPopup(renderer, tr(STR_BT_PAUSED_LOW_MEM_POPUP)); + activityManager.requestGhostCleanup(); + } + // Toast semantics: request a redraw so the popup clears after the (~2 s) page + // re-render instead of lingering until the next page turn. E-ink has no free + // timers -- clearing costs one refresh whenever it happens, so do it now. + activityManager.requestUpdate(); } return; }