fix: dictionary lookup OOM on .dict.dz definitions - #2791
Conversation
extractChunkSlice() read the entire compressed chunk into one heap buffer before inflating it. A dictzip chunk is ~58KB uncompressed -- 18,428 bytes compressed for a real dictionary -- and that block was held live while the 32KB inflate window was allocated on top of it. Two large contiguous requests at once, on a heap that sits under 50KB free mid-book. uzlib supports a pull callback, so feed it from the file instead. The new ChunkSource holds a 2KB refill buffer in place of the whole chunk, cutting the second-largest block on the lookup path from ~18KB to ~2.2KB. Two details preserved from the old path: - remaining enforces the chunk boundary, so the callback never reads into the next chunk's bytes -- the same hard limit the whole-chunk buffer imposed. - readFailed records an IO error, which uzlib would otherwise collapse into a generic decode failure and report as a corrupt .dz. Verified on host against lib/uzlib with a real 58,315-byte chunk: full-chunk decode, mid-chunk slice (the case every lookup takes) and a slice ending exactly at the chunk boundary all match the source bytes, and a truncated compressedSize is refused rather than over-read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dictionary lookup on a .dict.dz failed with "Not enough memory" even on a barely fragmented heap. Instrumented on device: chunkSlice:enter largest=36852 (would have fit the ring) after chunkSource (3348) largest=32756 inflateWindow need=32768 WILL FAIL Every buffer the lookup needs is carved from the same big free run, so allocation order decides the outcome. The ~3KB ChunkSource was taken first, out of the only block large enough for the 32KB ring, and left it twelve bytes short. Allocate largest-first: the ring goes to the big block while it is still whole, and ChunkSource fits in the remainder (or in one of the smaller free blocks). InflateReader gains initWithRing() to run streaming mode over a caller-owned buffer, since the reader is embedded in the struct whose allocation has to come second. ownsRing keeps deinit() from freeing a buffer it does not own, so the existing init(true) callers in ZipFile and PngToBmpConverter are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (2)
📜 Recent review details⏰ Context from checks skipped due to timeout. (3)
🧰 Additional context used🧠 Learnings (5)📚 Learning: 2026-02-27T22:49:59.600ZApplied to files:
📚 Learning: 2026-03-02T10:14:16.036ZApplied to files:
📚 Learning: 2026-04-12T12:28:33.205ZApplied to files:
📚 Learning: 2026-05-24T21:10:19.897ZApplied to files:
📚 Learning: 2026-07-28T18:42:02.364ZApplied to files:
🔇 Additional comments (1)
📝 WalkthroughWalkthrough
ChangesDictzip streaming decompression
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant DictZip
participant HalFile
participant chunkReadCb
participant InflateReader
DictZip->>HalFile: Seek to compressedOffset
DictZip->>InflateReader: initWithRing(ring)
InflateReader->>chunkReadCb: Request compressed bytes
chunkReadCb->>HalFile: Read next input buffer
HalFile-->>chunkReadCb: Bytes or IO failure
chunkReadCb-->>InflateReader: Input data or end-of-chunk
InflateReader-->>DictZip: Decompressed output or failure classification
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
initWithRing() memset the caller's ring with no null check, so a caller forwarding a failed allocation would hard-fault where init(true) returns false. It now returns bool and bails before the memset, leaving the reader deinitialised; DictZip checks the result. No behaviour change today — DictZip already null-checks `window` before calling — but the precondition is now enforced rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Still doesn't (completely) fix the dictionary loading failures when under pressure/fragmentation. |
barbarhan
left a comment
There was a problem hiding this comment.
I reviewed the new caller-owned ring-buffer path, ChunkSource callback streaming, allocation/destruction order, short-read handling, compressed-chunk boundary enforcement, and error classification.
The externally owned ring outlives InflateReader, ownsRing prevents both leaks and double-free, and every new DictZip read() call is made with a positive batch size. Short positive file reads are handled by refilling, while exhausted chunk input and actual I/O failures remain distinguishable.
CI is green, and I found no actionable issue introduced by this change.
Brings 16 upstream commits under the messenger line. No textual conflicts: the messenger work is almost entirely new files plus additive edits to files upstream did not touch this cycle. Every overlap was audited on the merits rather than trusted to the auto-merge. Notable integrations: - perf: shared static bidi scratch buffer (~1.5 KB RAM) (crosspoint-reader#2554) - perf: dictionary sidecar freshness checked once per open; index files opened once per lookup (crosspoint-reader#2733) - fix: dictionary lookup OOM on .dict.dz definitions (crosspoint-reader#2791) - fix: deque instead of vector for text token storage (crosspoint-reader#2814); this changed GfxRenderer::ensureSdCardFontReady's container type -- no messenger caller uses that overload - fix: avoid duplicate OTA update actions (crosspoint-reader#2807) - feat: OptionPopup is orientation-aware, now driven by NavPrevious/NavNext instead of Up|Left / Down|Right; no messenger surface constructs an OptionPopup, so the new nav semantics are inherited cleanly - fix: orient dictionary navigation buttons (crosspoint-reader#2749) - fix: properly power down SD power rails on x3 (crosspoint-reader#2808) - fix: save text settings immediately on each change (crosspoint-reader#2806) - fix: order languages by native name instead of code (crosspoint-reader#2803) - fix: restrict CrossPoint position extension to official server (crosspoint-reader#2790) - feat: access text settings from the epub reader (crosspoint-reader#2788) - fix: clean image page after sync return (crosspoint-reader#2747) - fix: restore spaces between CJK words (crosspoint-reader#2768) - fix: derive file-transfer WebSocket port from HTTP (crosspoint-reader#2729) Sole overlapping file: src/activities/settings/SettingsActivity.cpp. Upstream dropped a redundant SETTINGS.saveToFile() from the text-settings callback (TextSettingsActivity now saves on each change); our messenger entries only added list rows above it. Both sides kept; upstream's line landed on the callback it was written for. WebSocket port contract (crosspoint-reader#2729) is browser-side JavaScript only. The firmware still binds HTTP 80 and WebSocket 81 (CrossPointWebServer.h wsPort = 81), and the UDP discovery beacon still advertises that port, so the mobile app's hardcoded ws://<ip>:81 remains correct. No app change required. Messenger surfaces verified present after merge: MessageSync, BookSync, PeerProbe, MailboxSyncActivity, SleepActivity note precedence and display-once, main.cpp sleep/wake sync hooks, CrossPointState fields (mailboxApPsk bounds, display-once note id), SettingsList messenger entries including the mailboxApPsk DynamicString and settings-API obfuscation, CrossPointWebServer upload guards, HttpDownloader Range and deadline work, i18n strings. Compile gate: pio run -e default SUCCESS (RAM 15.5%, Flash 84.7%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Goal: Fix
.dict.dzdictionary lookups failing with "Not enough memory" (X3 dict lookup runs out of memory #2744). Two independent causes on the definition-extraction path, one commit each.Changes:
1. Stop buffering the whole compressed chunk.
extractChunkSlice()read the entire compressed chunk into one heap buffer before inflating it. A dictzip chunk is ~58 KB uncompressed — 18,428 bytes compressed for a real dictionary — and that block was held live while the 32 KB inflate window was allocated on top of it. Two large contiguous requests at once, on a heap sitting under 50 KB free mid-book. uzlib supports a pull callback, so the input is now fed from the file through a 2 KB refill buffer.2. Allocate largest-first. Even after (1), the lookup still failed — and the instrumented margin was twelve bytes:
Every buffer the lookup needs is carved from the same big free run, so allocation order decides the outcome. The ~3 KB
ChunkSourcewas taken first, out of the only block large enough for the 32 KB ring. Reversing the order gives the ring the big block while it is still whole.Note the heap here is not badly fragmented —
frag=15%, 14 free blocks. This was a shortfall-and-ordering bug, not a fragmentation one.Scope Check
CrossPoint is intentionally narrow. See SCOPE.md and ROADMAP.md.
Please confirm:
freeink-sdk/,lib/hal/, the bootloader, OTA, or recovery code, so no maintainer coordination is required on that basis.Additional Context
ChunkSource::remainingstops the callback atcompressedSize, so it never reads into the next chunk — exactly the hard limit the whole-chunk buffer imposed.-1into a generic decode failure, which would have turned every SD hiccup intoDecompressand partly undone fix: name dictionary lookup failures (low-memory vs decompress vs read) instead of 'Not found' #2706.readFailedrecords the cause and maps it back toReadError.InflateReader::initWithRing()is new: streaming mode over a caller-owned ring, needed because the reader is embedded in the struct whose allocation must come second.ownsRingkeepsdeinit()from freeing a buffer it doesn't own, so the existinginit(true)callers inZipFileandPngToBmpConverterare unaffected.ChunkSource(~2.2 KB, mostly the 2 KB refill buffer) is heap-allocated rather than stacked, to stay clear of the task stack budget.chunkReadCb(refill, setsource/source_limitto the tail, return the first byte) and thewindow/srcdeclaration order inextractChunkSlice— the ring must outlive the reader.Dictionary.{h,cpp}, the index-search path; this isDictZip.cpp+InflateReader). Neither depends on the other and they should not conflict. perf: open dictionary index files once per lookup #2733 does not fix this OOM — the failure is entirely downstream of what it changes.Testing
defaultenv (ESP32-C3); clang-format clean.lib/uzlibwith a real 58,315-byte chunk: full-chunk decode, mid-chunk slice (the case every lookup takes), and a slice ending exactly at the chunk boundary all match the source bytes; a truncatedcompressedSizeis refused rather than over-read.AI Usage
Did you use AI tools to help write this code? YES — diagnosed with Claude Code using temporary heap instrumentation (largest-free-block probes, not included in this PR), and the fix was implemented with it. Reviewed and tested on hardware (X3) by me.
🤖 Generated with Claude Code