Skip to content

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

Closed as not planned

Description

@tianq01

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

  2. TUI attachment paste — packages/tui/src/component/prompt/index.tsx

    • pasteInputText → readLocalAttachment(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 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:

  1. Cross-session retention — packages/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.

Activity

  1. github-actions commented on Aug 13, 2026

    @github-actions
    Contributor

    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.

  2. tianq01 commented on Aug 13, 2026

    @tianq01
    Author

    looks duplicate of #34215.
    this seems to be a high priority issue, as the copies survive across sessions and persist to sqlite

  3. YidiDev commented on Sep 2, 2026

    @YidiDev

    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-utils converts Uint8Array attachments 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-utils 4.0.23 and has direct convertToBase64() calls in:

    • packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts
    • packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts
    • patches/@ai-sdk%2Fxai@3.0.102.patch for PDF data

    If 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-specific convertToBase64() call sites for byte-array inputs.

  4. fm882 commented on Sep 15, 2026

    @fm882

    Hit this too, worth noting a variant symptom: it also causes a crash loop on every opencode startup, not just during a live session.

    My case: a session had a PDF (AI_Engineering.pdf) attached twice, each ingested as a ~308 MB data:application/pdf;base64,... string. opencode.log showed this on every single launch:

    message="schema rejection" kind=Body reason="RangeError: Out of memory"
    

    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.db had ballooned to 1.4 GB (two ~308 MB rows in part, matching the pattern in the issue description). Deleting that one session's rows (part, message, event, event_sequence, session) and running VACUUM brought 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 opencode from launching at all until someone manually edits the SQLite DB.

  5. MD5Visual commented on Sep 22, 2026

    @MD5Visual

    Image-specific angle (previously #49320, closed as duplicate): the read tool persists the full data:<mime>;base64,... URI in session_message tool-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 sums Buffer.byteLength(uri) for these file parts, so image payloads are counted twice (~2x context/usage). The new 20MiB MAX_MEDIA_INGEST_BYTES read-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.

  6. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    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 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions