Skip to content

fix(wait_for_tools): unblock event loop via asyncio.to_thread#21

Merged
tony merged 1 commit intomainfrom
fix/wait-for-channel-async
Apr 19, 2026
Merged

fix(wait_for_tools): unblock event loop via asyncio.to_thread#21
tony merged 1 commit intomainfrom
fix/wait-for-channel-async

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented Apr 19, 2026

Summary

  • Convert wait_for_channel and signal_channel from sync defasync def.
  • Wrap subprocess.run(...) in await asyncio.to_thread(...) so the FastMCP event loop is not starved for the duration of the wait.
  • Swap @handle_tool_errors@handle_tool_errors_async.

Before the fix, any MCP client whose stdio keepalive was shorter than the wait_for_channel timeout could disconnect mid-wait, and no other tool call could be serviced while a wait was pending. This mirrors the fix applied to wait_for_text / wait_for_content_change in 0.1.0a2 (commit 0a408fe) — the channel pair was missed.

The narrow subprocess.TimeoutExpired / CalledProcessError except blocks plus handle_tool_errors_async's Exception-only catch mean asyncio.CancelledError propagates naturally — locked in as a regression test.

Test plan

  • uv run ruff check . --fix --show-fixes
  • uv run ruff format .
  • uv run mypy
  • uv run py.test --reruns 0 -vvv (366 passed)
  • just build-docs

New regression tests in tests/test_wait_for_tools.py:

  • test_channel_tools_are_coroutines — pins async def surface.
  • 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 with task.cancel(), asserts CancelledError bubbles (not wrapped as ToolError).

Existing sync-call tests converted to asyncio.run(...) since the function signatures changed. One caller in tests/test_pane_tools.py similarly 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:

Closes #18

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.67%. Comparing base (22323a2) to head (9546080).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
Copy link
Copy Markdown
Member Author

tony commented Apr 19, 2026

Code review

No 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
@tony tony force-pushed the fix/wait-for-channel-async branch from 5b919b6 to 9546080 Compare April 19, 2026 20:49
@tony tony changed the title fix(wait_for_tools): unblock event loop via asyncio.to_thread fix(wait_for_tools): unblock event loop via asyncio.to_thread Apr 19, 2026
@tony tony merged commit 017eaef into main Apr 19, 2026
9 checks passed
@tony tony deleted the fix/wait-for-channel-async branch April 19, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants