Session hangs mid-turn after a PreToolUse hook returns: tool never dispatches (2.1.220, Windows)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Session hangs mid-turn after a PreToolUse hook returns: tool never dispatches, no timeout, no error
Environment
- Claude Code 2.1.220 (native binary, VS Code extension
anthropic.claude-code-2.1.220-win32-x64) - Windows 11 Pro 10.0.26200, PowerShell 7.6.6, Node v24.16.0
- Hooks configured in
~/.claude/settings.json: oneUserPromptSubmit, twoPreToolUse
(matchers Bash and PowerShell, each with if: "<tool>(git *)"), all pwsh scripts
Summary
A session stops mid-turn with no error, no timeout and no crash. claude.exe stays alive and its
event loop keeps ticking (background timers still fire), but the current turn never advances. The
last transcript entry is a tool_use with no matching tool_result — the tool is never dispatched.
Esc does nothing; a subsequent prompt is accepted into the queue and never processed. The only
recovery is killing the process and --resume.
It happens specifically on turns where a PreToolUse hook ran and returned. The hook is not the
blocker — see the evidence below, which is the point of this report.
The fingerprint
- Transcript's last line is
tool_use, with notool_resultafter it. - The hook process for that tool call has already exited (present in
Win32_Processas a zombie,
absent from Get-Process).
claude.exeburns ~0.4–1.2% of one core — idle, not spinning.- The status message from the hook's
statusMessagefield stays on screen indefinitely, because the
parent never moves past that stage.
Why this is not the hook (the part I can now prove)
After two earlier hangs on 2026-09-16 that were my own hook's fault (a script blocking on[Console]::In.ReadToEnd() waiting for an EOF that never came), I rewrote the shared stdin reader to
return as soon as the received bytes parse as JSON, and added a trace log around it.
On the most recent occurrence the trace shows the full hook lifecycle completing in 106 ms:
13:09:53.956 pid=43976 + 79ms review-before-commit start
13:09:53.983 pid=43976 + 103ms review-before-commit stdin ok - 548 byte(s) in 1 read(s), 16ms, event=PreToolUse
13:09:53.986 pid=43976 + 106ms review-before-commit verdict allow (silent) - not a commit
The transcript for that session goes silent at 13:09:53 — the same second. So: the hook started,
read its payload, emitted its verdict, and exited, and the session hung anyway. The hook's configured
timeout is 20 s and it finished in 106 ms, so no timeout was involved.
Note the verdict on that path is an exit 0 with zero bytes on stdout (the gate only acts on
commits; this command was not one). ~91% of this hook's invocations take that silent path. If the
parent distinguishes "hook produced no output" from "hook has not produced output yet" by waiting for
the stdout pipe to close, a zero-byte response is the case most likely to expose a race there. That
is speculation about the cause; everything above it is measured.
Occurrences (after my own hook bug was fixed)
| When | Session uptime | Transcript | Hook that ran | Hook verdict reached? |
|---|---|---|---|---|
| 2026-09-17 ~10:06 | 26 h | 8.4 MB | context-gauge (UserPromptSubmit) | yes — cleared by probe in 1.2 s |
| 2026-09-18 12:24:20 | 4.4 h | 4.4 MB | review-before-commit (PreToolUse) | yes — process had exited |
| 2026-09-18 13:09:53 | ~27 min | 4.4 MB | review-before-commit (PreToolUse) | yes — trace log, 106 ms |
Session age and context size are not preconditions: the third occurrence was a 27-minute-old
session resumed from --resume. The 2026-09-17 instance additionally logged[event-loop-stall] Total stalls: 77, cumulative: 366957ms.
Ruled out
- Hook blocking on stdin — the original 09-16 cause; fixed, and disproven since by both a real
unclosed-pipe probe and the trace log above.
- Nested shell wrapper —
settings.jsonpreviously had"shell": "powershell"andpwsh -File
in the command, so hooks ran nested and the timeout killed the wrapper rather than the script,
leaving an orphan holding the stdout pipe. Commands are now & 'path\to\script.ps1'.
- Hook timeout — 106 ms against a 20 s budget.
- Orphaned tool children — reaped; the hangs persist without them.
- Windows event log / crash — empty; the process never dies.
Workaround
Kill claude.exe and claude --resume <session-id>. Nothing is lost, as file edits are already on
disk, but the in-flight turn and its reasoning are gone.
What would help
- A dispatch-stage timeout, so a lost hook verdict surfaces as an error rather than an indefinite hang.
- Debug logging at the PreToolUse boundary that records the verdict as received by the parent, so the
gap between "hook wrote it" and "parent consumed it" is observable without instrumenting the hooks.
- Confirmation of whether a zero-byte
exit 0from a PreToolUse hook is handled on a different path
from one that writes a permissionDecision JSON object.
What Should Happen?
The session should not hang.
Error Messages/Logs
Steps to Reproduce
This is hard to reproduce. Most of the time it doesn't happen. See description.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.220
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
VS Code integrated terminal
Additional Information
_No response_
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
After two more occurrances, and looking at #81425, we have the following findings:
Cross-referencing #81425, which was suggested as a possible duplicate. It is not a duplicate, but I no
longer think it is a separate bug either: a freeze today puts a third failure point on the same path,
and the three of them line up in sequence. I also tested the workaround #81425 proposes, and it does not
work. Windows / VS Code extension.
Three stalls, one handoff
Normal order in my logs is: PreToolUse hook verdict -> permission classifier ->
tool_dispatch_start->tool_dispatch_end. I now have a hang at each of the first three boundaries:classifier_request_finished, no dispatch - #81425.tool_dispatch_start, notool_dispatch_end- what I originally reported here.Three consecutive points on one path, each losing the pending call silently and with no timeout, reads
like one defect - a permission verdict lost in the handoff to dispatch - rather than three.
The new one, in detail
Session's last transcript entry is an assistant message with
stop_reason=tool_useand notool_result.29 minutes later the process was idle (0s CPU over a 5s sample), so this is past the 600s ceiling, not a
long-running tool.
The hook is positively cleared - it ran, answered in 113 ms and exited. This is not a logging gap either:
of that session's 47
tool_useentries, 46 have atool_dispatch_startin the extension log andonly the last one has none. Across the whole log file - 231 dispatch starts, 229 ends, 154 classifier
start/finish pairs - nothing else is unmatched.
Worth noting for #81425's channel-error theory: I get
claude-vscode notification channel error: Received a response for an unknown message IDexactly onceper process, at startup, and never again. If that warning is suppressed after the first occurrence, a
message dropped later in the session leaves no trace anywhere - which is consistent with all three
failure points above.
The proposed workaround does not recover the session
#81425 says "only closing and reopening the chat (which rebuilds the webview channel) recovers the
session". Tested on a session frozen at stage 1 above. It does not recover it:
The reopen does not reattach to the stuck backend. It starts a fresh one against the same session file and
replays the transcript into the webview, so the UI looks restored - the last line is there - while the
pending tool call is still undispatched and that turn is permanently dead. It also orphans the original
backend (~450 MB each time).
So it is equivalent to killing the process and running
claude --resume <session-id>, minus the kill.As far as I can tell there is currently no recovery that preserves the in-flight turn, on either
issue. A timeout on the permission/dispatch path would not just avoid the hang, it would be the only way
to avoid losing the turn.
Two other things found while chasing this
A 600 s false positive - worth knowing before anyone debugs this. I diagnosed a hang that was not one.
Every indicator matched: last transcript entry a
tool_usewith notool_result,tool_dispatch_startwith no
tool_dispatch_end, the process at ~1% of one core, and the tool's child process still alive wellpast the default 120 s timeout. It was fine. The call had been dispatched with the maximum 600 s timeout,
which fired exactly on schedule (
tool_dispatch_end outcome=ok durationMs=600707) and moved the commandto the background, where it returned its output 53 s later. A live tool child past 120 s proves
nothing - check the clock against
tool_dispatch_start + 600 s.Tool grandchildren are orphaned, not reaped. Tool commands are not spawned in a job object, so closing
VS Code kills the session but not the tree below the immediate child. A
pwsh -> dotnet test -> vstest.console -> testhostchain left atesthost.exealive across three days andseveral full VS Code restarts. It held the project's build output directory, which caused four consecutive
MSB3027 "file in use" build failures and made the next
dotnet testblock for 10m56s - for a suite thatruns in 4 seconds. That run then hit the 600 s cap and leaked a fresh orphan of its own, so the failure
is self-perpetuating.
Environment
Claude Code 2.1.220.564, native binary, VS Code extension, Windows 11 Pro 26200. #81425 is Cursor on
macOS at 2.1.220.26d, so whatever the shared root cause is, it is not platform specific.
And another one:
Fourth stall stage, 16 minutes after my last comment — and this one is not on the tool path at all, which I think means the title of this issue is too narrow.
Same freeze signature, but on UserPromptSubmit, with no tool involved and no
tool_useentry anywhere near it:The turn never reached the model. The next
[API REQUEST]in the log belongs to a different sessionfour minutes later.
claude.exeburned 0.02 s of CPU over a 5 s sample 5 minutes after the freeze —idle, not spinning. The prompt is simply gone.
So across the occurrences I've now logged, the hang lands at four different stages:
classifier_request_finished-> no dispatch (#81425)tool_dispatch_start-> notool_dispatch_end(the original report above)The common factor in every one of mine is not the tool pipeline — it is that a hook completed and
whatever was awaiting its result never resumed, at whichever stage happened to come next. Stage 0 has
no permission decision, no classifier and no tool in it, so "lost permission verdict during the
permission->dispatch handoff" (my earlier framing) is too specific. Suggest retitling to something like
"Turn pipeline stalls after a hook returns; next stage never starts (UserPromptSubmit and PreToolUse)".
Negative evidence, since bloat keeps coming up as an explanation for this class of report:
| | 09-17 freeze | this freeze |
|---|---|---|
| process uptime | 26 h | 1 h 9 min |
| context | 272k tokens | 130k tokens |
| transcript | 8.4 MB | 1.1 MB |
| RSS | 467 MB | 448 MB |
|
[event-loop-stall]| 77 stalls, 366957 ms | 24 stalls, 37273 ms |An order of magnitude apart on stall accounting and 20x apart on uptime, identical outcome. Neither
session age, context size, nor cumulative event-loop stall predicts it.
Two small asks that would cost you little and help a lot:
queue-operationenqueue record. Right now those entries carryonly
type,operation,timestamp,sessionId— no text. So when the stall happens at stage 0the user's prompt is unrecoverable even via
--resume, because it was never written anywhere. If thetext were in the record,
--resumecould replay it and this class of stall would stop costing work.[Stall]log lines withsessionId. Two sessions in one VS Code window write to the sameClaude VSCode.logwith no session or PID marker, so reconstructing which session died means mappingPID->session via
--resume=on the command line and untangling interleaved lines by timestamp.And the earlier ask, generalised: a watchdog on every stage, not just dispatch. If a dequeued prompt
has produced no API request within N seconds, or a returned hook verdict has produced no next-stage
event, surface an error and let the turn fail. Any error at all would be better than a session that sits
idle forever with a frozen status message — at the moment the only recovery is
Stop-Process -Forceplus
claude --resume <id>, and the in-flight turn is always lost.Version unchanged: Claude Code 2.1.220, native binary, VS Code extension, Windows 11.
And while looking into it further, we found the following:
Follow-up to my two comments above. I wrote a triage script for the next occurrence, and building it
forced me to read the
[Stall]log format properly. That produced one piece of direct evidence I didnot have before, and falsified one claim I made earlier. Both below.
Direct evidence that the verdict is lost between the hook and its consumer
This is the thing I would look at first if I were picking this up.
When a PreToolUse hook returns, the extension logs that it consumed the output:
That line appears reliably, roughly half a second after each verdict. So I correlated my own hook trace
log (which records
start/stdin/verdict/exitper invocation) against that line, over oneday in one log file:
One verdict out of 67 was never consumed, and it is the one the session died on. The hook process
wrote its verdict and exited 0 at 13:04:46.877 (113 ms, against a 20 s timeout). Nothing on the
extension side ever acknowledged it.
It is stronger than that. The extension log has no lines at all between 13:04:39.141 and
13:04:53.457 - a 14.3 s total silence - and that gap contains both the creation of the stalled tool call
(13:04:45.084) and its hook invocation (13:04:46.841). Every other tool call in the same minute produces
a burst of chatter (
Permission suggestions for PowerShell: [],Fast mode unavailable: ...,Skipping hook due to if condition ...,Dynamic tool loading: ...). The stalled call produced not oneline of it - not even the
Skipping hookline it would emit if the matcher had declined.So the call exists in the transcript, the hook ran for it and answered, and the extension side has no
record that it ever happened.
Correction: I was wrong to split "before the classifier" from "after the classifier"
I distinguished "hook verdict returned, classifier never starts" from "classifier finished, no
dispatch". That does not hold, because the classifier is optional:
No classifier line precedes that, or exists near it. A call whose permission decision is already
determined skips the classifier and dispatches directly; I confirmed the same on a live healthy call.
So "no
classifier_request_started" is normal and not a symptom, and my stage 1 / stage 2 boundary isnot observable per call. Please read that part of my earlier comment as a list of observations, not of
distinct stages. What they share is what the section above shows: a verdict that returned and a dispatch
that never happened.
The untagged shared log is a correctness problem, not just an inconvenience
I asked earlier for
sessionIdon[Stall]lines. I want to upgrade that request, because its absenceproduced two confident, wrong diagnoses of my own freeze while I was writing the script.
Both sessions in one VS Code window write to one
Claude VSCode.logwith no session tag, so a stalledcall cannot be separated from a neighbouring session's healthy traffic by timestamp. Joining "the
classifier run nearest after the stalled request" gave me first a run that had started before the
frozen call and merely finished 8 s after it, then - after I anchored on the start instead - a run from
four minutes later belonging to the other session. Both rendered a clean-looking "classifier
finished, dispatch never started, this is #81425". Both were wrong: no classifier ran for that call at
all. Anyone triaging this from logs will hit the same trap.
There is a second half. Even within one session the two stages cannot be joined, because they share no
key:
reqIdappears on classifier lines only;toolUseIdon dispatch lines only; the sole link is adjacency.Putting
toolUseIdon the classifier lines (orreqIdon the dispatch line) would make the permissionpath traceable end to end, and costs one field.
You already have most of the watchdog
Slow permission decision: 15277ms for PowerShellappears 42 times in this one log. The detector for aslow decision exists; what is missing is the detector for no decision at all.
For calibration, measuring every finished classifier run against the next dispatch in the same log:
median gap 1.0 ms, 200 of 235 within 10 ms. The 33 runs above 2 s line up with the
Slow permission decisionentries, i.e. decisions that waited on me. So a threshold here would need tobe generous, or to exclude calls awaiting user input - but "hook verdict returned, nothing dispatched,
nothing logged, indefinitely" has no legitimate case at all, and that is the one I keep hitting.
The stalled call, exactly
PowerShellcd <repo>; foreach ($f in @('Global.json', ...)) { ... (git show ":$path") ... }stdin ok1198 bytes,verdict allow13:04:46.877,exit 0 - the only hook invocation in the window
tool_dispatch_start, no error, no timeoutNot a logging gap: of that session's 51
tool_useentries, 50 have atool_dispatch_startand onlythis one has none. Across the whole file, dispatch starts and ends differ by exactly the one call still
legitimately in flight.
Version
Still on 2.1.220 (VS Code extension, bundled native binary). 2.1.278 is current; I am updating and will
report back either way. I found nothing in 2.1.268-2.1.278 describing this path - the nearest is
2.1.278's "sessions that could hang with no result after an internal error", mentioned only because the
extension drives the binary over the SDK. I could not read the 2.1.221-2.1.267 notes.
Happy to attach the triage script if it is useful; it encodes the two log traps above.
We made some progress investigating, as it keeps happening:
Still reproduces on 2.1.278 (this issue was filed against 2.1.220), native binary, VS Code
extension, Windows 11. Adding one new discriminator, one environmental multiplier that may
explain why this reporter sees it so often, and two theories we chased and ruled out.
1. The Bash tool completes what the PowerShell tool stalls on
Same build-and-run, same session, minutes apart: stalls indefinitely when issued through the
PowerShelltool, completes in seconds through theBashtool.The stalled call has an unambiguous signature, confirmable in one command:
.outputfile stays at 0 bytes indefinitely, andtasklistreports nodotnet.exe/MSBuild.exe/testhost.exeat any point.No child and no output means the work never started. This is a dispatch-path failure, consistent
with stages 0-2 in the four-stage list above, and in this environment it appears specific to the
PowerShell tool path.
Reliable workaround: stop the task and re-run identical work through the Bash tool, redirected to
a log file. Builds that had "hung" for 10+ minutes finished in under 30 s every time.
2. Process creation on this machine costs ~10x normal
Possibly the answer to "why this reporter and not others". On an otherwise idle machine
(CPU 12%, RAM 45% of 63.7 GB, disk 2% busy, queue length 0):
| spawn (10 runs each) | ms each |
|-----------------------------|---------|
|
cmd /c exit| 213 ||
pwsh -NoProfilestartup | 552 ||
git --version(PowerShell)| 327 → 532 over 20 min ||
/usr/bin/true(Git Bash) | 408 ||
git --version(Git Bash) | 487 |Healthy is 20-40 ms. Note
/usr/bin/truefrom bash at 408 ms — it does nothing at all, and bashpays the same tax, so this is not a PowerShell property. Cause: Defender real-time protection is
off and Palo Alto Cortex XDR (
PSANHost+ thepselamsvcELAM driver) hooks every processcreation.
Why it may matter here: if the dispatch stall is a race, a 10x-slower spawn widens the window on
every tool call. The same session logged
[event-loop-stall] Total stalls: 77, cumulative:. It may be reproducible in-house by artificially delaying process creation.366957ms
3. Two theories ruled out — please don't spend triage time on them
EDR blocking process teardown: no. 10
pwshprocesses told to exit immediately,WaitForExit(15000): 0 of 10 failed to exit. The agent makes spawning slow; it does not blockteardown.
MSBuild node-reuse orphans holding the stdout pipe past EOF: no. This looked right — 9 orphaned
nodemode:1workers with a dead parent, node reuse enabled, and a bash/PowerShell split. It doesnot hold: the stalled calls had no child process at all, so nothing was holding a pipe. Separately,
in an earlier occurrence an orphan was holding the pipe and the call still ended with
tool_dispatch_end outcome=ok.4. Hooks remain positively cleared
343 hook runs on 2026-09-21, zero stdin anomalies. Each completes in ~120 ms against a 20 s
timeout (
hook-trace.log). The last stdin-level incidents were 2026-09-18, before the fix.