fix(server): stop no longer hangs on an unresponsive Claude runtime - #7349
fix(server): stop no longer hangs on an unresponsive Claude runtime#7349nhorto wants to merge 2 commits into
Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ 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.
ApprovabilityVerdict: Approved at 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>
|
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. |

The problem
ClaudeAdapter.interruptTurnawaitedquery.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
runningwithactive_turn_idpinned to a turn that is already terminal, every further Stop press appends anotherthread.turn-interrupt-requestedthat 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:
session.status === "running"(use-thread-outbox-drain.ts:317), so queued messages sit undelivered for as long as the session is stuck.runningwith anactiveTurnId(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
claudechild that had been sleeping at 0.4% CPU with no output for over an hour.SIGTERMon 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
stopSessionInternalpath the process exit would have taken, which completes the turn asinterruptedand frees the thread.That teardown had a second unbounded await on the same control channel, caught by Bugbot:
stopSessionInternalrunscompleteTurn, which probesgetContextUsagewith 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 normalcompleteTurnpath too. #5891 bounds the same call for the same reason.The bounded
stopTaskloop 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()andstopTask()frominterruptTurnentirely 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 onmain, 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 caseinterruptandgetContextUsage, since a wedged CLI stops answering its control channel altogether rather than just one requestvp linton both files — clean apart from one pre-existing warning outside the diffvp run --filter t3 typecheck— exit 0Not 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.
interruptTurnstill triesquery.interrupt()first, but after 10 seconds without acknowledgement it logsclaude.turn.interrupt.timeoutand tears the session down viastopSessionInternal(turn completes as interrupted, thread unpinned). Rejected interrupts are unchanged.Teardown was also stalling on the same channel:
queryCurrentContextUsagenow capsgetContextUsageat 1 second socompleteTurncannot 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.completedis interrupted.Reviewed by Cursor Bugbot for commit 69b9d6e. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix
interruptTurnhang when Claude runtime is unresponsiveinterruptTurnin ClaudeAdapter.ts now bounds thequery.interruptcall with a 10-second timeout; on timeout it logs a warning and force-stops the session viastopSessionInternal, emitting exit events.queryCurrentContextUsagenow wrapsgetContextUsagein a 1-second timeout, returningundefinedinstead of hanging indefinitely.turn.completedevent.Macroscope summarized 69b9d6e.