anomalyco / anomalyco/opencode

Memory leak: PDF attachments are base64-encoded without size limit and re-encoded every turn, causing OOM

Open
#42,263 4 comments 4 reactions 1 assignee View on GitHub

@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

  1. Prepare a large PDF (~100–150 MB).
  2. 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 the read tool).
  3. Let the agent do multi-turn work (reads, shell commands, etc.).
  4. 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): readFile buffers 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:

  1. The read toolpackages/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 read tool — the most common way PDFs enter a session.

  2. TUI attachment pastepackages/tui/src/component/prompt/index.tsx

    • pasteInputTextreadLocalAttachment(filepath) (line ~1189)
    • pasteAttachment encodes 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.
  3. Server-side prompt constructionpackages/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:

  1. Cross-session retentionpackages/tui/src/context/data.tsx

    • The DataProvider mounts once at the app root (packages/tui/src/app.tsx) and is never re-created.
    • store.session.message[sessionID] accumulates messages (including file parts with the full base64 url) keyed by session ID.
    • There is no delete/clear/prune of store.session.message when a session is switched, closed, or deleted — so the PDF base64 string survives for the lifetime of the process.

Suggested fix

  1. 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, and prompt.ts; reject or fall back to a text extraction message when exceeded.
  2. Cache the base64 result per part.url/file path so the same file is not re-read and re-encoded on every turn.
  3. Ensure the large string is not duplicated across the prompt store, message history, and request serialization (share the reference instead of re-serializing).
  4. Evict or cap store.session.message entries for inactive sessions (and release large attachment payloads) so memory does not grow with the number of sessions touched.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.