Fix heap fragmentation issues

- Keep-if-fits font buffer reuse (SdCardFont), the page-turn fragmentation fix
- Background-build heap floors + buildTickHeapGate() (the ParsedText::addWord abort fix), with the BLE-shed branch removed
- KOSync TLS gate split (free ≥ 50K, largest block ≥ 20K)
- wolfSSL SP ECC flags + FP_MAX_BITS 8192 in the patch script
- custom_sdkconfig: timer-stack trims (~7KB) and, per your answer, the WiFi IRAM opts (~25-30KB); plus the cloud-component removal the hybrid build requires. All verified present in the generated sdkconfig after the build.
- Web server watchdog registration fix (this branch's handlers already call esp_task_wdt_reset without it)
This commit is contained in:
Justin Mitchell
2026-07-14 15:45:44 -04:00
parent e66de575a1
commit 7cad3cfb0e
14 changed files with 249 additions and 57 deletions
+48 -9
View File
@@ -257,6 +257,17 @@ void EpubReaderActivity::openReaderMenu() {
});
}
bool EpubReaderActivity::buildTickHeapGate() {
const size_t freeHeap = ESP.getFreeHeap();
const size_t maxBlock = ESP.getMaxAllocHeap();
if (freeHeap >= BACKGROUND_BUILD_MIN_FREE_HEAP && maxBlock >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
return true;
}
// Below the floors: just wait. The tick is deferrable — page-turn transients
// free up between turns and the tick retries every loop pass.
return false;
}
void EpubReaderActivity::loop() {
if (!epub) {
// Should never happen
@@ -264,6 +275,35 @@ void EpubReaderActivity::loop() {
return;
}
// Idle glyph prewarm for the likely next page (currentPage + 1). The scan
// pass draws nothing (FCM scan mode suppresses pixels), so the displayed
// framebuffer is untouched; endScanAndPrewarm loads only glyphs not already
// cached. Debounced past rapid page-flipping, one attempt per position, and
// deferred while a render/build owns the CPU or the heap is at the render
// floor. Cross-chapter prewarm is deliberately out of scope (next spine's
// section isn't loaded).
constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400;
if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() &&
lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS &&
ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP &&
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
idlePrewarmSpine = currentSpineIndex;
idlePrewarmPage = section->currentPage;
const int nextPage = section->currentPage + 1;
if (nextPage < static_cast<int>(section->pageCount)) {
RenderLock lock; // the page table must not change under the scan
if (const auto p = section->loadPage(nextPage)) {
if (auto* fcm = renderer.getFontCacheManager()) {
const auto t0 = millis();
auto scope = fcm->createPrewarmScope();
p->render(renderer, SETTINGS.getReaderFontId(), 0, 0); // scan only, no pixels
scope.endScanAndPrewarm();
LOG_DBG("ERS", "Idle prewarm: page %d in %lums", nextPage, millis() - t0);
}
}
}
}
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
// session, so reopening a partial deliberately does NOT start it (see the deferral in
@@ -299,7 +339,8 @@ void EpubReaderActivity::loop() {
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
if (section && section->isBuilding() && !RenderLock::peek() &&
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD)) {
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) &&
buildTickHeapGate()) {
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
@@ -1282,6 +1323,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
lastRenderCompleteMs = millis();
}
// Only persist when the position actually changed. render() also runs on menu,
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
@@ -1400,12 +1442,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// HALF ghost-cleanup path, which drives every pixel to its target
// regardless of residue.
pagesUntilFullRefresh = 1;
} else if (overlapRefresh) {
// Async: start the waveform and return so the grayscale plane rendering
// below overlaps the panel's refresh time instead of following it.
ReaderUtils::displayWithRefreshCycleAsync(renderer, pagesUntilFullRefresh);
} else {
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
// Async form: start the waveform and return so the grayscale plane rendering
// below overlaps the panel's refresh time instead of following it.
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, overlapRefresh);
}
const auto tDisplay = millis();
@@ -1444,9 +1484,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// so the pass never starves concurrent allocations. Blocking panels skip
// the buffers entirely (nothing to overlap).
auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
auto msbPlaneBuf = (lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000)
? makeUniqueNoThrow<uint8_t[]>(planeBytes)
: nullptr;
auto msbPlaneBuf =
(lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
if (lsbPlaneBuf) {
renderPlaneToBuffer(true, lsbPlaneBuf.get());
@@ -41,6 +41,13 @@ class EpubReaderActivity final : public Activity {
bool showBookmarkMessage = false;
bool ignoreNextConfirmRelease = false;
bool currentPageBookmarked = false;
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
// SD reads on the page-turn critical path. One attempt per position.
int idlePrewarmSpine = -1;
int idlePrewarmPage = -1;
unsigned long lastRenderCompleteMs = 0;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
std::vector<BookmarkEntry> cachedBookmarks;
// Tracks whether this book is currently removed from Recent Books by the
@@ -86,6 +93,27 @@ 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;
// MEMFIX-PORT: background-build heap floor; portable
// 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 under heap pressure). 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;
// Fragmentation floor for the same gate: a tick passed the free-heap floor at
// 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the
// tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether
// any single allocation can actually have it. 16 KB also keeps the advance-table
// batch path (16 KB scratch) viable during builds.
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024;
// Gate for a background build tick: true when the heap can take parse allocations.
bool buildTickHeapGate();
// Heap floor for optional render-adjacent work (idle prewarm). Page
// deserialization (TextBlock word vectors/strings) and glyph caching allocate
// through throwing paths that abort() on OOM; skip deferrable work below it.
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 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
+10 -15
View File
@@ -59,26 +59,21 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
return {prev, next, tiltPrev || tiltNext};
}
inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) {
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
// One helper, blocking or deferred: the async form starts the refresh and
// returns so the caller can overlap CPU work with the panel's refresh time.
// Async callers must not touch the framebuffer until
// renderer.waitRefreshComplete() and must rebuild the differential baseline
// before the next page turn (the tiled grayscale cleanup does).
inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh, bool async = false) {
const auto mode = (pagesUntilFullRefresh <= 1) ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH;
if (async) {
renderer.displayBufferAsync(mode);
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
renderer.displayBuffer(mode);
}
}
// Async variant: starts the refresh and returns so the caller can overlap CPU
// work with the panel's refresh time. Caller must not touch the framebuffer
// until renderer.waitRefreshComplete() and must rebuild the differential
// baseline before the next page turn (the tiled grayscale cleanup does).
inline void displayWithRefreshCycleAsync(const GfxRenderer& renderer, int& pagesUntilFullRefresh) {
if (pagesUntilFullRefresh <= 1) {
renderer.displayBufferAsync(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBufferAsync();
pagesUntilFullRefresh--;
}
}
+17
View File
@@ -200,6 +200,14 @@ void CrossPointWebServer::begin() {
udpActive = udp.begin(LOCAL_UDP_PORT);
LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
// All request handlers run on the task that calls handleClient(). Register
// that task before any handler can call esp_task_wdt_reset().
const esp_err_t watchdogResult = esp_task_wdt_add(nullptr);
watchdogTaskRegistered = watchdogResult == ESP_OK;
if (!watchdogTaskRegistered) {
LOG_ERR("WEB", "Failed to register web server task with watchdog: %s", esp_err_to_name(watchdogResult));
}
running = true;
LOG_DBG("WEB", "Web server started on port %d", port);
@@ -229,6 +237,10 @@ void CrossPointWebServer::abortWsUpload(const char* tag) {
void CrossPointWebServer::stop() {
if (!running || !server) {
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
if (watchdogTaskRegistered) {
esp_task_wdt_delete(nullptr);
watchdogTaskRegistered = false;
}
return;
}
@@ -269,6 +281,11 @@ void CrossPointWebServer::stop() {
LOG_DBG("WEB", "Web server stopped and deleted");
LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
if (watchdogTaskRegistered) {
esp_task_wdt_delete(nullptr);
watchdogTaskRegistered = false;
}
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload
LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
+1
View File
@@ -72,6 +72,7 @@ class CrossPointWebServer {
std::unique_ptr<WebServer> server = nullptr;
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
bool running = false;
bool watchdogTaskRegistered = false;
bool apMode = false; // true when running in AP mode, false for STA mode
uint16_t port = 80;
uint16_t wsPort = 81; // WebSocket port