Repository navigation
fix(core): keep a cancellation that comes as a timed call ends - #102
Conversation
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.
eebdfe5 to
4ac8342
Compare
gopaljigaur
left a comment
There was a problem hiding this comment.
No findings.
Reviewed 4ac8342.
Checked
-
uv run pytestpasses on the default Python and on 3.10.19 (419 passed each); pre-commit anduv run mypy cyclopspass; CI is green on 3.10, 3.11 and 3.12. -
The shim:
cyclops/utils/compat.pyis the right place.cyclops.utilsimports only its logger, socyclops.mcpgets the helper without importingcyclops.core.asyncio_timeoutkeeps the name #94 introduced.completion.pyimports it, andwait_foris gone fromcyclops/. -
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, sincewait_foris fixed there; CI's 3.10 and 3.11 jobs are the ones that guard them. -
The
call_soonplussleep(0)setup is sound. It relies on the FIFO order ofcall_soon, which asyncio guarantees. Without the fix, the cancel runs before thewait_fortask finishes, andwait_forthen sees that task done and returns its result. With the fix, the cancel is thrown into the call atsleep(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
TimeoutErrorgives "t failed: ..."; - a sync tool past the deadline (run through
to_thread) gives "t timed out after 0.05 seconds"; - a tool raising
CancelledErrorraises it; - middleware raising
TimeoutErrorinbefore_toolorafter_toolends the run with that error, not a timeout result.
Both messages and the
TimeoutErrorpassed to middleware and hooks are unchanged; the existing tests at lines 230 to 278 cover them. - a sync tool raising its own
-
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 queuegetre-raises the cancel. -
MCP: on both versions, a slow handshake and a slow
list_toolsunderconnect_timeoutraise the same exception as on main:asyncio.TimeoutErroron 3.10, the builtinTimeoutErrorfrom 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.
Before Python 3.12,
asyncio.wait_forreturns the result of a call that finishes as its caller is cancelled, and the caller never seesCancelledError. #94 fixed this for the per-chunk stream timeout; this PR fixes the other three sites.What changed
cyclops/utils/compat.pyholdsasyncio_timeout:asyncio.timeoutfrom 3.11,async_timeout.timeoutbefore.completion.pyimports it from there instead of defining it. No existing core module fits, sincecyclops.mcpdoes not importcyclops.core.tool_execution.py: the tool call runs underasyncio_timeout. The tool's own errors, its ownTimeoutErrorincluded, are caught inside the timeout block, so aTimeoutErrorthat reaches the outer handler is the deadline. This replaces the elapsed-time check.Timeout.expiredis a method onasyncio.Timeoutand a property onasync_timeout.Timeout, so it was not used. Both error messages are unchanged.mcp/client.py:list_toolson attach runs underasyncio_timeout(connect_timeout).mcp/connection.py: the session handshake waits on its future underasyncio_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_attachtest_mcp_client.py::TestAttachDetach::test_cancel_as_the_tool_list_ends_cancels_attachOn Python 3.10 with the
cyclops/changes reverted, all three fail:With the fix, the full suite passes on 3.10 and 3.14 (419 passed), and
pre-commit run --all-filesandmypy cyclopspass.Closes #100