fix(wait_for_tools): unblock event loop via asyncio.to_thread#21
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #21 +/- ##
==========================================
- Coverage 86.85% 86.67% -0.18%
==========================================
Files 38 38
Lines 1719 1719
Branches 204 204
==========================================
- Hits 1493 1490 -3
- Misses 167 169 +2
- Partials 59 60 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
This was referenced Apr 19, 2026
569d002 to
8e261bc
Compare
tony
added a commit
that referenced
this pull request
Apr 19, 2026
Member
Author
Code reviewNo issues found. Checked for bugs and AGENTS.md compliance (repo has AGENTS.md, no CLAUDE.md). Scope:
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
wait_for_channel and signal_channel were sync `def` handlers running blocking subprocess.run(timeout=N) on FastMCP's event loop — for the duration of the wait the server couldn't service other tool calls, MCP pings, or client cancellations, and clients whose stdio keepalive was shorter than the timeout would disconnect mid-wait. Port to async def + asyncio.to_thread, matching the pattern already used by wait_for_text / wait_for_content_change (added in 0.1.0a2 via 0a408fe). Swap @handle_tool_errors → @handle_tool_errors_async on both. The narrow subprocess.* except blocks and the async decorator's Exception-only catch mean asyncio.CancelledError now propagates through naturally — tested as a regression guard. Also convert the one existing caller of wait_for_channel in tests/test_pane_tools.py to asyncio.run() so mypy stays clean. Closes #18
5b919b6 to
9546080
Compare
asyncio.to_thread
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
wait_for_channelandsignal_channelfrom syncdef→async def.subprocess.run(...)inawait asyncio.to_thread(...)so the FastMCP event loop is not starved for the duration of the wait.@handle_tool_errors→@handle_tool_errors_async.Before the fix, any MCP client whose stdio keepalive was shorter than the
wait_for_channeltimeout could disconnect mid-wait, and no other tool call could be serviced while a wait was pending. This mirrors the fix applied towait_for_text/wait_for_content_changein 0.1.0a2 (commit 0a408fe) — the channel pair was missed.The narrow
subprocess.TimeoutExpired/CalledProcessErrorexcept blocks plushandle_tool_errors_async'sException-only catch meanasyncio.CancelledErrorpropagates naturally — locked in as a regression test.Test plan
uv run ruff check . --fix --show-fixesuv run ruff format .uv run mypyuv run py.test --reruns 0 -vvv(366 passed)just build-docsNew regression tests in
tests/test_wait_for_tools.py:test_channel_tools_are_coroutines— pinsasync defsurface.test_wait_for_channel_does_not_block_event_loop— ticker-based discriminator: fires a 10 ms-tick coroutine in parallel with a 500 ms unsignalled wait; asserts ≥ 20 ticks (≈ 0 ticks pre-fix).test_wait_for_channel_propagates_cancellation— cancels an in-flight wait withtask.cancel(), assertsCancelledErrorbubbles (not wrapped asToolError).Existing sync-call tests converted to
asyncio.run(...)since the function signatures changed. One caller intests/test_pane_tools.pysimilarly updated.Companion PRs
Part of a batch of post-0.1.0a2 smoke-test fixes. Each PR is independent and can land in any order:
asyncio.to_thread#21 — event-loop block in channel waits (this PR, closes wait_for_channel and signal_channel block the FastMCP event loop (sync subprocess.run) #18)is_callersocket-blind false positive (closes is_caller annotation gives false positives across tmux sockets #19)libtmux_test*sockets (closes list_servers exposes dozens of leaked libtmux_test* tmux daemons after test runs #20)Closes #18