anomalyco / anomalyco/opencode
Memory leak: PDF attachments are base64-encoded without size limit and re-encoded every turn, causing OOM
@jlongster is already working on this.
Since Aug 13, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- Avg merge
- 7h 2m
- Merged PRs (30d)
- 384
Description
Summary
Ingesting a large PDF — either by pasting its path into the input box (renders as a [PDF N] chip) or by letting the agent read it with the read tool — base64-encodes the entire file into memory with no size limit, and the resulting data:application/pdf;base64,... string is duplicated on every subsequent turn. The same base64 is also persisted into SQLite, bloating opencode.db (988 MB in one case). RSS balloons from ~800 MB to 3.7 GB+ and the process eventually dies (OOM / unresponsive), especially on machines with limited RAM.
Environment
- opencode version: 1.17.9 and 1.18.18 (both affected)
- OS: Linux, 5.8 GB RAM, 2 GB swap
- Trigger: development session with a large PDF (~135 MB) attached
Steps to reproduce
- Prepare a large PDF (~100–150 MB).
- EITHER paste the PDF's file path into the input box (replaced by a
[PDF 1]chip), OR ask the agent to read the PDF (it calls thereadtool). - Let the agent do multi-turn work (reads, shell commands, etc.).
- Watch RSS: it grows from ~800 MB to 2.1 GB → 2.4 GB → 3.7 GB, at which point the UI becomes sluggish and eventually dies.
Expected behavior
Attaching a PDF should either be rejected with a clear size limit (like images are), or its content should be ingested once and reused, not re-encoded and duplicated on every turn. Memory should stay bounded.
Actual behavior / evidence
Heap snapshots captured via the built-in "Write heap snapshot" command show the same PDF's base64 content duplicated multiple times:
At 3.7 GB RSS, tui.heapsnapshot (self_size total 635 MB):
string: data:application/pdf;base64,JVBERi0xLjQK... 179.7 MB × 3 identical copies = 539 MB
At 2.4 GB RSS, server.heapsnapshot additionally contained:
string: data:application/pdf;base64,... 179.7 MB
string: JVBERi0xLjQK... (raw PDF) 89.8 MB
string: data:application/pdf;base64,... 35.9 MB × 4
object: BlobInternalReadableStreamSource 134.8 MB
string: {"model":"deepseek-v4-pro","messages":[...]} 1.3 MB (LLM request body containing the PDF)
The remaining RSS (3.7 GB − ~635 MB JS heap ≈ 3 GB) sits in native memory (WKFastMalloc + [heap]), consistent with repeated readFile buffers and base64 intermediates that were never released.
The copies survive across sessions
After switching to a different session in the same process (RSS had dropped back to ~1.44 GB after a GC), a fresh snapshot still contained:
string: data:application/pdf;base64,JVBERi0xLjQK... 179.7 MB × 2 = 359.4 MB
i.e. the PDF base64 string is process-global and never released when the session is switched or closed. The RSS drop (3.7 GB → 1.44 GB) separates the leak into two layers:
- Live leak (never GC'd): the PDF base64 copies held by the prompt store / message history — this is the floor that only grows.
- Garbage buildup (GC-able but not reclaimed promptly):
readFilebuffers and base64 intermediates from each turn.
The PDF is also persisted into SQLite
The base64 content is written into the database, bloating it from a few MB to 988 MB (~/.local/share/opencode/opencode.db):
event 317 MB (17 rows containing the PDF, ~96 MB; type message.part.updated.1 = 232 MB total)
part 206 MB (17 rows containing the PDF, ~96 MB; largest single row = 90 MB)
That 90 MB row is a read tool part for xxx.pdf:
{"type":"tool","tool":"read",
"input":{"filePath":".../xxx.pdf","limit":100},
"output":"PDF read successfully",
"attachments":[{"type":"file","mime":"application/pdf",
"url":"data:application/pdf;base64,JVBERi0xLjQK...(90 MB)..."}]}
So even after the process is restarted, restoring the session reloads the PDF base64 from the database back into memory.
Root cause
Three code paths ingest PDFs with no size limit and no caching:
-
The
readtool —packages/opencode/src/tool/read.ts(primary path)if (isImage || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) // whole PDF, no size check ... attachments: [{ type: "file", mime, url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`, }] }This fires whenever the agent reads a PDF with the
readtool — the most common way PDFs enter a session. -
TUI attachment paste —
packages/tui/src/component/prompt/index.tsxpasteInputText→readLocalAttachment(filepath)(line ~1189)pasteAttachmentencodes the whole file:content: Buffer.from(attachment.content).toString("base64")(line ~1200)- builds the data URL:
url: `data:${file.mime};base64,${file.content}`(line ~1251) readLocalAttachment(packages/tui/src/component/prompt/local-attachment.ts) reads the whole file as bytes with no size check.
-
Server-side prompt construction —
packages/opencode/src/session/prompt.ts-
In the
case "file:"branch (~line 949–968), every prompt construction re-reads and re-encodes the file:url: `data:${mime};base64,` + Buffer.from(yield* fsys.readFile(filepath)...).toString("base64"),
-
All three paths bypass the limits that exist for images:
| File | Limit |
|---|---|
packages/core/src/tool/read-filesystem.ts |
MAX_MEDIA_INGEST_BYTES = 20 MB (images) |
packages/opencode/src/image/image.ts |
MAX_BASE64_BYTES = 5 MB |
packages/opencode/src/tool/read.ts (PDF) |
no limit |
packages/tui/src/component/prompt/index.tsx (PDF) |
no limit |
packages/opencode/src/session/prompt.ts (PDF) |
no limit |
Additionally, the resulting base64 string is retained by multiple owners without being released, so each turn adds another full copy:
-
Cross-session retention —
packages/tui/src/context/data.tsx- The
DataProvidermounts once at the app root (packages/tui/src/app.tsx) and is never re-created. store.session.message[sessionID]accumulates messages (includingfileparts with the full base64url) keyed by session ID.- There is no delete/clear/prune of
store.session.messagewhen a session is switched, closed, or deleted — so the PDF base64 string survives for the lifetime of the process.
- The
Suggested fix
- Apply a size limit (aligned with
MAX_MEDIA_INGEST_BYTES, e.g. 20 MB) to PDF ingestion in all three paths —tool/read.ts,local-attachment.ts/index.tsx, andprompt.ts; reject or fall back to a text extraction message when exceeded. - Cache the base64 result per
part.url/file path so the same file is not re-read and re-encoded on every turn. - Ensure the large string is not duplicated across the prompt store, message history, and request serialization (share the reference instead of re-serializing).
- Evict or cap
store.session.messageentries for inactive sessions (and release large attachment payloads) so memory does not grow with the number of sessions touched.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.