fix(opencode): huge OpenCode user messages no longer crash the usage scan#27679
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
WHEREterm keeps rows whose JSON has noroleor hasrole = 'assistant'. ACASE WHEN typeof(data) = 'text' AND json_valid(data)guard skips malformed and non-text rows the way the oldJSON.parsecatch 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_objectoftokens,cost,model,modelID,time,idandsessionIDin place ofdata.parseOpenCodeMessage, the rowid paging, thesession_messagepath 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 usageinpackages/provider-opencode/src/server/usage.test.tsputs 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 bytesStatementSync.allreturns per page. - On this branch it passes, and the assistant's 1,500 tokens are still counted.
- On main it fails with
- All 6 OpenCode usage-reader tests pass.
vp run check:changedpasses: 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 NULLdata, nestedmodelobjects and largecostvalues. The only differences are duplicaterolekeys 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.
| Run | Commit | DB | Result |
|---|---|---|---|
| 1 | main 616c426f5f | 32 user rows x 150 MB, rowids 2..33 (one 64-rowid page), 4.8 GB | server aborts: FATAL heap OOM in ExtractRowValues -> Statement::All, dev-runner "Failed running 'src/bin.ts'", client loses the connection |
| 2 | main 616c426f5f | 8 user rows x 150 MB, one per 64-rowid page, 1.2 GB | survives; Usage shows OpenCode 1 session, 1.50K tokens |
| 3 | fix 0d69ec9f83 (code identical at head bee4d4ad39) | same 4.8 GB db as run 1 | survives; 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 |
| 3 | FATAL 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] |
| 5 | 12: 0xcf06d8 v8::String::NewFromOneByte(v8::Isolate*, unsigned char const*, v8::NewStringType, int) [node/26.12.1/bin/node] |
| 6 | 13: 0xbb4708 node::sqlite::ExtractRowValues(node::Environment*, sqlite3_stmt*, int, bool, v8::LocalVector<v8::Value>*) [node/26.12.1/bin/node] |
| 7 | 14: 0xbb4c57 node::sqlite::StatementExecutionHelper::All(node::Environment*, node::sqlite::Statement*) [node/26.12.1/bin/node] |
| 8 | 15: 0xbb5e6d node::sqlite::Statement::All(v8::FunctionCallbackInfo<v8::Value> const&) [node/26.12.1/bin/node] |
| 9 | Failed running 'src/bin.ts'. Waiting for file changes before restarting... |
Run 3 (fix)
| 1 | $ grep -cE "FATAL|heap limit" dev-fix-huge.log |
| 2 | 0 |
| 3 | server 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)
| 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 |
| 3 | set -euo pipefail |
| 4 | dir=$1; n=$2; size=$3; gap=${4:-1} |
| 5 | mkdir -p "$dir" |
| 6 | now=$(( $(date +%s) * 1000 )) |
| 7 | sqlite3 "$dir/opencode.db" <<SQL |
| 8 | CREATE 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); |
| 9 | CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id); |
| 10 | INSERT 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)))); |
| 15 | WITH RECURSIVE k(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM k WHERE i < $n) |
| 16 | INSERT 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; |
| 20 | SQL |
| 21 | sqlite3 -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
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
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.
Thread transfer impact
✅ Thread transfer remains within every enforced ceiling.
| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |
|---|---|---|---|---|---|---|
| Codex | Total thread wire | 5.0 KiB | 5.0 KiB | 0 B (0.0%) | 6.8 KiB | ✅ |
| Codex | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Codex | Live turn WebSocket wire | 1.2 KiB | 1.2 KiB | 0 B (0.0%) | 2.0 KiB | ✅ |
| Codex | Live turn WebSocket decoded | 21.6 KiB | 21.6 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Codex | Live turn messages | 2 | 2 | 0 (0.0%) | 8 | ✅ |
| Claude | Total thread wire | 5.1 KiB | 5.0 KiB | −6 B (−0.1%) | 6.8 KiB | ✅ |
| Claude | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Claude | Live turn WebSocket wire | 1.3 KiB | 1.3 KiB | −6 B (−0.5%) | 2.0 KiB | ✅ |
| Claude | Live turn WebSocket decoded | 21.9 KiB | 21.9 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Claude | Live turn messages | 2 | 2 | 0 (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.
No actionable comments were generated in the recent review.
External image Recent review info
-
External image Run configuration
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: fix/issue-27667
- File: .coderabbit.config.ts
- Commit: bee4d4a
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
98454070-0b0e-4622-84b2-e1ebf4fff783
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.tspackages/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.tsThe 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:XSSuggested 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 name Status Explanation Title check Passed The title clearly and concisely describes the main fix: preventing large OpenCode user messages from crashing the usage scan. Description check Passed 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… Linked Issues check Passed Issue #27667requires the OpenCode usage scan to avoid materializing large user-message bodies while preserving assistant usage.usage.tsnow filters valid text JSON to rows with no role or `role = …Out of Scope Changes check Passed 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 #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
#27667requires the OpenCode usage scan to avoid materializing large user-message bodies while preserving assistant usage.usage.tsnow filters valid text JSON to rows with no role orrole = 'assistant', and projects only usage fields withjson_objectbefore 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.