fix: do not swallow concurrent native Task.cancel on CancelScope exit (#1214) - #1251
Open
MohammedAnasNathani wants to merge 2 commits into
Open
Conversation
When a cancel scope delivers cancellation to its host task (e.g. Happy Eyeballs winning and cancelling the task group) and a native Task.cancel() lands in the same cycle, __exit__ previously uncancelled only the AnyIO-tagged cancels and then swallowed the AnyIO CancelledError. That left cancelling() > 0 with no exception propagating — the host returned normally and callers waiting on the task hung. Snapshot Task.cancelling() at scope enter and, after undoing this scope's pending uncancels, refuse to swallow if the cancel count is still above that baseline. Pre-existing cancels from before enter are unaffected. Fixes agronholm#1214
Owner
|
The test already passes on |
agronholm
reviewed
Aug 19, 2026
Comment on lines
+1692
to
+1734
| async def test_child_scope_cancel_does_not_swallow_native_host_cancel( | ||
| self, | ||
| ) -> None: | ||
| """ | ||
| When a child cancels its task group (Happy Eyeballs style) and a native | ||
| ``Task.cancel()`` lands on the host in the same cycle, the host must still | ||
| observe ``CancelledError`` — not return normally with ``cancelling() > 0``. | ||
|
|
||
| Regression test for #1214. | ||
| """ | ||
| attempt_started = asyncio.Event() | ||
| connection_won = asyncio.Event() | ||
|
|
||
| async def operation() -> None: | ||
| async with create_task_group() as task_group: | ||
|
|
||
| async def connect_attempt() -> None: | ||
| attempt_started.set() | ||
| await connection_won.wait() | ||
| # Same pattern as connect_tcp(): winner cancels the group. | ||
| task_group.cancel_scope.cancel() | ||
|
|
||
| task_group.start_soon(connect_attempt) | ||
| await sleep_forever() | ||
|
|
||
| task = asyncio.create_task(operation()) | ||
| await attempt_started.wait() | ||
|
|
||
| external_cancel_accepted: list[bool] = [] | ||
|
|
||
| def externally_cancel() -> None: | ||
| external_cancel_accepted.append(task.cancel("external cancellation")) | ||
|
|
||
| connection_won.set() | ||
| asyncio.get_running_loop().call_soon(externally_cancel) | ||
|
|
||
| with pytest.raises(asyncio.CancelledError): | ||
| await task | ||
|
|
||
| assert external_cancel_accepted == [True] | ||
| assert task.cancelled() | ||
| assert task.cancelling() >= 1 | ||
|
|
Owner
There was a problem hiding this comment.
This test is a massively overcomplicated. The bug can be reproduced with 1/5 of the code here.
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.
Changes
On the asyncio backend, when a
CancelScopecancels its host task and a concurrent nativeTask.cancel()lands in the same event-loop cycle,__exit__could swallow the AnyIO-taggedCancelledErrorafter undoing only the scope's own cancel counts. The host then returned normally withtask.cancelled() is Falseandtask.cancelling() > 0, so callers awaiting the task never saw cancellation.This is the Happy Eyeballs /
connect_tcppattern from production: a child cancels the task group while an unrelated caller cancels the host task.Fix
Task.cancelling()when the scope is entered.uncancel()for each cancel this scope delivered to the host, do not swallow ifcancelling()is still above that baseline — a native cancel that arrived while the scope was active must still propagate.test_cancel_message_replacedand related uncancel semantics).Checklist
TestUncancel.test_child_scope_cancel_does_not_swallow_native_host_cancel, mirrors the CancelScope can swallow a concurrent native asyncio.Task.cancel() when the scope is also cancelled #1214 repro)docs/versionhistory.rstTestUncancelsuite green; fulltests/test_taskgroups.py: 539 passedRelated
Fixes #1214
Verification