Fix deferred tool resume executing without re-running PreToolUse callback#1008
Open
Oxygen56 wants to merge 2 commits into
Open
Fix deferred tool resume executing without re-running PreToolUse callback#1008Oxygen56 wants to merge 2 commits into
Oxygen56 wants to merge 2 commits into
Conversation
…back (anthropics#993) Bug: When using Python SDK in-process PreToolUse hook callbacks, a deferred tool on resume executes WITHOUT re-running the PreToolUse callback. The CLI replays deferred tools during startup BEFORE the initialize request sends hook callback IDs, so the pending tool executes unconditionally. Root cause: The startup sequence in both query() and ClaudeSDKClient called transport.connect() (which starts the CLI subprocess with --resume) before extracting Query parameters, creating the Query object, and calling initialize(). On resume, the CLI replays deferred tools during its synchronous startup before it reads the initialize control request from stdin. Since Python callback hooks are only sent via this initialize request (unlike settings-based command hooks which are loaded from --settings at startup), they arrive too late. Fix (two-part): 1. Restructure _process_query_inner and _connect_inner to pre- compute all Query parameters and create the Query object BEFORE connect(). This eliminates all intervening work between the subprocess starting and hooks being registered, so initialize() is called at the earliest possible moment. 2. Add an early_data parameter to SubprocessCLITransport.connect() that writes data to stdin immediately after the process starts. This provides a mechanism for further reducing the race window by putting the initialize message in the pipe buffer before the CLI enters its main read loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…scope error
Query.close() previously used anyio.TaskGroup which raised
RuntimeError("Attempted to exit cancel scope in a different task")
when close() was called from a different task during async-generator
teardown (GeneratorExit). This was fixed by PRs anthropics#746 and anthropics#870, which
replaced the task group with backend-agnostic spawn_detached()
from _task_compat.py.
This PR adds targeted regression tests that reproduce the exact
pattern from the issue:
- An async generator wrapping the message stream calls close() in
its `finally` block, matching the pattern in _process_query_inner
- The consumer breaks the loop, triggering GeneratorExit which
runs the finally block on a (potentially) different task
- Verifies that no RuntimeError is raised on either backend
Closes anthropics#983.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When using Python SDK in-process
PreToolUsehook callbacks, a deferred tool on resume executes without re-running thePreToolUsecallback. The CLI replays deferred tools during startup BEFORE the initialize request sends hook callback IDs, so the pending tool executes unconditionally.Root Cause
The startup sequence in both
query()(viaInternalClient._process_query_inner) andClaudeSDKClient.connect()(via_connect_inner) calledtransport.connect()— which starts the CLI subprocess with--resume— before extracting Query parameters, creating the Query object, and callinginitialize().On resume, the CLI replays deferred tools during its synchronous startup, before it reads the initialize control request from stdin. Since Python callback hooks are only sent via this initialize request (unlike settings-based command hooks which are loaded from
--settingsat CLI startup), they arrive too late.Fix
Two-part fix:
Restructure startup ordering: Pre-compute all Query parameters (SDK MCP servers, agents, hooks, exclude_dynamic_sections, timeout) and create the
Queryobject before callingconnect(). Afterconnect()starts the subprocess, immediately callstart()andinitialize()with no intervening work. This eliminates the window between process start and hook registration.Early data in connect(): Added an
early_dataparameter toSubprocessCLITransport.connect()that writes data to stdin immediately after the process starts. This provides a mechanism for putting the initialize message in the pipe buffer before the CLI enters its main read loop, further reducing the race window.Test Results
All 761 existing tests pass.
Related Issue
Fixes #993