Merge branch 'pr-2527' into feat-bluetooth

# Conflicts:
#	src/activities/reader/EpubReaderActivity.cpp
This commit is contained in:
Justin Mitchell
2026-07-06 02:10:55 -04:00
8 changed files with 136 additions and 18 deletions
+48 -16
View File
@@ -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();
@@ -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> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
@@ -2,9 +2,12 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
#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 "";
}