Repository navigation
Token counting can block OpenCode's event loop for minutes on long single-character runs #659
Description
Summary
DCP counts tokens with @anthropic-ai/tokenizer's countTokens(), which encodes synchronously on the calling thread. For ordinary text this is fast, but its encode time grows super-linearly for long runs without natural token boundaries (for example one character repeated). While a count runs, the OpenCode process cannot service anything else: the UI, server requests and other sessions all stall.
DCP counts such text in several places that see untrusted or model-generated content: the system prompt, tool inputs/outputs in the tool cache, message priorities, /dcp context, and compression summaries. One sufficiently pathological string in any of them blocks the host for as long as the encode takes.
This is separate from #638/#639, which reduce the fixed per-call setup cost of the tokenizer. Reusing the tokenizer makes ordinary counts cheaper but does not bound a single slow encode, which still runs on the event loop and cannot be interrupted. #621 discusses broader long-session freezes; this issue is only about bounding the tokenizer itself.
Reproduction (synthetic, bounded)
repro.mjs (below) counts ordinary prose and one repeated character at increasing sizes, with a 25 ms heartbeat timer to measure how long the event loop is blocked. In a directory with @anthropic-ai/tokenizer@0.0.4 installed:
timeout 60 node repro.mjs
timeout 60 bun repro.mjsObserved on Linux x64 (Node 24.22.0, Bun 1.4.2):
| Input | Characters | Node: encode / max event-loop gap | Bun: encode / max event-loop gap |
|---|---|---|---|
| ordinary prose | 100,000 | 40 ms / 41 ms | 41 ms / 45 ms |
| one repeated character | 10,000 | 138 ms / 138 ms | 69 ms / 70 ms |
| one repeated character | 25,000 | 326 ms / 326 ms | 238 ms / 238 ms |
| one repeated character | 50,000 | 1,221 ms / 1,221 ms | 890 ms / 890 ms |
| one repeated character | 100,000 | 4,910 ms / 4,910 ms | 3,368 ms / 3,368 ms |
Doubling the input roughly quadruples the time, so the default sizes stop at 100,000 characters. In a separate measurement, 500,000 repeated characters took about 84 seconds, with the event loop blocked throughout.
repro.mjs
// Synthetic reproduction: @anthropic-ai/tokenizer's countTokens() runs synchronously on the
// calling thread, and its encode time grows super-linearly for long runs without natural
// token boundaries. A 25 ms heartbeat timer shows how long the event loop is blocked.
//
// Usage (in a directory with @anthropic-ai/tokenizer@0.0.4 installed):
// timeout 60 node repro.mjs [maxCharacters]
// Default sizes stay small (at most 100,000 characters, a few seconds).
import { countTokens } from "@anthropic-ai/tokenizer"
const max = Number(process.argv[2] ?? 100_000)
const sizes = [10_000, 25_000, 50_000, 100_000].filter((n) => n <= max)
let last = performance.now()
let maxGap = 0
const heartbeat = setInterval(() => {
const now = performance.now()
maxGap = Math.max(maxGap, now - last)
last = now
}, 25)
const tick = () => new Promise((resolve) => setTimeout(resolve, 100))
for (const [shape, unit] of [
["ordinary prose", "The quick brown fox jumps over the lazy dog. "],
["one repeated character", "a"],
]) {
for (const n of sizes) {
await tick()
maxGap = 0
last = performance.now()
const text = unit.repeat(Math.ceil(n / unit.length)).slice(0, n)
const started = performance.now()
const tokens = countTokens(text)
const ms = performance.now() - started
await tick()
console.log(
JSON.stringify({
shape,
characters: n,
tokens,
encodeMs: Math.round(ms),
maxEventLoopGapMs: Math.round(maxGap),
}),
)
}
}
clearInterval(heartbeat)Why a worker thread is not enough
Moving countTokens() into a worker_threads Worker keeps the main loop responsive, but under Bun (which OpenCode uses) worker.terminate() does not stop a Worker that is executing the tokenizer's WASM, so a pathological count keeps a CPU core busy until it finishes and later counts queue behind it. Under Node, terminate() does stop it. A reliable bound therefore needs a process that can be killed.
Expected behaviour
- Token counting never blocks the host event loop.
- A count that takes too long is stopped and reported as an error, without silently falling back to an estimate (which would change compression decisions and stored summary sizes).
- Successful counts are unchanged.
Activity
aiev commentedon Oct 19, 2026
Same failure mode here, measured from the host side (also written up in #638):
countTokenscall over the session text (7.8 MB) blocked the server for ~3 s (100% CPU for 47–79 s during the compress pass,/api/infounanswered for 9 s), the clients' health check (2 s × 3) then declared the service unresponsive — three warnings in one afternoon (16:40, 16:54, 17:06 UTC), all during compression, plus a SIGKILL of the service after a stalled stream — and the compact restarted in a loop every ~2 minutes.Our fork bounds the per-call work instead of moving it off-process: above ~16k characters (env-overridable) it counts head/middle/tail samples and extrapolates, and any input with a long repeated-character run is sampled regardless of size. Worst case per call drops to tens of ms and stays on the event loop, so no IPC/async rework is needed; sampled counts stayed within 0.6% of exact on our corpus (prose/JSON/code, 32 KB–8 MB). If you would rather land #660's child-process design, we are happy to drop ours and only contribute the measurements and benches.