Reduce TLS memory requirements for KOReader sync

Split heap gate into separate free-memory (50KB) and largest-block (20KB) thresholds based on field measurements. Enable wolfSSL single-precision ECC to use fixed 256-bit arrays instead of heap-allocated fast-math bignums, reducing TLS handshake memory footprint. Reclaim ~7KB by right-sizing ESP timer task stacks and move WiFi code out of IRAM to free ~25-30KB for heap. Add memory audit landmarks for font and EPUB allocations.
This commit is contained in:
Justin Mitchell
2026-07-14 14:53:41 -04:00
parent 02398c8a96
commit a86a644adc
16 changed files with 200 additions and 75 deletions
+5
View File
@@ -369,6 +369,11 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8
}
stats.pageBufferBytes += totalBytes;
stats.pageGlyphsBytes += glyphCount * sizeof(PageGlyphEntry);
// MEMFIX-PORT: page-slot address landmark for the heap map; portable
// Landmark for the heap block map: page slots are the largest flash-font
// allocations and otherwise show up as anonymous ~4-20 KB used blocks.
LOG_DBG("FDC", "page slot buffer=%p bytes=%u glyphs=%u", static_cast<void*>(slot.buffer), (unsigned)totalBytes,
(unsigned)glyphCount);
slot.fontData = fontData;
slot.glyphCount = glyphCount;
+1
View File
@@ -104,6 +104,7 @@ class SdCardFont {
uint32_t uniqueGlyphs = 0;
uint32_t bitmapBytes = 0;
};
// MEMFIX-PORT: SD font resident-bytes audit; portable
// Log per-style resident heap (full tables + kept-if-fits mini arenas +
// advance tables + overflow bitmaps) and return the total in bytes. Pure
// accounting — no allocation, no state change.
+1
View File
@@ -32,6 +32,7 @@ class SdCardFontManager {
// Get name of currently loaded family (empty if none).
const std::string& currentFamilyName() const { return loadedFamilyName_; };
// MEMFIX-PORT: font manager audit passthrough; portable
// Sum of loaded fonts' resident heap (see SdCardFont::reportMemory).
size_t reportMemory() const;
+1
View File
@@ -44,6 +44,7 @@ class Epub {
}
~Epub() = default;
std::string& getBasePath() { return contentBasePath; }
// MEMFIX-PORT: epub resident-bytes audit accessor; portable
// Approximate resident heap of the open book (audit): path strings, the CSS
// file list, and the parsed stylesheet. BookMetadataCache is file-backed
// (counts + HalFile handles) and contributes little.
+1
View File
@@ -132,6 +132,7 @@ class Section {
// (covers finalized sections and partials from a previous session).
std::optional<uint16_t> findAnchor(const std::string& anchor) const;
// MEMFIX-PORT: section resident-bytes audit accessor; portable
// Approximate resident heap for the audit log. Steady state (no build) a
// Section holds little beyond itself; during a build the page LUT and path
// strings dominate (the parser's internal footprint is not walked here).
+1
View File
@@ -67,6 +67,7 @@ class CssParser {
*/
[[nodiscard]] static CssStyle parseInlineStyle(std::string_view styleValue);
// MEMFIX-PORT: stylesheet resident-bytes audit accessor; portable
// Approximate resident heap of the parsed stylesheet, for the audit log.
// unordered_map cost model: bucket array + one node per rule (libstdc++ node
// overhead ~= 2 pointers + hash) + key string capacity when it exceeds SSO.
+18 -4
View File
@@ -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;
+23 -1
View File
@@ -45,7 +45,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
@@ -99,6 +106,21 @@ custom_sdkconfig =
CONFIG_BT_CTRL_BLE_MAX_ACT=3 ; was 6; need conn + scan + initiate only
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=50 ; was 100; pairing-time scan only
CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=10 ; was 30
; MEMFIX-PORT: task stack right-sizing (~7 KB); NOTE develop has no
; custom_sdkconfig block — port via sdkconfig.defaults or equivalent.
; 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 incl. BLE sessions; 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
+5 -1
View File
@@ -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
"""
+85
View File
@@ -0,0 +1,85 @@
#include "HeapMap.h"
#include <Arduino.h>
#include <Logging.h>
#include <Memory.h>
#include <esp_heap_caps.h>
#include <cstdio>
#include "rom/ets_sys.h"
// heap_caps_dump walks each heap inside a critical section (interrupts
// masked), so its output can neither go through the UART-0 ROM path we can't
// see nor be flow-controlled toward the CDC (the CDC ring drains in an
// interrupt handler — waiting deadlocks into the interrupt WDT,
// field-verified). Instead the ROM putc parses each line into a compact
// record; the captured table is logged after the dump with interrupts live.
namespace heapmap {
namespace {
struct BlockRec {
uint32_t addr;
uint32_t size;
bool free;
};
constexpr uint16_t kMaxRecs = 1400;
BlockRec* g_recs = nullptr; // borrowed buffer, valid only during capture
uint16_t g_recCount = 0;
bool g_overflowed = false;
char g_line[96];
uint8_t g_lineLen = 0;
void captInterpolatePutc(char c) {
if (c != '\n') {
if (g_lineLen < sizeof(g_line) - 1) g_line[g_lineLen++] = c;
return;
}
g_line[g_lineLen] = '\0';
g_lineLen = 0;
// e.g. "Block 0x3fcc69bc data, size: 89424 bytes, Free: Yes"
unsigned addr = 0, size = 0;
char freeWord[4] = {0};
if (sscanf(g_line, "Block 0x%x data, size: %u bytes, Free: %3s", &addr, &size, freeWord) == 3) {
if (g_recs && g_recCount < kMaxRecs) {
g_recs[g_recCount++] = {addr, size, freeWord[0] == 'Y'};
} else {
g_overflowed = true;
}
}
}
} // namespace
void dump() {
auto recBuf = makeUniqueNoThrow<BlockRec[]>(kMaxRecs);
if (!recBuf) {
LOG_ERR("MEM", "heap map skipped: no room for capture buffer");
return;
}
g_recs = recBuf.get();
g_recCount = 0;
g_overflowed = false;
g_lineLen = 0;
// Capture (interrupts masked inside the dump): parse into records, never
// wait. Log the table afterward with the system live. NOTE: the capture
// buffer itself appears in the map as a used block of ~17.4 KB — it frees
// on return (observer effect, do not chase it as a leak/splitter).
ets_install_putc1(&captInterpolatePutc);
heap_caps_dump(MALLOC_CAP_8BIT);
ets_install_uart_printf();
g_recs = nullptr;
LOG_DBG("MEM", "---- heap block map: %u blocks%s ----", g_recCount, g_overflowed ? " (TRUNCATED)" : "");
uint32_t dustCount = 0, dustBytes = 0;
for (uint16_t i = 0; i < g_recCount; ++i) {
const auto& r = recBuf[i];
if (r.free || r.size >= 256) {
LOG_DBG("MEM", "%s 0x%08x %u", r.free ? "FREE" : "used", r.addr, r.size);
} else {
dustCount++;
dustBytes += r.size;
}
}
LOG_DBG("MEM", "dust: %u used blocks < 256B totaling %u bytes", dustCount, dustBytes);
LOG_DBG("MEM", "---- end heap block map ----");
}
} // namespace heapmap
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// MEMFIX-PORT: heap block map (on-demand via CMD:MEMMAP + reader one-shot); portable, no BLE dependency
namespace heapmap {
// Capture-and-log the DRAM heap block map (address/size/free per block,
// sub-256B used blocks rolled up as "dust"). Safe to call from the main loop;
// ~60-100 LOG_DBG lines. See HeapMap.cpp for why capture-then-log is the only
// shape that works (heap_caps_dump runs with interrupts masked).
void dump();
} // namespace heapmap
+1
View File
@@ -32,6 +32,7 @@ class SdCardFontSystem {
/// Non-const access to the registry (for FontInstaller).
SdCardFontRegistry& registry() { return registry_; }
// MEMFIX-PORT: font system audit passthrough; portable
/// Resident heap held by loaded SD fonts (audit; see SdCardFont::reportMemory).
size_t reportFontMemory() const { return manager_.reportMemory(); }
+6 -69
View File
@@ -41,6 +41,7 @@
#include "SdCardFontSystem.h"
#include <esp_heap_caps.h>
#include "rom/ets_sys.h"
#include "HeapMap.h"
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -70,45 +71,6 @@ bool isInReadFolder(const std::string& path) {
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
}
// Heap block map capture. heap_caps_dump walks each heap inside a critical
// section (interrupts masked), so its output can neither go through the
// UART-0 ROM path we can't see nor be flow-controlled toward the CDC (the CDC
// ring drains in an interrupt handler — waiting deadlocks into the interrupt
// WDT, field-verified). Instead the ROM putc parses each line into a compact
// record; the captured table is logged after the dump with interrupts live.
namespace heapmap {
struct BlockRec {
uint32_t addr;
uint32_t size;
bool free;
};
constexpr uint16_t kMaxRecs = 1400;
BlockRec* recs = nullptr; // borrowed buffer, valid only during capture
uint16_t recCount = 0;
bool overflowed = false;
char line[96];
uint8_t lineLen = 0;
void putc(char c) {
if (c != '\n') {
if (lineLen < sizeof(line) - 1) line[lineLen++] = c;
return;
}
line[lineLen] = '\0';
lineLen = 0;
// e.g. "Block 0x3fcc69bc data, size: 89424 bytes, Free: Yes"
unsigned addr = 0, size = 0;
char freeWord[4] = {0};
if (sscanf(line, "Block 0x%x data, size: %u bytes, Free: %3s", &addr, &size, freeWord) == 3) {
if (recs && recCount < kMaxRecs) {
recs[recCount++] = {addr, size, freeWord[0] == 'Y'};
} else {
overflowed = true;
}
}
}
} // namespace heapmap
class FrameBufferBuildLoan {
public:
explicit FrameBufferBuildLoan(GfxRenderer& renderer) : renderer_(renderer) {}
@@ -1545,6 +1507,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// preceding lines show (mini rebuilds, kern reloads, BLE churn).
LOG_DBG("MEM", "post-render: free=%u maxAlloc=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
{
// MEMFIX-PORT: per-render heap owner audit + one-shot block map; portable
// Heap audit: attribute resident heap to its owners so margin work is
// measurement-driven. `other` = IDF/Arduino baseline + SdFat + epub
// metadata + BLE (when on) + anything not yet instrumented.
@@ -1568,36 +1531,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
static bool heapMapDumped = false;
if (!heapMapDumped && !(section && section->isBuilding())) {
heapMapDumped = true;
auto recBuf = makeUniqueNoThrow<heapmap::BlockRec[]>(heapmap::kMaxRecs);
if (recBuf) {
heapmap::recs = recBuf.get();
heapmap::recCount = 0;
heapmap::overflowed = false;
heapmap::lineLen = 0;
// Capture (interrupts masked inside the dump): parse into records,
// never wait. Log the table afterward with the system live.
ets_install_putc1(&heapmap::putc);
heap_caps_dump(MALLOC_CAP_8BIT);
ets_install_uart_printf();
heapmap::recs = nullptr;
LOG_DBG("MEM", "---- heap block map: %u blocks%s ----", heapmap::recCount,
heapmap::overflowed ? " (TRUNCATED)" : "");
uint32_t dustCount = 0, dustBytes = 0;
for (uint16_t i = 0; i < heapmap::recCount; ++i) {
const auto& r = recBuf[i];
if (r.free || r.size >= 256) {
LOG_DBG("MEM", "%s 0x%08x %u", r.free ? "FREE" : "used", r.addr, r.size);
} else {
dustCount++;
dustBytes += r.size;
}
}
LOG_DBG("MEM", "dust: %u used blocks < 256B totaling %u bytes", dustCount, dustBytes);
LOG_DBG("MEM", "---- end heap block map ----");
} else {
LOG_ERR("MEM", "heap map skipped: no room for capture buffer");
}
// Landmarks: known owners' addresses, so map blocks self-identify.
LOG_DBG("MEM", "landmark framebuffer=%p section=%p epub=%p activity=%p", renderer.getFrameBuffer(),
static_cast<void*>(section.get()), static_cast<void*>(epub.get()), static_cast<void*>(this));
heapmap::dump();
}
}
}
+24
View File
@@ -34,6 +34,8 @@
#include "images/LoadingIcon.h"
#include "util/ButtonNavigator.h"
#include "util/ScreenshotUtil.h"
#include <Memory.h>
#include "HeapMap.h"
GfxRenderer renderer(display);
MappedInputManager mappedInputManager(gpio, renderer);
@@ -596,6 +598,23 @@ void loop() {
LOG_INF("MEM", "Free: %d bytes, Total: %d bytes, Min Free: %d bytes, MaxAlloc: %d bytes", ESP.getFreeHeap(),
ESP.getHeapSize(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap());
lastMemPrint = millis();
// MEMFIX-PORT: per-task stack high-water audit; portable
// Task stack audit (~once/min): name, stack base (matches blocks in the
// heap map), and high-water free bytes — the margin available for
// right-sizing each stack. TRACE_FACILITY is on in this sdkconfig.
static uint8_t memPrintCount = 0;
if (++memPrintCount >= 6) {
memPrintCount = 0;
const UBaseType_t taskCount = uxTaskGetNumberOfTasks();
auto taskStatus = makeUniqueNoThrow<TaskStatus_t[]>(taskCount + 2);
if (taskStatus) {
const UBaseType_t got = uxTaskGetSystemState(taskStatus.get(), taskCount + 2, nullptr);
for (UBaseType_t i = 0; i < got; ++i) {
LOG_DBG("MEM", "task %-20s stackBase=%p highWaterFree=%u", taskStatus[i].pcTaskName,
taskStatus[i].pxStackBase, (unsigned)taskStatus[i].usStackHighWaterMark);
}
}
}
}
// Handle incoming serial commands,
@@ -611,6 +630,11 @@ void loop() {
uint8_t* buf = display.getFrameBuffer();
logSerial.write(buf, bufferSize);
logSerial.printf("SCREENSHOT_END\n");
// MEMFIX-PORT: on-demand heap map serial command; portable
} else if (cmd == "MEMMAP") {
// On-demand heap block map: capture the heap exactly when it looks
// interesting (e.g. maxAlloc degraded mid-session) without a reboot.
heapmap::dump();
}
}
}
+17
View File
@@ -201,6 +201,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);
@@ -230,6 +238,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;
}
@@ -270,6 +282,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