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
This commit is contained in:
Nick
2026-07-02 17:24:29 -05:00
parent 06aa1ac2f7
commit 74a0969cc6
2 changed files with 38 additions and 12 deletions
+30 -12
View File
@@ -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
@@ -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> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;