diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 5032056c..261ea6a6 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -148,6 +148,10 @@ std::unique_ptr Page::deserialize(HalFile& file) { uint16_t count; serialization::readPod(file, count); + // Reserve up front: growth-by-doubling needs old + new capacity live at once and + // reallocates repeatedly — a field crash (bad_alloc -> abort under -fno-exceptions) + // hit exactly this append path on a heavily fragmented heap. + page->elements.reserve(count); for (uint16_t i = 0; i < count; i++) { uint8_t tag; diff --git a/src/BleInput.h b/src/BleInput.h index 85ec7858..cadb6e7b 100644 --- a/src/BleInput.h +++ b/src/BleInput.h @@ -25,11 +25,22 @@ 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; +// Heap floor for starting the NimBLE stack (measured begin() cost: ~52 KB). This must +// be reachable in steady-state reading, not just at fresh boot: with an SD font and a +// loaded section, mid-session idle heap is ~84 KB — a 100 KB floor was only ever +// passed via a stale-gate bug (heap sampled before the render lock), and once that +// was fixed the stack could never start again after a shed. 80 KB starts at ~84 KB +// steady state and leaves ~32 KB of reading slack; the render shed floor +// (RENDER_MIN_FREE_HEAP, 24 KB) backstops the tight sessions, and the lifecycle +// cooldown paces any shed/restart cycle. Shared by the main-loop lifecycle gate and +// the reader menu's toggle (which offers a defrag restart below the floor). +inline constexpr size_t kStartMinFreeHeap = 80 * 1024; + +// Lower floor for the Bluetooth settings screen, where the user has explicitly asked +// for BLE right now (scanning/pairing is dead without the stack). No page renders or +// section builds run there, so the reader-sized reserve above doesn't apply — only +// NimBLE's own ~57 KB plus working margin. +inline constexpr size_t kStartMinFreeHeapExplicit = 70 * 1024; // Start the BLE HID host (idempotent). Returns false if BLE is compiled out or // NimBLE init failed. Safe to call repeatedly. diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 583263ec..bacf1d94 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -269,9 +269,12 @@ void EpubReaderActivity::loop() { // RenderLock and locked out page turns. The build follows the reader instead, and instant // reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit. // Skip while the render mutex is busy so we never delay a pending render; re-check - // isBuilding() under the lock since render() may have just finished it. + // isBuilding() under the lock since render() may have just finished it. Also skip + // while free heap is below the floor — the tick is deferrable, and parsing into a + // starved heap abort()s (see BACKGROUND_BUILD_MIN_FREE_HEAP). if (section && section->isBuilding() && !RenderLock::peek() && - static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) { + static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && + ESP.getFreeHeap() >= BACKGROUND_BUILD_MIN_FREE_HEAP) { RenderLock lock; // Re-check under the lock: render() (which also holds the RenderLock) may have finalized the // build between the outer isBuilding() check and acquiring the lock here, in which case @@ -898,6 +901,20 @@ void EpubReaderActivity::render(RenderLock&& lock) { ~LifecycleUnpause() { bleinput::setLifecyclePaused(false); } } lifecycleUnpause; + // Shed the BLE stack before rendering into a starved heap. Everything below — + // page deserialization, glyph caching, catch-up build steps — allocates through + // throwing paths that abort() on OOM under -fno-exceptions. Field data: with no + // shed, a session ground to <2.2 KB free (per-glyph SD fallbacks, no AA, 4.7 s + // pages) and then aborted on a tiny vector growth. Freeing BLE returns ~52 KB and + // restores a large contiguous block; the lifecycle restarts it behind its heap + // gate once the pressure passes. + if (BleHid.isRunning() && ESP.getFreeHeap() < RENDER_MIN_FREE_HEAP) { + LOG_ERR("ERS", "Render heap %u below floor %u; freeing BLE RAM", (unsigned)ESP.getFreeHeap(), + (unsigned)RENDER_MIN_FREE_HEAP); + bleinput::setLifecyclePaused(true); + bleinput::stop(); + } + const auto showPendingSyncSaveError = [this]() { if (!pendingSyncSaveError) return; pendingSyncSaveError = false; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8a9de1bb..ceb4584d 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -67,6 +67,13 @@ class EpubReaderActivity final : public Activity { // (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; + // Heap floor for rendering a page at all. Page deserialization (TextBlock word + // vectors/strings) and glyph caching allocate through throwing paths that abort() + // on OOM; below this floor render() sheds the BLE stack (~52 KB back, and it + // restores a large contiguous block) before touching the page. Field data: a + // session with no shed ground to <2.2 KB free and aborted on a page load. + static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024; + void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar() const; @@ -75,6 +82,14 @@ class EpubReaderActivity final : public Activity { // background build chunk never noticeably delays input or a pending render. static constexpr int BUILD_PAGES_PER_CHUNK = 8; static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2; + + // Skip background build ticks below this free-heap floor. The parse path grows + // word vectors of heap strings — throwing allocations that abort() on OOM under + // -fno-exceptions (field crash: bad_alloc in ParsedText::addWord during a + // background tick with the BLE stack resident). The tick is deferrable work: + // page-turn transients free up between turns and the build resumes; the render + // path still builds the page it actually needs regardless of this floor. + static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024; // How many pages to keep laid out ahead of the reader for a still-building section. A page // turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder // -- a tiny buffer is enough. The background build stops once the watermark is this far diff --git a/src/activities/settings/BluetoothSettingsActivity.cpp b/src/activities/settings/BluetoothSettingsActivity.cpp index a1d2aeec..1a608b5c 100644 --- a/src/activities/settings/BluetoothSettingsActivity.cpp +++ b/src/activities/settings/BluetoothSettingsActivity.cpp @@ -7,6 +7,7 @@ #include #include "BleButtonMapActivity.h" +#include "BleInput.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" @@ -57,6 +58,13 @@ void BluetoothSettingsActivity::startScanView() { awaitingConnect = false; lastLoggedScanState = false; lastLoggedDeviceCount = 0xFF; + // The main-loop lifecycle owns steady-state start/stop, but a scan needs the stack + // this instant — entering this screen can precede the lifecycle's next tick, or its + // heap gate may have deferred the start. ensureStarted() is idempotent, and with + // this screen on top the lifecycle keeps the stack up (keepsBluetoothAlive). + if (!BleHid.isRunning() && !bleinput::ensureStarted()) { + LOG_ERR("BLEUI", "scan: BLE start failed (heap=%u)", ESP.getFreeHeap()); + } BleHid.startScan(kScanMs); LOG_INF("BLEUI", "scan view: startScan requested scanning=%d devices=%u", BleHid.isScanning(), BleHid.deviceCount()); requestUpdate(); @@ -185,6 +193,9 @@ void BluetoothSettingsActivity::loop() { const int scanCount = BleHid.deviceCount(); if (!awaitingConnect && scanCount == 0 && !BleHid.isScanning()) { LOG_INF("BLEUI", "scan view: restart scan requested"); + if (!BleHid.isRunning() && !bleinput::ensureStarted()) { + LOG_ERR("BLEUI", "scan restart: BLE start failed (heap=%u)", ESP.getFreeHeap()); + } BleHid.startScan(kScanMs); LOG_INF("BLEUI", "scan view: restart scan state scanning=%d devices=%u", BleHid.isScanning(), BleHid.deviceCount()); diff --git a/src/main.cpp b/src/main.cpp index 69f00118..ce7ba38c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -520,12 +520,18 @@ void updateBluetoothLifecycle() { // 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)) { + // The BT settings screen is explicit user intent to run BLE right now (scan/pair is + // dead without the stack). Its floor only needs to cover NimBLE itself — the 100 KB + // reader floor reserves build/render headroom that never gets used there — and the + // cooldown protects the reader's build-recovery loop, which can't occur in settings. + const bool explicitBtContext = activityManager.currentKeepsBluetoothAlive(); + const size_t startFloor = explicitBtContext ? bleinput::kStartMinFreeHeapExplicit : bleinput::kStartMinFreeHeap; + if (wanted && !BleHid.isRunning() && ((inCooldown && !explicitBtContext) || ESP.getFreeHeap() < startFloor)) { 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); + LOG_INF("BLELC", "start deferred: heap %u floor %u cooldown=%d", ESP.getFreeHeap(), (unsigned)startFloor, + 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 @@ -549,6 +555,15 @@ void updateBluetoothLifecycle() { SETTINGS.bluetoothEnabled, activityManager.isReaderActivity(), activityManager.currentKeepsBluetoothAlive(), WiFi.getMode(), BleHid.pairedCount(), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); RenderLock renderLock; + // Re-check the floor under the lock: acquiring the RenderLock waits out any + // in-flight render, and a chapter open during that render consumes tens of KB — + // observed in the field as the gate passing at 116 KB free and begin() then + // landing on 44 KB, where the session ground down and aborted. Defer to the next + // tick; the gate re-evaluates against the settled heap. + if (ESP.getFreeHeap() < startFloor || bleinput::lifecyclePaused()) { + LOG_INF("BLELC", "start aborted under render lock: heap %u floor %u", ESP.getFreeHeap(), (unsigned)startFloor); + return; + } if (!bleinput::ensureStarted()) { LOG_ERR("BLELC", "start failed heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); return;