fix(opencode): huge OpenCode user messages no longer crash the usage scan#27679

closed4
main
fix/issue-27667
opened11d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 14h ago

Problem

The OpenCode usage reader pages OpenCode's message table 64 rows at a time, but it selects the whole data JSON of every row. OpenCode can store user messages of 11–150 MB, almost all of it summary.diffs[].patch. When several of those land in one 64-row page, StatementSync.all() pulls hundreds of MB to GBs of strings onto the V8 heap. Each scan then crashes the server with FATAL ERROR: Ineffective mark-compacts near heap limit in node::sqlite::ExtractRowValues, and the client sees "reconnecting" in a loop.

Reproduced on main at 616c426f5f with a synthetic opencode.db: one assistant row plus 32 user rows of 150 MB each, all in one 64-rowid page, read with OPENCODE_DATA_DIR=<dir> vp run dev and the Usage page opened. The server aborts with the reporter's stack. The same 150 MB rows spread one per page scan fine, so what kills the server is the total per page.

Change

readOpenCodeUsage now does the role check and field extraction in SQL, so a page only returns usage-sized data:

  • A WHERE term keeps rows whose JSON has no role or has role = 'assistant'. A CASE WHEN typeof(data) = 'text' AND json_valid(data) guard skips malformed and non-text rows the way the old JSON.parse catch did, without failing the page. The guard comes after the time and rowid predicates, so rows outside the window are rejected before their bodies are parsed.
  • The query returns a small json_object of tokens, cost, model, modelID, time, id and sessionID in place of data. parseOpenCodeMessage, the rowid paging, the session_message path and the legacy JSON-file path are unchanged.

SQLite still reads each large body into native memory while it checks the row. That memory is per row and short-lived, and it is not on the V8 heap.

Scope and approval

Fixes pingdotgg/t3code#27667

Triaged via the automated issue flow.

Verification

  • New test readOpenCodeUsage > does not materialize user message diffs that share a page with usage in packages/provider-opencode/src/server/usage.test.ts puts one assistant row and 32 user rows, each with a 1 MiB diff patch, in one page:
    • On main it fails with expected 33558178 to be below 6291456: the bytes StatementSync.all returns per page.
    • On this branch it passes, and the assistant's 1,500 tokens are still counted.
  • All 6 OpenCode usage-reader tests pass. vp run check:changed passes: format, lint, knip, typecheck of 10 packages, and tests.
  • The two reviewers compared the old and new filters on over 20 edge cases and found no difference that OpenCode's own data can produce. The cases included rows with no role, role: null, malformed JSON, JSON5, BLOB and NULL data, nested model objects and large cost values. The only differences are duplicate role keys and nesting deeper than 1000 levels.
  • With the 32 × 150 MB db and a 512 MiB heap limit, main aborts in ExtractRowValues. This branch returns the usage, and its largest page result is 153 bytes.
  • No UI changes, so there are no captures. This is a server-only fix, proved by the test above and the runtime transcript below. The db is synthetic and contains no real data.
  • Not checked: native Windows and the reporter's WSL setup. The code path is platform-independent and was verified on Linux.
Runtime transcript (real dev server, synthetic data)

Driver: drivers/make-db.sh builds an OpenCode opencode.db with one assistant row (1,200 input + 300 output tokens) plus N user rows whose data is {"role":"user",...,"summary":{"diffs":[{"file":"gen.js","patch":"<x * size>"}]}}.

Each run: T3CODE_DEV_SEED=0 OPENCODE_DATA_DIR=<dir> vp run dev (fresh dev database), open /usage in the web client. Node v26 (heap_size_limit 4192 MB), Linux x86_64.

RunCommitDBResult
1main 616c426f5f32 user rows x 150 MB, rowids 2..33 (one 64-rowid page), 4.8 GBserver aborts: FATAL heap OOM in ExtractRowValues -> Statement::All, dev-runner "Failed running 'src/bin.ts'", client loses the connection
2main 616c426f5f8 user rows x 150 MB, one per 64-rowid page, 1.2 GBsurvives; Usage shows OpenCode 1 session, 1.50K tokens
3fix 0d69ec9f83 (code identical at head bee4d4ad39)same 4.8 GB db as run 1survives; no FATAL in the log; Usage shows OpenCode 1 session, 1.50K tokens

