Skip to content

Token counting can block OpenCode's event loop for minutes on long single-character runs #659

Open

Description

@kernel-oops

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.mjs

Observed 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

  1. aiev commented on Oct 19, 2026

    @aiev

    Same failure mode here, measured from the host side (also written up in #638):

    • Super-linear cost confirmed for degenerate runs, warm, same process: 4k chars ~20 ms, 10k ~116 ms, 16k ~315 ms (cold process measured ~0.5 s at 10k — right where the JS/WASM warmup sits). We did not reproduce minutes-scale cases at 100k (175 ms) on this box, but 500k+ is clearly on the wrong side of the curve.
    • End to end in a real session: one countTokens call over the session text (7.8 MB) blocked the server for ~3 s (100% CPU for 47–79 s during the compress pass, /api/info unanswered 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions