Skip to content

tui: resume hangs at 100% CPU when a session has thousands of child sessions #57641

Open

Description

@pcwhy

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

  1. Have a session with a large transcript (here: 5,809 messages / 72 MB) and more than ~2,000 child sessions created through task subagents. A real-world session had 2,575 finished children.
  2. Resume it: opencode -s <session-id> (also reproduces with opencode -c, selecting it in the TUI, and opencode --standalone -s <session-id>).
  3. 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:

  1. Export a session: opencode session export <id> > /tmp/parent.json.
  2. Import it once as a new parent, then import ~2,600 empty child sessions with POST /api/experimental/session/import, each with info.parentID pointing at the new parent (and a fresh info.id).
  3. 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}/form and GET /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 in setTerminalTitle → 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, /diff all 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_archived on the child sessions does not remove them from GET /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_message and 6.8 GB in the legacy part table. 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

Activity

  1. opencode-agent commented on Oct 21, 2026

    @opencode-agent
    Contributor

    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 parentID pointing 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 sample shows the main thread stuck in setTerminalTitle → 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:

    • Does it still hang in a different terminal on the same Mac, e.g. iTerm2, Ghostty or kitty?
    • Does it still hang if you turn off the terminal title setting in the TUI config, if your version has it?
  2. pcwhy commented on Oct 21, 2026

    @pcwhy
    Author

    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):

    Children Time at ~100% CPU before the prompt appears
    558 ~5 s
    1,300 ~26 s
    2,600 ~99 s

    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):

    • iTerm2: still pinned at ~100% CPU for the full 20 s I sampled.
    • terminal.title=false (verified zero OSC title sequences are emitted): still pinned. So setTerminalTitle in the profile is just where the sampler caught it, not the cause.
    • Also still pinned with TERM_PROGRAM/TERM_SESSION_ID unset, 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-534 recursively lists all children (13 pages for 2,600).
    • registerSession (:559-578) pushes each child into store.session.family[root] one at a time via setStore.
    • packages/tui/src/routes/session/index.tsx:198-201 derives descendantSessionIDs from that array, and the effect at :321-328 re-runs on every change and calls permission.sync + form.sync for 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.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions