Repository navigation
Description
Description
I was working on a project it went intentionally massive with spending over 40M Input tokens but all of sudden it crashed while doing forensics of evidences figured out these 3 BUGS which are as following :
Bug 1: O(N²) Text Delta Accumulation in Processor
File: packages/opencode/src/session/processor.ts
The Code (Lines ~480-500)
case "reasoning-delta":
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text // ← O(N²) BUG
// ...
case "text-delta":
if (!ctx.currentText) return
ctx.currentText.text += value.text // ← O(N²) BUG
// ...Mathematical Proof
Given:
- Let
n= total number of delta events in a session - Let
k_i= length of the i-th delta string (in characters) - Let
S_i = Σ(k_j for j=1..i)= cumulative length after i deltas
Cost of each += operation:
In JavaScript/V8, str += chunk on a rope/cons-string triggers flattening when the string is accessed externally (e.g., by the UI render loop, NDJSON serializer, or bus event broadcaster).
Flattening cost: O(S_i) — must copy all S_i characters to a new contiguous buffer.
Total cost across all n deltas:
T(n) = Σ(S_i for i=1..n)
= Σ(Σ(k_j for j=1..i) for i=1..n)
= Σ(k_j * (n - j + 1) for j=1..n) [by rearranging the double sum]
Worst case (all deltas equal length k):
T(n) = k * Σ(n - j + 1 for j=1..n)
= k * Σ(i for i=1..n)
= k * n(n+1)/2
= O(kn²)
Empirical evidence from GitHub Issue #30067:
| Run config | Per-step avg | 2h timeouts |
|---|---|---|
| Unpatched | 61s | 28% (5/18) |
| Patched | 21s | 0% (0/53) |
Impact on my working cli session:
- The 100-step sequential thinking generated ~50+ reasoning deltas
- Each delta triggered V8 rope flattening via the bus event broadcaster
- Per-step latency grew: 6s → 30s → 60s → 100s+
- Session effectively hung after ~50 turns
Bug 2: Numeric Error Code Classification Failure
File: packages/opencode/src/session/retry.ts
The Code (Lines ~62-70)
const json = parseJSON(msg)
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : "" // ← BUG
if (json.type === "error" && json.error?.type === "too_many_requests") {
return { message: "Too Many Requests" }
}
if (code.includes("exhausted") || code.includes("unavailable")) { // ← NEVER MATCHES
return { message: "Provider is overloaded" }
}Mathematical Proof
Given:
- OpenRouter returns:
{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}} json.code = 502(type:number)
Type check:
typeof json.code === "string"
= typeof 502 === "string"
= "number" === "string"
= false
Result:
code = "" (empty string)
String matching:
"".includes("exhausted") = false
"".includes("unavailable") = false
Conclusion: The retry check NEVER fires for numeric error codes.
Additional missing check: json.metadata.error_type is never inspected.
Evidence from GitHub Issue #22448:
"From my session database, I've logged 19 instances of
502 Network connection losterrors across April 9-14. None were retried."
Bug 3: Incomplete Error Classification in fromError()
File: packages/opencode/src/session/message-v2.ts
The Code (Lines ~340-400)
export function fromError(
e: unknown,
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
): NonNullable<Assistant["error"]> {
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
return new AbortedError(/*...*/).toObject()
// ...
case (e as SystemError)?.code === "ECONNRESET":
return new APIError({ isRetryable: true, /*...*/ }).toObject()
// ...
case e instanceof Error:
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
// ...
}
}Mathematical Proof
Error classification coverage:
Let E = set of all possible network errors
Let R = set of errors classified as retryable by fromError()
Currently in R:
R = {ECONNRESET, ZlibError, HeaderTimeoutError, ResponseStreamError, APICallError}
Missing from R (transient network errors):
M = {ETIMEDOUT, ENOTFOUND, EAI_AGAIN, ECONNREFUSED, EHOSTUNREACH,
ENETUNREACH, EPIPE, "fetch failed", "socket hang up",
"terminated", "other side closed"}
Coverage ratio:
|R| / (|R| + |M|) = 5 / (5 + 11) = 31.25%
68.75% of transient network errors are NOT classified as retryable.
Impact chain:
1. Network transient error occurs (e.g., undici "terminated")
2. fromError() classifies as NamedError.Unknown (not retryable)
3. retryable() returns undefined (no retry)
4. Session processor calls halt() → session dies
5. User sees "Network connection lost" with no recovery
Bug 4: Context Inflation via cache.read (Issue #30649)
File: packages/opencode/src/session/session.ts (estimated)
The Code Pattern
// Session-level token counters grow unbounded
session.tokens_input += usage.input
session.tokens_output += usage.output
session.tokens_cache_read += usage.cache.read // ← ACCUMULATES FOREVERMathematical Proof
Given:
- A session with
nturns - Each turn sends
Ccache read tokens (the full context) - Cache read tokens are cumulative, not representative of current context
After n turns:
Total cache read = n * C
Your session:
C ≈ 39M tokens (actual context)
n ≈ 2028 messages / 2 messages per turn ≈ 1014 turns
Total cache read ≈ 937M tokens (937,559,056)
The problem: If any compaction/routing logic uses tokens_cache_read as a proxy for current context size, it will think the context is 937M tokens when it's actually ~39M.
Evidence from GitHub Issue #30649:
"The inflated value is dominated by cached reads rather than actual message text."
"session-level cumulativetokens_cache_readcan dominate token totals and make context/compaction behavior diverge from real prompt size."
Combined Kill Chain (For Session)
1. Session accumulated 937M cache read tokens (Bug #4)
→ OpenCode may have treated this as actual context size
2. 100-step sequential thinking hit O(N²) slowdown (Bug #1)
→ Per-step latency grew from 6s to 100s+
→ Session effectively hung
3. yggdrasil tool had JSON parsing issues with long strings
→ Tool calls failed with "invalid" errors
4. Request payload became too large for provider
→ Provider returned transient error
5. Error classification missed the error type (Bug #3)
→ Classified as NamedError.Unknown (not retryable)
6. Retry logic failed for numeric error codes (Bug #2)
→ Even if classified, code check would fail
7. Session died with "Network connection lost"
→ No recovery attempted
Proposed Fixes
Fix 1: O(N²) → O(N) Text Accumulation
Current:
ctx.currentText.text += value.text // O(n²) totalFix:
// Use array accumulator, join once at end
if (!ctx.currentText._chunks) ctx.currentText._chunks = []
ctx.currentText._chunks.push(value.text)
// In text-end handler:
ctx.currentText.text = ctx.currentText._chunks.join("")Complexity: O(n) total instead of O(n²)
Fix 2: Numeric Error Code Handling
Current:
const code = typeof json.code === "string" ? json.code : ""Fix:
const code = typeof json.code === "string"
? json.code
: typeof json.code === "number"
? String(json.code)
: ""
const errorType = typeof json.metadata?.error_type === "string"
? json.metadata.error_type
: ""
if (code.includes("exhausted") || code.includes("unavailable") || errorType.includes("unavailable")) {
return { message: "Provider is overloaded" }
}
if (["502", "503", "524"].includes(code)) {
return { message: "Provider temporarily unavailable" }
}Fix 3: Expand Error Classification
Current:
case (e as SystemError)?.code === "ECONNRESET":
return new APIError({ isRetryable: true, /*...*/ }).toObject()Fix:
case ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN",
"ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"]
.includes((e as SystemError)?.code ?? ""):
return new APIError({ isRetryable: true, /*...*/ }).toObject()
case e instanceof Error && /terminated|fetch failed|socket hang up|other side closed/i.test(e.message):
return new APIError({ isRetryable: true, /*...*/ }).toObject()Fix 4: Separate Billing from Context Accounting
Current:
session.tokens_cache_read += usage.cache.readFix:
// Keep billing counters separate from context accounting
session.billing_tokens_cache_read += usage.cache.read
// For context/compaction decisions, use only current message sizes
session.effective_context_tokens = calculateCurrentContext(messages)Version Matrix
| Bug | Affects Versions | Fix PR | Status |
|---|---|---|---|
| O(N²) text accumulation | All versions | #30067 | Open |
| Numeric error codes | All versions | #22448 | Open |
| Incomplete error classification | All versions | #30638 | PR open |
| Context inflation via cache.read | v1.14.24+ | #30649 | Open |
References
- GitHub Issue 502
provider_unavailableerrors from OpenRouter are not retried, causing subagent/session aborts #22448 - 502 provider_unavailable errors not retried - GitHub Issue [BUG]: Desktop task execution is unexpectedly interrupted with file watcher Invalid handle and undici terminated errors on Windows #29589 - Desktop task interrupted with undici terminated errors
- GitHub Issue session/processor: text/reasoning delta accumulation is O(N²); long agent loops hang at 50-80 turns #30067 - O(N²) text/reasoning delta accumulation
- GitHub Issue Session token usage grows unbounded via cache.read and causes unrecoverable context-window errors #30649 - Session token usage grows unbounded via cache.read
- GitHub Issue [BUG] OpenCode crashes on long sessions. #22883 - OpenCode crashes on long sessions (OOM)
- GitHub PR fix(session): retry network errors, cap at 3, add retry_exhausted status #28792 - fix(session): retry network errors, cap at 3
- GitHub PR fix(session): classify transport and timeout errors as retryable #30638 - Widen retryable error classification
- GitHub Issue Network Connection Lost #20060 - Network Connection Lost (labeled bug + core)
Plugins
yggdrasil
OpenCode version
1.17.9
Steps to reproduce
The steps as per BUGS,
BUG 1 :
Steps to reproduce:
-
Start a session with a thinking-mode model (DeepSeek V4, Kimi K2, Qwen3-thinking, or similar)
-
Use sequential thinking or a task that generates many small reasoning deltas
(e.g., 100-step sequential thinking review) -
Monitor per-step latency as the session grows:
- Turn 1-10: ~6s per step
- Turn 20-30: ~30s per step
- Turn 40-50: ~60s per step
- Turn 50+: hangs or exceeds 2-hour timeout
-
Check the logs — you'll see V8 doing string rope flattening:
each "reasoning-delta" or "text-delta" event triggers
ctx.currentText.text += value.text which copies the entire
accumulated string on every access by the UI render loop
Where the bug is:
packages/opencode/src/session/processor.tsLines handling "reasoning-delta" and "text-delta" events:
case "reasoning-delta":
ctx.reasoningMap[value.id].text += value.text // O(N²)case "text-delta":
ctx.currentText.text += value.text // O(N²)
Expected: Per-step latency stays roughly constant (~6s)
Actual: Latency grows quadratically, eventually hanging
Bug 2 :
-
Configure OpenCode with OpenRouter as provider
-
Start a long-running session or subagent
-
Wait for OpenRouter to return a 502 error:
{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}} -
Check the logs — the error shows as "Network connection lost" with no retry
-
Check the session database:
SELECT data FROM message WHERE data LIKE '%Network connection lost%'
— you'll find multiple unretried instances
Where the bug is:
packages/opencode/src/session/retry.ts
Line ~62:
const code = typeof json.code === "string" ? json.code : ""
_> When json.code is 502 (a number), this becomes "".
The check code.includes("unavailable") never matches.
Expected: 502 errors trigger retry with backoff
Actual: 502 errors kill the session immediately_
Bug 3 :
Steps to reproduce:
-
Use a model with a defined context limit (e.g., mimo-v2.5-free @ 1,048,576 tokens)
-
Run a long session until you're at the limit:
Check: SELECT tokens_input + tokens_cache_read FROM session WHERE id = 'YOUR_SESSION' -
Send one more message that would exceed the limit
-
Session dies with "Network connection lost" — no compaction, no retry
-
Check session data:
The error shows as:
{"name":"UnknownError","data":{"message":""Network connection lost.""}}
Where the bug is:
packages/opencode/src/session/message-v2.ts
Line ~716:
case e instanceof Error:
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
_> Context overflow errors wrapped in generic Error fall through here.
Expected: Context overflow triggers auto-compaction
Actual: Session dies immediately_
Bug 4 :
-
Start a long-running session with a provider that reports cache read tokens
-
Keep the session active for many turns (50+ turns with cache hits)
-
Check the session database:
SELECT tokens_cache_read, tokens_input, tokens_output FROM session WHERE id = 'YOUR_SESSION' -
You'll see something like:
tokens_input: 14,774,671
tokens_output: 77,881
tokens_cache_read: 41,255,296 ← cumulative, not current context -
The session thinks it has 56M tokens of context when it actually has ~2MB of data
-
Auto-compaction triggers based on the inflated count, or the provider
rejects the request because it thinks the context is too large
Where the bug is:
packages/core/src/session/projector.ts
tokens_cache_read: SessionTable.tokens_cache_read + value.tokens.cache.read
This accumulates forever. If any compaction logic uses this as current
context size, it will trigger premature overflow.
Expected: Context checks use actual message size, not cumulative counters
Actual: Cumulative cache.read inflates perceived context size
Screenshot and/or share link
No response
Operating System
Windows 11
Terminal
Windows Terminal
Activity
github-actions commentedon Jun 22, 2026
This issue does not fully meet our contributing guidelines.
What needs to be fixed:
Please edit this issue to address the above within 2 hours, or it will be automatically closed.
This issue appears to be a duplicate of several existing open issues. Please check:
session/processor.ts(Bug 1)provider_unavailableerrors from OpenRouter are not retried, causing subagent/session aborts #22448: 502provider_unavailableerrors from OpenRouter not retried — numeric error code not handled (Bug 2)cache.read(Bug 4)If you believe this was flagged incorrectly, please let a maintainer know.