Run 1 log excerpt (main)

1[3882263:0x8692000] 38533 ms: Mark-Compact (reduce) 4187.6 (4192.8) -> 4187.6 (4192.8) MB, pooled: 0.0 MB, 84.58 / 0.00 ms (average mu = 0.137, current mu = 0.000) las
2[3882263:0x8692000] 38618 ms: Mark-Compact (reduce) 4187.6 (4192.8) -> 4187.6 (4192.8) MB, pooled: 0.0 MB, 84.60 / 0.00 ms (average mu = 0.074, current mu = 0.000) las
3FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
4 2: 0x8420bd node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node/26.12.1/bin/node]
512: 0xcf06d8 v8::String::NewFromOneByte(v8::Isolate*, unsigned char const*, v8::NewStringType, int) [node/26.12.1/bin/node]
613: 0xbb4708 node::sqlite::ExtractRowValues(node::Environment*, sqlite3_stmt*, int, bool, v8::LocalVector<v8::Value>*) [node/26.12.1/bin/node]
714: 0xbb4c57 node::sqlite::StatementExecutionHelper::All(node::Environment*, node::sqlite::Statement*) [node/26.12.1/bin/node]
815: 0xbb5e6d node::sqlite::Statement::All(v8::FunctionCallbackInfo<v8::Value> const&) [node/26.12.1/bin/node]
9Failed running 'src/bin.ts'. Waiting for file changes before restarting...

Run 3 (fix)

1$ grep -cE "FATAL|heap limit" dev-fix-huge.log
20
3server process still listening on its port after the scan; peak RSS 1.98 GB (whole server, all usage readers)
Driver: make-db.sh (synthetic OpenCode db, not committed)
bash
1#!/usr/bin/env bash
2# Synthetic OpenCode data dir: $1=dir, $2=user rows, $3=bytes per user patch, $4=rowid gap between user rows
3set -euo pipefail
4dir=$1; n=$2; size=$3; gap=${4:-1}
5mkdir -p "$dir"
6now=$(( $(date +%s) * 1000 ))
7sqlite3 "$dir/opencode.db" <<SQL
8CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);
9CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
10INSERT INTO message (rowid, id, session_id, time_created, time_updated, data)
11 VALUES (1, 'msg_assistant_1', 'ses_1', $now - 60000, $now - 60000,
12 json_object('id','msg_assistant_1','sessionID','ses_1','role','assistant','modelID','claude-sonnet-4-5','providerID','anthropic',
13 'time', json_object('created', $now - 60000), 'cost', 0.01,
14 'tokens', json_object('input', 1200, 'output', 300, 'reasoning', 0, 'cache', json_object('read', 0, 'write', 0))));
15WITH RECURSIVE k(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM k WHERE i < $n)
16INSERT INTO message (rowid, id, session_id, time_created, time_updated, data)
17 SELECT 1 + i * $gap, 'msg_user_' || i, 'ses_1', $now - 30000 + i, $now - 30000 + i,
18 '{"id":"msg_user_' || i || '","sessionID":"ses_1","role":"user","time":{"created":' || ($now - 30000 + i) || '},"summary":{"diffs":[{"file":"gen.js","patch":"' || printf('%.*c', $size, 'x') || '"}]}}'
19 FROM k;
20SQL
21sqlite3 -readonly "$dir/opencode.db" "SELECT count(*), sum(length(data))/1000000 || ' MB', max(rowid) FROM message"

Model and harness: Claude Opus 5.5 (claude-opus-5-5) in Claude Code via T3 Code; reviews by GPT-6.1 Sol (Codex) and Claude Opus 5.5.

🤖 Generated with Claude Code

Checks

24 successful · 16 skipped
All checks passed
bee4d4a

Added 4 commits

  • fix(opencode): parse usage message bodies only inside the scan window0d69ec9
  • test(opencode): reproduce #27667 user message diffs inflating usage scan pages9276b8e
  • Merge remote-tracking branch 'origin/main' into fix/issue-27667bee4d4a
  • fix(opencode): usage scan no longer loads user message diffs into memoryd9fdf99
Synced from GitHub

Approvability

Verdict: Approved at bee4d4a

