Repository navigation
tui: resume hangs at 100% CPU when a session has thousands of child sessions #57641
Copy link
Copy link
Open
Description
Summary
Resuming (opening) a session that has thousands of child subagent sessions makes the TUI spin at ~100% CPU indefinitely and never become interactive. With ~2,559 children it never recovers. With 558 or 778 children the same session opens normally. Deleting the child sessions fixes it immediately.
Environment
- opencode version: 2.0.32 (latest channel at the time; 2.1.2 was already offered)
- OS: macOS 15.8.1 (Darwin 24.6.0, x86_64)
- Terminal: Apple Terminal,
TERM=xterm-256color,TERM_PROGRAM=Apple_Terminal - Shell:
/bin/zsh - Install/channel: release install (
~/.opencode/bin/opencode), latest - Active plugins: none configured by the user (only built-in TUI plugins such as
opencode.diffs,opencode.merman,opencode.latex)
Reproduction
- Have a session with a large transcript (here: 5,809 messages / 72 MB) and more than ~2,000 child sessions created through
tasksubagents. A real-world session had 2,575 finished children. - Resume it:
opencode -s <session-id>(also reproduces withopencode -c, selecting it in the TUI, andopencode --standalone -s <session-id>). - The TUI renders the first page, then pins one core at 100% CPU and stops responding to input.
Synthetic repro without needing a real subagent run:
- Export a session:
opencode session export <id> > /tmp/parent.json. - Import it once as a new parent, then import ~2,600 empty child sessions with
POST /api/experimental/session/import, each withinfo.parentIDpointing at the new parent (and a freshinfo.id). opencode -s <new-parent-id>.
Reproducibility: 100% for us. It is not project-specific (reproduces in an empty directory) and not provider/model-specific.
Expected Behavior
The session opens to an interactive prompt, like any other session.
Actual Behavior
- While loading, the client sends 2 HTTP requests per child session:
GET /api/session/{childID}/formandGET /api/session/{childID}/permission(plus paginated child lists, 200 per page). For 2,559 children that is ~5,118 requests. - All of those requests complete in ~2 seconds (server responds in 3–40 ms each). After that the client spins forever at ~100% CPU.
sample <pid>of the hung process shows the main thread repeatedly insetTerminalTitle→writeOut(Bun), i.e. it never drains back to the event loop. No further HTTP traffic is sent.- The shared server stays healthy;
GET /api/session/{id},/message,/context,/diffall answer in milliseconds. - Memory grows to ~450–580 MB RSS; the screen keeps showing the loading spinner.
Additional Context
Measured threshold (same 5,809-message parent transcript, synthetic child sessions):
| Child sessions | Result |
|---|---|
| 0 | opens, CPU settles |
| 558 | opens (1,108 form/permission requests), CPU settles |
| 778 | opens, CPU settles |
| 2,559 | 100% CPU hang |
| 2,778 | 100% CPU hang |
Other notes:
time_archivedon the child sessions does not remove them fromGET /api/session?parentID=..., so archiving is not a workaround.- Workaround that does work: delete the child sessions (
DELETE /api/session/{id}/opencode session delete <id>), keeping the parent. After deleting 2,575 children, resume went from a permanent 100% CPU to ~3–7% and the full transcript was intact. - Data hygiene note: on this machine the DB had grown to 17 GB. A single parent's 558 children occupied 6.9 GB in
session_messageand 6.8 GB in the legacyparttable. The child-session fan-out is the main source of DB bloat in our usage. - The hang is client-side:
--standalone(private server) reproduces identically, and the shared server logs show no errors.
Relevant log lines during load (client):
message="cli starting" version=2.0.32 args="[\"-s\",\"<session-id>\"]" role=cli
message="event stream connected" component=client role=cli
# then ~5k requests:
GET /api/session/<child>/form -> 200
GET /api/session/<child>/permission -> 200
# then no further output; process stays at ~100% CPU
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
opencode-agent commentedon Oct 21, 2026
Thanks for the detailed report and the synthetic repro.
I tried it on Linux with 2.0.32, using a virtual terminal: a parent imported with 6,000 messages, 2,600 empty child sessions imported with
parentIDpointing at it, then the parent opened through/sessions. Here the TUI spiked to about 110% CPU for a few seconds while loading, then settled and accepted input normally. I couldn't reproduce the permanent hang.Your
sampleshows the main thread stuck insetTerminalTitle→writeOut, so this may depend on how the terminal handles output on macOS / Apple Terminal, which I can't test here. A maintainer with a Mac will need to look at it. Two things would help narrow it down:pcwhy commentedon Oct 21, 2026
Thanks for looking — and an important correction after longer runs: it is not a permanent hang, it is a quadratic startup stall that looks like one.
On macOS x64, with the same synthetic parent (5,809-message transcript) and zero-message children (2.0.32 and 2.1.2 behave identically):
All HTTP traffic finishes in ~2 s; everything after that is pure client CPU with no terminal output. For the real 2,575-child session that means ~1.5 minutes of an unresponsive TUI, which is why users read it as a freeze. Your Linux run finishing in a few seconds is probably a faster/coalesced path, but the scaling is still quadratic (2x children → ~4x time).
Answers to your two questions (same Mac, 2,600 children):
terminal.title=false(verified zero OSC title sequences are emitted): still pinned. SosetTerminalTitlein the profile is just where the sampler caught it, not the cause.TERM_PROGRAM/TERM_SESSION_IDunset,TERM=dumb, a clean pty, and a 200x50 window. Not terminal-specific.Likely cause in the 2.0.32 source:
packages/client/src/solid/data.ts:526-534recursively lists all children (13 pages for 2,600).registerSession(:559-578) pushes each child intostore.session.family[root]one at a time viasetStore.packages/tui/src/routes/session/index.tsx:198-201derivesdescendantSessionIDsfrom that array, and the effect at:321-328re-runs on every change and callspermission.sync+form.syncfor every descendant again.That is O(n²): ~3.4M sync calls at 2,600 children, matching the ~99 s. Suggested fix: register fetched children in one batch (single
setStore), and/or make the effect sync only newly added descendants instead of recomputing the whole set; or fetch child form/permission lazily when a child is actually opened.Happy to test a build or provide the seeding script.