Repository navigation
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.
Activity
github-actions commentedon Aug 13, 2026
This issue might be a duplicate of existing issues. Please check:
If your issue is not already covered, consider adding your heap snapshot data to #20695 as well, since that's where the team is coordinating memory investigations.
tianq01 commentedon Aug 13, 2026
looks duplicate of #34215.
this seems to be a high priority issue, as the copies survive across sessions and persist to sqlite
YidiDev commentedon Sep 2, 2026
Possible additional memory amplifier, separate from the retention/persistence root cause documented here:
Vercel AI SDK issue vercel/ai#20149 reports that
@ai-sdk/provider-utilsconvertsUint8Arrayattachments to base64 by appending one byte at a time to a JavaScript string. Their measured 14 MB PDF produced a +606 MB RSS delta in that conversion alone. The proposed upstream fix is vercel/ai#13581.OpenCode currently declares
@ai-sdk/provider-utils4.0.23 and has directconvertToBase64()calls in:packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.tspackages/core/src/github-copilot/responses/convert-to-openai-responses-input.tspatches/@ai-sdk%2Fxai@3.0.102.patchfor PDF dataIf any of those paths receive a
Uint8Array, they may incur the same large temporary allocation before the resulting base64 is retained or serialized. Passing an already encoded base64 string bypasses the affected SDK loop.This does not appear to explain the primary reproduction in this issue: the main PDF read and newer media-validation paths already use native
Buffer.from(bytes).toString("base64"), so the documented duplication across turns, TUI state, and SQLite remains a distinct problem. This is only a suggestion to audit the remaining provider-specificconvertToBase64()call sites for byte-array inputs.fm882 commentedon Sep 15, 2026
Hit this too, worth noting a variant symptom: it also causes a crash loop on every
opencodestartup, not just during a live session.My case: a session had a PDF (
AI_Engineering.pdf) attached twice, each ingested as a ~308 MBdata:application/pdf;base64,...string.opencode.logshowed this on every single launch:i.e. schema validation of the session/message body OOMs before the TUI even becomes usable — so once a session gets into this state, every subsequent startup fails, not just the one where the PDF was ingested.
~/.local/share/opencode/opencode.dbhad ballooned to 1.4 GB (two ~308 MB rows inpart, matching the pattern in the issue description). Deleting that one session's rows (part,message,event,event_sequence,session) and runningVACUUMbrought it down to 226 MB, and startup stopped OOMing.Given the root cause described above, it'd also be good if the startup-time schema/body validation failed gracefully per-session (e.g. skip/quarantine the offending session) rather than OOMing the whole process — right now a single bad session can permanently break
opencodefrom launching at all until someone manually edits the SQLite DB.MD5Visual commentedon Sep 22, 2026
Image-specific angle (previously #49320, closed as duplicate): the
readtool persists the fulldata:<mime>;base64,...URI insession_messagetool-result records — verified on v2.0.14 (1,259,239 chars for a single PNG in this reporter's DB).boundImages(packages/core/src/session/model-request.ts) also sumsBuffer.byteLength(uri)for these file parts, so image payloads are counted twice (~2x context/usage). The new 20MiBMAX_MEDIA_INGEST_BYTESread-side cap mitigates the OOM side of this issue but leaves everything under 20MB unchanged. The managed per-session attachment store in #46173 should be the real fix.thdxr commentedon Oct 11, 2026
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (
opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