Macroscope's review found this PR approvable — The OpenCode usage scan now filters messages and extracts only usage fields in SQLite, preventing huge message bodies from overwhelming the JavaScript heap. Read-only access and existing scan safeguards remain intact, with reported large-history verification supporting the limited risk.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire5.0 KiB5.0 KiB0 B (0.0%)6.8 KiB✅
CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
CodexLive turn WebSocket wire1.2 KiB1.2 KiB0 B (0.0%)2.0 KiB✅
CodexLive turn WebSocket decoded21.6 KiB21.6 KiB0 B (0.0%)24.4 KiB✅
CodexLive turn messages220 (0.0%)8✅
ClaudeTotal thread wire5.1 KiB5.0 KiB−6 B (−0.1%)6.8 KiB✅
ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
ClaudeLive turn WebSocket wire1.3 KiB1.3 KiB−6 B (−0.5%)2.0 KiB✅
ClaudeLive turn WebSocket decoded21.9 KiB21.9 KiB0 B (0.0%)24.4 KiB✅
ClaudeLive turn messages220 (0.0%)8✅

Baseline: 35dcdd6 · PR result: bee4d4a · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review.

External image Recent review info
External image Run configuration
External image Commits

Reviewing files that changed from the base of the PR and between 35dcdd601e11d821e0df5fd3435b350e92d15bf6 and bee4d4ad394a626d7aa4025c4002cdc5c3de85cd.

External image Files selected for processing: 2
  • packages/provider-opencode/src/server/usage.test.ts
  • packages/provider-opencode/src/server/usage.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Walkthrough

The OpenCode usage query now filters out rows with a non-assistant role and returns selected usage-related fields instead of full message data. A test checks the returned usage and measures raw SQLite query-result size when user messages contain large patches.

Changes: OpenCode usage query

Layer / File(s)Summary
Filter and project usage rows
packages/provider-opencode/src/server/usage.ts, packages/provider-opencode/src/server/usage.test.ts
The query retains rows with no role or an assistant role, and projects selected usage fields. The test adds large user-message patches and checks the assistant usage result and largest raw query-result size.

Priority: External image Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested labels: size:XS

Suggested reviewers: t3dotgg, macroscopeapp, yash-singh1

Merge Risk: External image Minimal · up to bee4d

Usage scans return compact records without an identified merge-blocking risk.

Pre-merge checks: Passed 4
Check nameStatusExplanation
Title checkPassedThe title clearly and concisely describes the main fix: preventing large OpenCode user messages from crashing the usage scan.
Description checkPassedThe description includes the required Problem, Change, Scope and approval, and Verification sections. It explains the failure, implementation, linked issue, focused tests, runtime results, limitations…
Linked Issues checkPassedIssue #27667 requires the OpenCode usage scan to avoid materializing large user-message bodies while preserving assistant usage. usage.ts now filters valid text JSON to rows with no role or `role = …
Out of Scope Changes checkPassedThe reviewed changes are limited to the OpenCode usage SQL query and a focused regression test. The filter, field projection, malformed-row guard, and test instrumentation directly support issue #2766…
Full details: Description check

Explanation

The description includes the required Problem, Change, Scope and approval, and Verification sections. It explains the failure, implementation, linked issue, focused tests, runtime results, limitations, and agent details.

Full details: Linked Issues check

Explanation

Issue #27667 requires the OpenCode usage scan to avoid materializing large user-message bodies while preserving assistant usage. usage.ts now filters valid text JSON to rows with no role or role = 'assistant', and projects only usage fields with json_object before SQLite returns rows. The new test inserts 32 large user diffs beside an assistant row, verifies 1,500 tokens, and bounds the largest raw result below 6 MiB. The existing parser, paging, and legacy paths remain intact in the reviewed diff.

Full details: Out of Scope Changes check

Explanation

The reviewed changes are limited to the OpenCode usage SQL query and a focused regression test. The filter, field projection, malformed-row guard, and test instrumentation directly support issue #27667. No unrelated product behavior or database mutation is introduced.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Thanks, Julius, for the SQL projection and the thorough reproduction. #33612, merged as bbdc7dca32, incorporates this fix: it filters and projects usage fields in SQLite so large user message bodies stay off the JS heap. Closing as covered.