Skip to content

fix(core): keep a cancellation that comes as a timed call ends - #102

Merged
gopaljigaur merged 1 commit into
mainfrom
fix/wait-for-cancel
Oct 15, 2026
Merged

gopaljigaur merged 1 commit into
mainfrom
fix/wait-for-cancel

Conversation

@gopaljigaur

@gopaljigaur gopaljigaur commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Before Python 3.12, asyncio.wait_for returns the result of a call that finishes as its caller is cancelled, and the caller never sees CancelledError. #94 fixed this for the per-chunk stream timeout; this PR fixes the other three sites.

What changed

  • cyclops/utils/compat.py holds asyncio_timeout: asyncio.timeout from 3.11, async_timeout.timeout before. completion.py imports it from there instead of defining it. No existing core module fits, since cyclops.mcp does not import cyclops.core.
  • tool_execution.py: the tool call runs under asyncio_timeout. The tool's own errors, its own TimeoutError included, are caught inside the timeout block, so a TimeoutError that reaches the outer handler is the deadline. This replaces the elapsed-time check. Timeout.expired is a method on asyncio.Timeout and a property on async_timeout.Timeout, so it was not used. Both error messages are unchanged.
  • mcp/client.py: list_tools on attach runs under asyncio_timeout(connect_timeout).
  • mcp/connection.py: the session handshake waits on its future under asyncio_timeout(connect_timeout).

Tests

One test per site cancels the caller as the awaited call completes and checks that the cancellation reaches it:

  • test_tool_execution.py::test_a_cancel_as_the_tool_returns_stops_the_run (naive tool mode, where the call runs in the run's own task)
  • test_mcp_client.py::TestAttachDetach::test_cancel_as_the_handshake_ends_cancels_attach
  • test_mcp_client.py::TestAttachDetach::test_cancel_as_the_tool_list_ends_cancels_attach

On Python 3.10 with the cyclops/ changes reverted, all three fail:

E       AssertionError: assert 'completed' == 'cancelled'
E       Failed: DID NOT RAISE <class 'asyncio.exceptions.CancelledError'>
E       Failed: DID NOT RAISE <class 'asyncio.exceptions.CancelledError'>
FAILED tests/test_tool_execution.py::test_a_cancel_as_the_tool_returns_stops_the_run
FAILED tests/test_mcp_client.py::TestAttachDetach::test_cancel_as_the_handshake_ends_cancels_attach
FAILED tests/test_mcp_client.py::TestAttachDetach::test_cancel_as_the_tool_list_ends_cancels_attach
3 failed, 68 deselected

With the fix, the full suite passes on 3.10 and 3.14 (419 passed), and pre-commit run --all-files and mypy cyclops pass.

Closes #100

Before 3.12, asyncio.wait_for returns the result of a call that finishes
as its caller is cancelled, and the cancellation is lost. The tool call,
the MCP handshake and the MCP tool listing now use the version-gated
timeout the stream already used, moved to cyclops.utils.compat.

A tool's own TimeoutError is now told apart from the deadline by where it
is raised instead of by the elapsed time.

@gopaljigaur gopaljigaur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No findings.

Reviewed 4ac8342.

Checked

  • uv run pytest passes on the default Python and on 3.10.19 (419 passed each); pre-commit and uv run mypy cyclops pass; CI is green on 3.10, 3.11 and 3.12.

  • The shim: cyclops/utils/compat.py is the right place. cyclops.utils imports only its logger, so cyclops.mcp gets the helper without importing cyclops.core. asyncio_timeout keeps the name #94 introduced. completion.py imports it, and wait_for is gone from cyclops/.

  • The three tests: with the cyclops/ changes reverted on 3.10.19, all three fail with the messages in the PR body, 20 runs out of 20. With the fix they pass 20 of 20 on 3.10 and on the default Python. On 3.12+ they also pass without the fix, since wait_for is fixed there; CI's 3.10 and 3.11 jobs are the ones that guard them.

  • The call_soon plus sleep(0) setup is sound. It relies on the FIFO order of call_soon, which asyncio guarantees. Without the fix, the cancel runs before the wait_for task finishes, and wait_for then sees that task done and returns its result. With the fix, the cancel is thrown into the call at sleep(0). Either way the order is fixed, not a race.

  • Tool timeouts: a probe compared main and this PR on 3.10 and the default Python, in native and naive mode. The output was identical for each case:

    • a sync tool raising its own TimeoutError gives "t failed: ...";
    • a sync tool past the deadline (run through to_thread) gives "t timed out after 0.05 seconds";
    • a tool raising CancelledError raises it;
    • middleware raising TimeoutError in before_tool or after_tool ends the run with that error, not a timeout result.

    Both messages and the TimeoutError passed to middleware and hooks are unchanged; the existing tests at lines 230 to 278 cover them.

  • Native mode: never affected. On 3.10 with the fix reverted, a cancel scheduled as a native call returns stops the run with 1 or 3 calls and 0 to 3 extra loop turns. The run waits on as_completed, whose queue get re-raises the cancel.

  • MCP: on both versions, a slow handshake and a slow list_tools under connect_timeout raise the same exception as on main: asyncio.TimeoutError on 3.10, the builtin TimeoutError from 3.11, both with an empty message. No server stays attached and no session stays live.

  • Confidentiality and prose: nothing in the title, body, commit, added lines or issue #100, and no dashes used as punctuation.

@gopaljigaur
gopaljigaur merged commit 6188e0d into main Oct 15, 2026
9 checks passed
@gopaljigaur
gopaljigaur deleted the fix/wait-for-cancel branch October 15, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(core): cancellation can be lost under asyncio.wait_for before 3.12

1 participant