Skip to content

fix(server): stop no longer hangs on an unresponsive Claude runtime - #7349

Closed
nhorto wants to merge 2 commits into
pingdotgg:mainfrom
nhorto:fix/claude-stop-unresponsive-runtime
Closed

fix(server): stop no longer hangs on an unresponsive Claude runtime#7349
nhorto wants to merge 2 commits into
pingdotgg:mainfrom
nhorto:fix/claude-stop-unresponsive-runtime

Conversation

@nhorto

@nhorto nhorto commented Aug 17, 2026

Copy link
Copy Markdown

The problem

ClaudeAdapter.interruptTurn awaited query.interrupt() with no bound. That call writes a control request to the CLI's stdin and waits for an acknowledgement, so a wedged CLI — alive, but no longer answering — makes the promise never settle.

The thread is then unrecoverable from any client. The session projection stays running with active_turn_id pinned to a turn that is already terminal, every further Stop press appends another thread.turn-interrupt-requested that cannot settle, and the only way out is finding the child PID and killing it by hand.

Two knock-on effects are worth calling out, because they make this worse than a dead Stop button:

  • The mobile outbox gates delivery on session.status === "running" (use-thread-outbox-drain.ts:317), so queued messages sit undelivered for as long as the session is stuck.
  • Archiving is refused while a session is running with an activeTurnId (useThreadActions.ts:214), so the user cannot even clear the thread away.

I hit this on a real thread: 30 interrupt events over 44 minutes, all no-ops, against a claude child that had been sleeping at 0.4% CPU with no output for over an hour. SIGTERM on that one PID let the adapter finalize the session immediately, and three queued mobile messages delivered themselves a second later.

This looks like the mechanism behind #4713 and #5587.

The fix

Bound the interrupt at 10 seconds. If the runtime does not acknowledge, tear the session down instead — the same stopSessionInternal path the process exit would have taken, which completes the turn as interrupted and frees the thread.

That teardown had a second unbounded await on the same control channel, caught by Bugbot: stopSessionInternal runs completeTurn, which probes getContextUsage with no bound, so a wedged runtime could stall the escalation in the same place the interrupt did. That probe is now bounded at one second, which covers the normal completeTurn path too. #5891 bounds the same call for the same reason.

The bounded stopTask loop directly above already guards this exact hazard for child tasks, and its comment names it: "Effect.ignore handles rejection, not non-resolution". The parent call was simply missed.

A rejected interrupt() deliberately keeps its existing behavior. Rejections can be benign, and tearing down a healthy session on one would be worse than the bug.

Relationship to #5891

#5891 supersedes this if it merges — it removes interrupt() and stopTask() from interruptTurn entirely in favor of closing the query, so there would be no unbounded await left to bound. I opened this because #5891 has been idle since Aug 9 and this bug is still live on main, but maintainers should take whichever they prefer rather than both.

The two differ in more than size: this keeps the graceful interrupt and only escalates when the runtime proves unresponsive, whereas #5891 always hard-closes. #6531 applies the same "don't block on a provider call that may hang" shape to OpenCode.

Validation

  • vp test run apps/server/src/provider/Layers/ClaudeAdapter.test.ts — 71 passed, including the new case
  • The regression test hangs both interrupt and getContextUsage, since a wedged CLI stops answering its control channel altogether rather than just one request
  • Each bound was reverted independently to confirm the test bites: either one alone leaves the test hanging until the 60s vitest timeout
  • vp lint on both files — clean apart from one pre-existing warning outside the diff
  • vp run --filter t3 typecheck — exit 0

Not audited: whether Codex, Cursor, Grok, or OpenCode share the hazard. They are structurally different (runtime-delegated, ACP, and HTTP abort respectively), and #5587 raises the same question. Worth a separate pass.

Made with Claude Opus 5 (1M context) in T3 Code through the Claude Code harness.


Note

Medium Risk
Changes session stop and teardown paths for Claude threads; mis-tuned timeouts could tear down healthy sessions, but the change replaces indefinite hangs on a known production failure mode.

Overview
Stop no longer blocks forever when the Claude CLI accepts control requests but never answers. interruptTurn still tries query.interrupt() first, but after 10 seconds without acknowledgement it logs claude.turn.interrupt.timeout and tears the session down via stopSessionInternal (turn completes as interrupted, thread unpinned). Rejected interrupts are unchanged.

Teardown was also stalling on the same channel: queryCurrentContextUsage now caps getContextUsage at 1 second so completeTurn cannot hang while escalating after a wedged interrupt.

Tests add a fake runtime that never resolves interrupt/context usage and assert Stop returns, the query closes, and turn.completed is interrupted.

Reviewed by Cursor Bugbot for commit 69b9d6e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix interruptTurn hang when Claude runtime is unresponsive

  • interruptTurn in ClaudeAdapter.ts now bounds the query.interrupt call with a 10-second timeout; on timeout it logs a warning and force-stops the session via stopSessionInternal, emitting exit events.
  • queryCurrentContextUsage now wraps getContextUsage in a 1-second timeout, returning undefined instead of hanging indefinitely.
  • Behavioral Change: sessions that previously hung forever on an unresponsive runtime will now be torn down after 10 seconds with an interrupted turn.completed event.

Macroscope summarized 69b9d6e.

Claude's interruptTurn awaited query.interrupt() with no bound. A wedged
CLI never answers that control request, so Stop hung forever: the session
projection stayed `running`, every later Stop press queued another
interrupt that could not settle, queued client messages never drained,
and the thread could not even be archived. Recovery meant hunting the
child PID and killing it by hand.

The interrupt is now bounded at 10 seconds, and a runtime that will not
acknowledge is torn down instead — the same path its own process exit
takes, which completes the turn and frees the thread. The bounded
stopTask loop directly above already guarded this exact hazard for child
tasks; the parent call was missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 958c2727-9ee9-4ea1-a1c4-c06c543ba456

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2e4b3b1. Configure here.

Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
@macroscopeapp

macroscopeapp Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at 69b9d6e

Macroscope's review found this PR approvable — This is a straightforward bug fix that adds timeouts to prevent indefinite hangs when the Claude runtime becomes unresponsive. The changes are limited in scope (two timeout additions), well-documented with comments explaining the reasoning, and include comprehensive test coverage.

You can add or adjust custom eligibility rules. Learn more.

Bugbot caught that the escalation could hang where the interrupt did.
stopSessionInternal runs completeTurn, which probes getContextUsage over
the same control channel that just failed to answer, and that await was
unbounded — so a wedged runtime could stall the very teardown meant to
free the thread.

Bound it at one second. A skipped context-meter refresh is invisible
next to a stranded turn.

The regression test now hangs its getContextUsage too, since a wedged
CLI stops answering the channel altogether rather than just the
interrupt. The previous fake omitted the method, so it only ever
exercised the short-circuit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@t3dotgg

t3dotgg commented Aug 23, 2026

Copy link
Copy Markdown
Member

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing this PR after an automated pass over open pull requests. The same Claude stop behavior already shipped in #5891.

@t3dotgg t3dotgg closed this Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:S 10-29 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants