Skip to content

OPDS browsing: Expat buffer allocation failure on xteink devices #372

Description

@gershalfred

Summary

Two xteink/CrossInk devices reproduce this when browsing a Calibre-Web OPDS catalog:

couldn't allocate memory for buffer

The same server works from Kobo and the Calibre-Web website. The OPDS XML validates with curl/XML tooling, so this looks like a CrossInk OPDS parser heap-pressure issue rather than a malformed server feed.

I coded a small candidate fix and pushed it here:

I attempted to open this as a PR, but GitHub returned:

gershalfred does not have the correct permissions to execute CreatePullRequest

So I’m posting the full diagnosis here for maintainers to pull/cherry-pick or recreate.

User-visible symptom

While browsing an OPDS catalog on xteink/CrossInk:

  • root/server access succeeds
  • browsing some feeds may work briefly
  • selecting Authors / Books / an author can fail with:
couldn't allocate memory for buffer

This was verified on two xteink devices.

Why this appears firmware-side

The exact message maps to the OPDS parser path in:

lib/OpdsParser/OpdsParser.cpp

The failure path is:

void* const buf = XML_GetBuffer(parser, chunkSize);
if (!buf) {
  errorOccured = true;
  errorReason = OpdsParserError::BUFFER_MEMORY;
  LOG_DBG("OPDS", "Couldn't allocate memory for buffer");
  destroyXmlParser(parser);
  parser = nullptr;
  return length;
}

So the message means Expat could not allocate its parse buffer on-device.

Why Calibre-Web is probably not the root cause

The same Calibre-Web server:

  • works from Kobo
  • works from the Calibre-Web website
  • returns valid OPDS XML via curl/XML validation
  • returns normal sized feeds from server-side tests

A temporary server-side compatibility shim with tiny feeds reduces the issue, which further points to device-side memory pressure during OPDS parsing.

Suspected regression class

This may be related to the broader OPDS/networking changes where HttpDownloader moved to esp_http_client and buffering was tuned for download reliability/speed.

Those changes are useful for actual EPUB downloads, but catalog browsing has a different memory shape:

  • Wi-Fi/TLS / esp_http_client buffers are live
  • the fetch body buffer is live
  • Expat asks for a parse buffer
  • parsed OPDS entries and their std::string fields are retained

On ESP32-C3/no-PSRAM hardware, this can leave insufficient contiguous heap for XML_GetBuffer(...) even when the XML is valid.

Candidate fix coded

The candidate fix separates catalog fetches from file downloads:

  1. Keep book/file downloads at the current larger buffer:
DEFAULT_DOWNLOAD_BUFFER_SIZE = 2048
  1. Add a smaller default for catalog/text fetches:
DEFAULT_FETCH_BUFFER_SIZE = 512
  1. Make HttpDownloader::fetchUrl(...) use the smaller fetch buffer.

  2. Reduce Expat parse chunk size:

chunkSize = 1024 -> 256

That lowers transient contiguous heap requirements while browsing catalogs, without intentionally slowing actual EPUB downloads.

Patch

diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp
index 2a935947..e47c0200 100644
--- a/lib/OpdsParser/OpdsParser.cpp
+++ b/lib/OpdsParser/OpdsParser.cpp
@@ -34,7 +34,9 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
 
   const char* currentPos = reinterpret_cast<const char*>(xmlData);
   size_t remaining = length;
-  constexpr size_t chunkSize = 1024;
+  // Keep Expat's transient parse buffer small; OPDS parsing happens while the
+  // HTTP/TLS stack and entry strings are also consuming fragmented heap.
+  constexpr size_t chunkSize = 256;
 
   while (remaining > 0) {
     const size_t toRead = remaining < chunkSize ? remaining : chunkSize;
diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp
index ed9ccdf7..a9e9c389 100644
--- a/src/network/HttpDownloader.cpp
+++ b/src/network/HttpDownloader.cpp
@@ -25,6 +25,9 @@ constexpr int HTTP_TX_BUF = 1024;
 constexpr int HTTP_TIMEOUT_MS = 60000;
 constexpr int HTTP_READ_POLL_TIMEOUT_MS = 5000;
 constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 30000;
+// Catalog fetches stream into parsers while WiFi/TLS buffers are live. Keep
+// their body buffer smaller than file downloads to preserve contiguous heap.
+constexpr size_t DEFAULT_FETCH_BUFFER_SIZE = 512;
 constexpr size_t DEFAULT_DOWNLOAD_BUFFER_SIZE = 2048;
 constexpr uint8_t MAX_REDIRECTS = 5;
 
@@ -415,7 +418,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, const DataCallback& onData
 
   Sink sink;
   sink.write = onData;
-  return runGet(url, username, password, sink, DEFAULT_DOWNLOAD_BUFFER_SIZE) == OK;
+  return runGet(url, username, password, sink, DEFAULT_FETCH_BUFFER_SIZE) == OK;
 }

Local verification

This passed:

git diff --check

I attempted:

pio run -e default

but my local checkout fails before compiling because the SDK/library symlink target is missing:

PackageException: Can not create a symbolic link for `freeink-sdk/libs/hardware/BatteryMonitor`, not a directory

So this needs CI or a maintainer machine with the SDK checkout available for compile validation.

Hardware verification request

On a device that reproduces the issue:

  1. Configure a Calibre-Web OPDS server.
  2. Browse root → Authors → letter → author.
  3. Browse root → Books.
  4. Confirm the device no longer shows:
couldn't allocate memory for buffer
  1. Confirm selecting/downloading an EPUB still works and download speed is not materially regressed.

Why this should be safe

This does not remove the faster file download path. It only makes catalog/text fetches and XML parsing less demanding on contiguous heap while browsing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions