diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 3c6171a1..1fe36220 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -302,6 +302,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/platformio.ini b/platformio.ini index 409f407c..8080b93a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -97,6 +97,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/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/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 5a238763..583263ec 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" @@ -888,6 +889,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; @@ -967,25 +977,45 @@ void EpubReaderActivity::render(RenderLock&& lock) { LOG_DBG("ERS", "Cache not found, building..."); } + // 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); + } + // 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). - // On build failure with BT enabled: free the BLE stack, retry the build, then - // restore it. The inflated HTML is cached after a successful build, so this - // recovery runs at most once per uncached chapter; BT reconnects in a few s. + // On build failure (or a pre-flight floor miss) with BT enabled: free the BLE stack + // and retry. The chapter is cached afterwards, so this recovery runs at most once + // per uncached chapter. + // 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. const auto retryWithBleFreed = [&](auto&& buildFn) { - LOG_INF("ERS", "Section build failed with Bluetooth on; freeing BLE RAM and retrying"); + LOG_INF("ERS", "Section build needs heap; freeing BLE RAM and retrying"); bleinput::setLifecyclePaused(true); bleinput::stop(); - const bool built = buildFn(); - 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(); - return built; + return buildFn(); + }; + + // 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. + const auto silentRestartDefrag = [&]() { + if (bootWasSilentRestart()) return; + LOG_ERR("ERS", "Section build failed after BLE recovery; silent restart to defrag heap"); + silentRestartToReader(); }; // Jumps that need the final pagination or the anchor map -- explicit page jumps, @@ -1013,11 +1043,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn); }; - bool built = buildSection(); + bool built = !heapTooLow && buildSection(); if (!built && SETTINGS.bluetoothEnabled) { built = retryWithBleFreed(buildSection); } if (!built) { + silentRestartDefrag(); LOG_ERR("ERS", "Failed to persist page data to SD"); section.reset(); showPendingSyncSaveError(); @@ -1065,11 +1096,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.focusReadingEnabled); }; - bool started = beginBuild(); + bool started = !heapTooLow && beginBuild(); if (!started && SETTINGS.bluetoothEnabled) { started = retryWithBleFreed(beginBuild); } if (!started) { + silentRestartDefrag(); LOG_ERR("ERS", "Failed to start section build"); section.reset(); showPendingSyncSaveError(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index dfe66ef1..8a9de1bb 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -59,6 +59,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; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index b475c261..477c859c 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" @@ -93,6 +96,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; } @@ -149,8 +161,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 3622413c..69f00118 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(); @@ -496,7 +503,47 @@ 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 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 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() < 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)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 + // 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(); + } + // 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; + } 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(), @@ -508,6 +555,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;