Add heap pressure management for BLE and rendering

Prevent OOM aborts by shedding BLE stack when free heap drops below 24KB before rendering. Lower BLE start threshold from 100KB to 80KB to match steady-state heap levels (~84KB). Reserve vector capacity upfront in page parsing to avoid reallocation crashes on fragmented heaps. Gate background section builds on heap availability.
This commit is contained in:
Justin Mitchell
2026-07-06 08:12:11 -04:00
parent 0d7a84e3c5
commit 613371b716
6 changed files with 83 additions and 10 deletions
+16 -5
View File
@@ -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.
+19 -2
View File
@@ -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<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
static_cast<int>(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;
@@ -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> 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
@@ -7,6 +7,7 @@
#include <cstdio>
#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());
+18 -3
View File
@@ -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;