diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 1609ab3a..7a988547 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -68,6 +68,22 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c const char* asCStr(const std::string& s) { return s.c_str(); } const char* asCStr(const char* s) { return s; } +// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the +// current capacity. Freeing + reallocating slightly different sizes every page +// turn punches non-coalescing holes in the heap (the freed block rarely fits the +// next page's need), eroding the largest contiguous block all session. With +// reuse, capacities converge on the book's max page after a few turns and page +// turns stop touching the allocator. Only three small instantiations exist +// (interval/glyph/byte arrays), so template bloat is negligible. +template +bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) { + if (buf && capacity >= needed) return true; + delete[] buf; + buf = new (std::nothrow) T[needed > 0 ? needed : 1]; + capacity = buf ? static_cast(needed) : 0; + return buf != nullptr; +} + } // namespace SdCardFont::~SdCardFont() { freeAll(); } @@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) { s.miniBitmap = nullptr; s.miniIntervalCount = 0; s.miniGlyphCount = 0; + s.miniIntervalCapacity = 0; + s.miniGlyphCapacity = 0; + s.miniBitmapCapacity = 0; freeStyleMiniKern(s); memset(&s.miniData, 0, sizeof(s.miniData)); s.epdFont.data = &s.stubData; @@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) { s.miniKernRightEntryCount = 0; s.miniKernLeftClassCount = 0; s.miniKernRightClassCount = 0; + s.miniKernLeftCapacity = 0; + s.miniKernRightCapacity = 0; + s.miniKernMatrixCapacity = 0; } void SdCardFont::freeStyleAll(PerStyle& s) { @@ -311,13 +333,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++; } - // Step 4: allocate the three mini buffers. The matrix is <1KB in practice - // (<30 × <30 × 1 byte) so fragmentation is a non-issue. + // Step 4: size the three mini buffers (reused across pages when they fit; the + // per-page sizes vary by a few entries, which as free+realloc churn was punching + // non-coalescing holes in the heap every page turn). const uint32_t matrixBytes = static_cast(numLeft) * numRight; - s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount]; - s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount]; - s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes]; - if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) { + if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) || + !ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) || + !ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) { LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u, matrixBytes); freeStyleMiniKern(s); @@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 return missed; } - // Build mini intervals from sorted codepoints - freeStyleMiniData(s); + // Build mini intervals from sorted codepoints. Reset counts and fall back to the + // stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits + // reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter. + s.miniIntervalCount = 0; + s.miniGlyphCount = 0; + s.miniKernLeftEntryCount = 0; + s.miniKernRightEntryCount = 0; + s.miniKernLeftClassCount = 0; + s.miniKernRightClassCount = 0; + memset(&s.miniData, 0, sizeof(s.miniData)); + s.epdFont.data = &s.stubData; - uint32_t intervalCapacity = validCount; - s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity]; - if (!s.miniIntervals) { + if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) { LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); delete[] mappings; return static_cast(cpCount); @@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 } } - // Allocate mini glyph array - s.miniGlyphCount = validCount; - s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount]; - if (!s.miniGlyphs) { + // Mini glyph array (reused across pages when it fits) + if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) { LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); delete[] mappings; freeStyleMiniData(s); return static_cast(cpCount); } + s.miniGlyphCount = validCount; // Build sorted read order for sequential I/O uint32_t* readOrder = new (std::nothrow) uint32_t[validCount]; @@ -891,8 +919,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 totalBitmapSize += s.miniGlyphs[i].dataLength; } - s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1]; - if (!s.miniBitmap) { + if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) { LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx); delete[] readOrder; delete[] mappings; diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 443fee68..a54f5ffe 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -163,13 +163,22 @@ class SdCardFont { // Stub EpdFontData returned when not prewarmed EpdFontData stubData{}; - // Mini EpdFontData built during prewarm + // Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages + // (capacities below track allocated sizes): freeing and reallocating slightly + // different sizes on every page turn was a primary heap fragmenter — each page's + // freed hole rarely fit the next page's need, so maxAlloc eroded all session. + // After a few pages the capacities converge on the book's max and page turns + // stop allocating entirely. freeStyleMiniData() still releases everything (and + // zeroes capacities) for style eviction / font unload. EpdFontData miniData{}; EpdUnicodeInterval* miniIntervals = nullptr; EpdGlyph* miniGlyphs = nullptr; uint8_t* miniBitmap = nullptr; uint32_t miniIntervalCount = 0; uint32_t miniGlyphCount = 0; + uint32_t miniIntervalCapacity = 0; + uint32_t miniGlyphCapacity = 0; + uint32_t miniBitmapCapacity = 0; // Per-page mini kern matrix (built by buildMiniKernMatrix on each full // prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints @@ -184,6 +193,10 @@ class SdCardFont { uint8_t miniKernLeftClassCount = 0; uint8_t miniKernRightClassCount = 0; int8_t* miniKernMatrix = nullptr; + // Kept-if-fits capacities, same rationale as the mini glyph buffers above. + uint16_t miniKernLeftCapacity = 0; + uint16_t miniKernRightCapacity = 0; + uint32_t miniKernMatrixCapacity = 0; // The EpdFont whose data pointer we manage EpdFont epdFont{&stubData}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index bacf1d94..ed4f9860 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -257,6 +257,27 @@ 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. If the BLE stack is what's squeezing the heap, shed it — the + // established policy on this branch is that builds and resident BLE don't coexist, + // and this was the one build path without that protection (field crash: a tick's + // parse allocation aborted at maxAlloc ~11 KB with BLE resident). The lifecycle's + // build-pending deferral keeps BLE down until the window is caught up, then + // restarts it behind the start floor. Without BLE resident, just wait: page-turn + // transients free up between turns and the tick retries every loop pass. + if (BleHid.isRunning()) { + LOG_INF("ERS", "Background build needs heap (free=%u maxAlloc=%u); freeing BLE RAM", (unsigned)freeHeap, + (unsigned)maxBlock); + bleinput::stop(); + } + return false; +} + void EpubReaderActivity::loop() { if (!epub) { // Should never happen @@ -269,12 +290,10 @@ 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. 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). + // isBuilding() under the lock since render() may have just finished it. if (section && section->isBuilding() && !RenderLock::peek() && static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && - ESP.getFreeHeap() >= BACKGROUND_BUILD_MIN_FREE_HEAP) { + 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 @@ -1273,6 +1292,10 @@ 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); + // Fragmentation tracker: free vs largest block after every page. A falling + // maxAlloc/free ratio across pages points at whichever allocation pattern the + // preceding lines show (mini rebuilds, kern reloads, BLE churn). + LOG_DBG("MEM", "post-render: free=%u maxAlloc=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap()); } saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages()); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index ceb4584d..f086fa7b 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -90,6 +90,17 @@ class EpubReaderActivity final : public Activity { // 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. + // When BLE is what's squeezing the heap, sheds it (build-pending deferral in the + // lifecycle then holds restarts off until the window is caught up) instead of + // stalling the build forever below the floors. + bool buildTickHeapGate(); // 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