Skip to content

Client stalls at single-core 100% CPU during long sessions with many rapid tool-calling turns — unrecoverable except via SIGINT #42567

Closed

Description

@shmty

Title: Client stalls at single-core 100% CPU during long sessions with many rapid tool-calling turns — unrecoverable except via SIGINT

Summary

During a long-running session involving a background job monitored via repeated short tool-calling turns, the OpenCode client stalls completely — no partial output, no tool call, nothing — for 20+ minutes. Escape does not cancel the stalled turn. Only killing the process from the terminal (Ctrl+C) recovers, requiring the session to be resumed afterward. Confirmed reproducible twice in the same day, same workflow signature.

Environment

  • Provider: github-copilot, model: claude-sonnet-5
  • Platform: Linux (see full env details on request)
  • Session was long-running (~1.5h+) at the time of the stall, with substantial accumulated context (cache.read ~248,000 tokens by the time of the incident)

Reproduction pattern

  1. Start a long-running background job (in this case via tmux new -d -s NAME '<command> | tee log').
  2. Poll it repeatedly from the agent in short tool-calling turns (in this case: ~12-13 turns of sleep 115; tmux list-sessions; cat log, each turn ~2 minutes apart).
  3. After the background job actually completes (~20-25 min in), the next agent turn — which should be cheap (just reading a finished log / detecting the tmux session ended) — instead stalls completely for 20+ minutes before producing any output at all.

Evidence gathered (from ~/.local/share/opencode/opencode.db)

Querying the local SQLite session DB (message, part, event tables) for the stalled turn showed:

  • The stalled message's own time.created timestamp was ~23.5 minutes before its own first part (not even a step-start part) appeared. This rules out "a tool/bash call took a long time to execute" — the stall happened before the turn even began producing anything.
  • The eventual token/cost breakdown for that same message (once it finally completed) was: input: 2, output: 426, reasoning: 0, cache.read: 247987, cache.write: 597, finish: "tool-calls". This is a cheap, almost entirely cache-hit request — model inference for 426 output tokens should take seconds, not 23+ minutes.
  • This rules out a slow/expensive model generation as the cause of the wall-clock delay.

Key additional fact: CPU was pinned at 100% on a SINGLE core during the stall (both occurrences)

This is the detail that rules out "waiting on a slow/stalled network response" (which would show near-zero CPU, blocked on I/O) and instead points to a synchronous, non-yielding operation blocking the client's single-threaded main/event-loop. Single-core saturation + a completely unresponsive UI (Escape doesn't register at all) is consistent with the JS/Bun main thread being busy in a tight loop or an O(n²)-or-worse computation, not legitimate parallel work and not I/O wait.

Escape failing to cancel is consistent with this: if the main event loop is blocked synchronously, it cannot process the keypress event that would trigger a cancel action. Ctrl+C works because the terminal's line discipline delivers SIGINT to the process at the OS level, independent of whether the app's own event loop is currently responsive — the process then dies (unhandled/stuck signal → default termination), which matches needing to "resume the session" afterward rather than a graceful cancel.

Hypothesis (unconfirmed — I don't have access to the OpenCode source to verify)

Given the correlation with (a) a large accumulated context (cache.read ~248k tokens) and (b) many rapid successive short tool-calling turns in the same session, I suspect some client-side bookkeeping step that runs synchronously on the main thread and scales with conversation/context size (e.g. serialization, token/cache accounting, or persistence to the local SQLite DB) has worse-than-linear complexity, and becomes a multi-minute main-thread stall once a session crosses some size/turn-count threshold. I have not been able to locate the specific code path causing this.

Request

  • Any pointers to where in the codebase a per-turn synchronous operation might scale with session size would help narrow this down further.
  • In the meantime, is there a supported way to reliably interrupt a stalled turn without killing the whole client process? (Escape did not work in either occurrence.)

Diagnostic method for anyone else hitting this

Query ~/.local/share/opencode/opencode.db directly:

-- find message.time_created (turn issued) vs its parts' time_created (turn actually starts producing output)
SELECT m.id, m.time_created,
  (SELECT MIN(p.time_created) FROM part p WHERE p.message_id = m.id) as min_part,
  (SELECT MAX(p.time_created) FROM part p WHERE p.message_id = m.id) as max_part
FROM message m WHERE m.session_id = '<session_id>' ORDER BY m.time_created;

-- pull full token/cost breakdown for a specific stalled message from the event log
SELECT data FROM event WHERE aggregate_id = '<session_id>' AND type = 'message.updated.1'
  AND data LIKE '%<message_id>%' ORDER BY seq DESC LIMIT 1;

Happy to provide the exact (anonymized) query outputs from both occurrences if useful.

Activity

  1. shmty commented on Aug 14, 2026

    @shmty
    Author

    Closing — not pursuing this upstream for now.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions