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:
@@ -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
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user