From 7cad3cfb0efde4442aef4aff9ad26d8d0c84f47d Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 14 Jul 2026 15:45:44 -0400 Subject: [PATCH] Fix heap fragmentation issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 6 ++ freeink-sdk | 2 +- lib/EpdFont/SdCardFont.cpp | 61 ++++++++++++++------ lib/EpdFont/SdCardFont.h | 15 ++++- lib/GfxRenderer/GfxRenderer.h | 8 +-- lib/KOReaderSync/KOReaderSyncClient.cpp | 22 +++++-- lib/hal/HalDisplay.h | 8 +-- platformio.ini | 50 +++++++++++++++- scripts/patch_wolfssl.py | 6 +- src/activities/reader/EpubReaderActivity.cpp | 57 +++++++++++++++--- src/activities/reader/EpubReaderActivity.h | 28 +++++++++ src/activities/reader/ReaderUtils.h | 25 ++++---- src/network/CrossPointWebServer.cpp | 17 ++++++ src/network/CrossPointWebServer.h | 1 + 14 files changed, 249 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 6bd12537..b493ee4b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ lib/EpdFont/scripts/output/ # (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out. .claude/* !.claude/skills/ +/managed_components +/.dummy +CMakeLists.txt +dependencies.lock +sdkconfig.default +sdkconfig.defaults diff --git a/freeink-sdk b/freeink-sdk index e7d33619..4350bc7a 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit e7d336191b5e45aa0ea9908fda7294fdd82a60b5 +Subproject commit 4350bc7a0e1fa7ff52c0cf7671b535d55a939535 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/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 85a3547b..9c3d0878 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -138,13 +138,13 @@ class GfxRenderer { // Non-blocking refresh: starts the waveform and returns so CPU work (e.g. // grayscale strip rendering) can overlap the panel's refresh time. The // framebuffer must stay untouched until waitRefreshComplete(). Falls back to - // a blocking refresh when fadingFix is enabled or the panel lacks async + // a blocking refresh when fadingFix is enabled or the panel lacks deferral // support. See HalDisplay::displayBufferAsync for the baseline contract. void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; void waitRefreshComplete() const; - // True when displayBufferAsync() genuinely overlaps: panel has real async - // support and fadingFix isn't forcing the blocking path. Callers can skip - // overlap scaffolding (e.g. whole-plane grayscale buffers) when false. + // True when displayBufferAsync() genuinely overlaps: panel defers and + // fadingFix isn't forcing the blocking path. Callers can skip overlap + // scaffolding (e.g. whole-plane grayscale buffers) when false. bool supportsAsyncRefresh() const; // EXPERIMENTAL: Windowed update - display only a rectangular region // void displayWindow(int x, int y, int width, int height) const; diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index d8395366..2c20054e 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -22,7 +22,21 @@ constexpr char DEVICE_ID[] = "crosspoint-reader"; // footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative // floor. Check both total free heap and largest contiguous block so fragmented // heap does not fall through into a failed TLS allocation path. -constexpr uint32_t MIN_HEAP_FOR_TLS = 55000; +// MEMFIX-PORT: TLS heap gate; portable +// Field data (July 2026): launching sync from a reader session lands at +// 51.9-58.2 KB free / 42-53 KB maxAlloc after WiFi comes up. wolfSSL handles +// allocation failure by returning MEMORY_E (no abort under -fno-exceptions), +// so an optimistic attempt degrades to the same clean "sync failed" as the +// gate — the gate only needs to keep out states where a doomed handshake +// would waste tens of seconds, not guarantee success. +// +// Free and largest-block have separate requirements: with SP ECC +// (WOLFSSL_HAVE_SP_ECC) the handshake's crypto uses fixed 256-bit arrays, so +// the largest single TLS allocation is the ~17 KB wolfSSL record buffer, not +// a run of fast-math bignums. A handshake was measured succeeding inside a +// 43 KB largest block; requiring 50 KB contiguous refused syncs that fit. +constexpr uint32_t MIN_FREE_FOR_TLS = 50000; +constexpr uint32_t MIN_BLOCK_FOR_TLS = 20000; // Apply the shared KOSync auth headers after begin(). x-auth-* is the native // KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility. @@ -39,9 +53,9 @@ void applyAuthHeaders(freeink::SecureHttpClient& http) { bool insufficientHeap() { const uint32_t freeHeap = ESP.getFreeHeap(); const uint32_t maxAllocHeap = ESP.getMaxAllocHeap(); - if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) { - LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap, - maxAllocHeap, MIN_HEAP_FOR_TLS); + if (freeHeap < MIN_FREE_FOR_TLS || maxAllocHeap < MIN_BLOCK_FOR_TLS) { + LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u), %u max alloc (need %u)", freeHeap, + MIN_FREE_FOR_TLS, maxAllocHeap, MIN_BLOCK_FOR_TLS); return true; } return false; diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 4387de14..34e74a62 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -43,12 +43,12 @@ class HalDisplay { // while the panel refreshes on its own. The framebuffer must stay untouched // until waitRefreshComplete(), and the caller must rebuild the differential // baseline before the next differential update (the tiled grayscale cleanup - // does). Panels without async support fall back to a blocking refresh. + // does). Panels without deferral fall back to a blocking refresh. void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH); - // Block until a pending async refresh completes (no-op when none is). + // Block until a pending deferred refresh completes (no-op when none is). void waitRefreshComplete(); - // True when displayBufferAsync() genuinely overlaps (panel driver has real - // async support); false where it falls back to a blocking refresh. + // True when displayBufferAsync() genuinely overlaps (panel driver defers); + // false where it falls back to a blocking refresh. bool supportsAsyncRefresh() const; void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); diff --git a/platformio.ini b/platformio.ini index 89b3762d..63174c08 100644 --- a/platformio.ini +++ b/platformio.ini @@ -42,7 +42,14 @@ build_flags = -DWOLFSSL_OPTIONS_H -DWOLFSSL_CLIENT_EXAMPLE -DWOLFSSL_TLS13 - -DWOLFSSL_SP_RISCV32 +# MEMFIX-PORT: single-precision ECC (sp_c32.c). Without it every P-256 operation +# (TLS 1.3 key_share keygen, ECDHE, ECDSA cert verify) runs on fast-math bignums +# that WOLFSSL_SMALL_STACK heap-allocates at FP_MAX_BITS size -- tens of KB of +# temporaries, which OOMs (MP_MEM) at the ~50KB free heap a reading session +# leaves. SP uses fixed 256-bit arrays: a few KB, and several times faster. +# SP_SMALL trades the large precomputed point tables for smaller flash. + -DWOLFSSL_HAVE_SP_ECC + -DWOLFSSL_SP_SMALL -DHAVE_TLS_EXTENSIONS -DHAVE_SUPPORTED_CURVES -DHAVE_HKDF @@ -63,6 +70,47 @@ board_build.flash_mode = dio board_build.flash_size = 16MB board_build.partitions = partitions.csv +; MEMFIX-PORT: custom_sdkconfig heap reclamation (~32-37 KB). Rebuilds the +; Arduino core libs on first build (slower once, cached after; needs the CMake +; pin in platformio.local.ini on macOS). +; +; If an interrupted rebuild fails with "multiple definition of 'app_main'" +; (stale generated scaffold), clean it up with: +; rm -rf .dummy CMakeLists.txt sdkconfig.default sdkconfig.defaults .pio/build/default +; Do NOT use `git clean -fdX` — it deletes platformio.local.ini. +custom_sdkconfig = + ; Task stack right-sizing from measured high-water marks (heap block map + + ; per-task stack audit, July 2026): esp_timer used ~0.8 KB of 8 KB across + ; every capture; the FreeRTOS timer service used ~0.5 KB + ; of 4 KB. Neither runs TLS or app code. ~7 KB back to the heap. + CONFIG_ESP_TIMER_TASK_STACK_SIZE=4096 + CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2560 + ; Move the WiFi stack's non-critical hot paths out of IRAM into flash. + ; On the C3, IRAM and DRAM share one SRAM pool, so the ~25-30 KB this + ; frees lands directly in the heap — paid for with lower WiFi throughput + ; during transfers (occasional sync/OTA use, not streaming: acceptable). + ; IRAM cost is static, so the heap gain applies even with WiFi off. + CONFIG_ESP_WIFI_IRAM_OPT=n + CONFIG_ESP_WIFI_RX_IRAM_OPT=n + ; Keep the Arduino wrappers for the removed cloud components (below) out of + ; the core source list; all other bundled libraries default to enabled. + CONFIG_ARDUINO_SELECTIVE_COMPILATION=y + CONFIG_ARDUINO_SELECTIVE_RainMaker=n + CONFIG_ARDUINO_SELECTIVE_Insights=n + +; Drop unused cloud components from the core rebuild. esp_insights/rainmaker +; require embedded server certs the lib builder can't generate +; ("https_server.crt.S not found"); this firmware uses none of them. +custom_component_remove = + espressif/esp_insights + espressif/esp_rainmaker + espressif/esp_diagnostics + espressif/esp_diag_data_store + espressif/esp_schedule + espressif/esp_rcp_update + espressif/esp_secure_cert_mgr + espressif/cbor + extra_scripts = pre:scripts/patch_wolfssl.py pre:scripts/build_html.py diff --git a/scripts/patch_wolfssl.py b/scripts/patch_wolfssl.py index 95b86964..b905e879 100644 --- a/scripts/patch_wolfssl.py +++ b/scripts/patch_wolfssl.py @@ -12,8 +12,12 @@ OVERRIDES = f""" #ifndef HAVE_FFDHE_2048 #define HAVE_FFDHE_2048 #endif +/* MEMFIX-PORT: 8192 handles up to RSA-4096 keys (the public-CA maximum, + ISRG Root X1 included) with half the per-bignum heap of 16384: with + WOLFSSL_SMALL_STACK each fast-math temp is FP_MAX_BITS/8 * 2 bytes on the + heap, and TLS cert verification allocates dozens at once. */ #undef FP_MAX_BITS -#define FP_MAX_BITS 16384 +#define FP_MAX_BITS 8192 """ diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 64bc3599..99738b1d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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(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(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD)) { + (section->isPartial() || static_cast(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, 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, const int or // so the pass never starves concurrent allocations. Blocking panels skip // the buffers entirely (nothing to overlap). auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow(planeBytes) : nullptr; - auto msbPlaneBuf = (lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) - ? makeUniqueNoThrow(planeBytes) - : nullptr; + auto msbPlaneBuf = + (lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow(planeBytes) : nullptr; if (lsbPlaneBuf) { renderPlaneToBuffer(true, lsbPlaneBuf.get()); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index e8799018..954d1698 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -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 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 diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 26a0cf23..8866635e 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -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--; } } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index fbee83da..28f3ba57 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -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()); diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index fc1aaf6e..5d5e0048 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -72,6 +72,7 @@ class CrossPointWebServer { std::unique_ptr server = nullptr; std::unique_ptr 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