Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

**UNRELEASED**

- Fixed a child task failing without cancelling its task group's scope on asyncio
when an outer cancel scope was already cancelled
(`#787 <https://github.com/agronholm/anyio/issues/787>`_)
- Dropped support for Python 3.9
- Fixed ``anyio.Path`` not being compatible with Python 3.15 due to the removal of
``pathlib.Path.is_reserved()`` and the addition of ``pathlib.Path.__vfspath__()``
Expand Down
3 changes: 1 addition & 2 deletions src/anyio/_backends/_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,8 +835,7 @@ def task_done(_task: asyncio.Task) -> None:
if not isinstance(exc, CancelledError):
self._exceptions.append(exc)

if not self.cancel_scope._effectively_cancelled:
self.cancel_scope.cancel()
self.cancel_scope.cancel()
else:
task_status_future.set_exception(exc)
elif task_status_future is not None and not task_status_future.done():
Expand Down
29 changes: 29 additions & 0 deletions tests/test_taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,35 @@ async def exit_scope(scope: CancelScope) -> None:
)


@pytest.mark.parametrize("anyio_backend", asyncio_params)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should run this on the Trio backend too:

Suggested change
@pytest.mark.parametrize("anyio_backend", asyncio_params)

async def test_child_task_cancels_scope_when_parent_scope_cancelled() -> None:
"""
Regression test for #787 (weak case).

When a child task exits with an unhandled exception, the task group's
cancel scope must be cancelled even if an outer scope is already cancelled.
Previously the ``_effectively_cancelled`` guard in ``task_done`` prevented
this, leaving the host task unaware that a child had failed.
"""

async def taskfunc() -> None:
raise Exception("child task failed")

with pytest.raises(BaseExceptionGroup) as exc:
with CancelScope() as outer_scope:
async with create_task_group() as tg:
outer_scope.cancel()
tg.start_soon(taskfunc)
with CancelScope(shield=True):
await wait_all_tasks_blocked()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we use the version of this test that uses an event ("taskfunc_exited") instead of the version that uses wait_all_tasks_blocked? It would give me a bit more confidence that this test is testing what it's supposed to. The asyncio version of wait_all_tasks_blocked is a bit less precise than the Trio version, and the point of these awaits is to control scheduling order.

I suppose that this point matters more for the strong case than the weak case. For the weak case, as long as we wait long enough, the test is fine and will not pass when it should fail. For the strong case, the test needs to wait until a particular event loop cycle and not longer than that.

@gschaffner gschaffner Mar 20, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess I am foreseeing that we may remove the sleep from this test in the future in order to change it from being the weak test into the strong test. Per #787: the weak case only tests for bug (1) in #787, but the strong case tests for bug (2) also. If/when we fix bug (2), I don't see a reason to retain a separate test that still has the sleep (the weak form), because bug (1) will clearly be completely covered by the strong test already, making the weak test redundant (a waste of 0.1 s every run).

In other words: if we remove task_done to fix bug (2) (i.e. we change task_done from a callback to a finally), then the line

                    # Wait at least one more scheduling round to ensure that taskfunc's
                    # done callback (task_done) on asyncio has finished. This is
                    # workaround for the delay that is currently present between a task
                    # failing and cancelling its task group on asyncio (#787, bug (2)).
                    await sleep(0.1)

in the weak test would not make sense anymore, because bug (2) and the callback task_done would no longer exist :)

await sleep(0.1)

@gschaffner gschaffner Mar 20, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This sleep is the difference between the weak and the strong case. The reason that the sleep is here is just to work around bug (2) in #787. It might be good to have a comment here about how this test intentionally waits an extra event loop cycle for the task_done callback to run on the asyncio backend.

tg.cancel_scope.shield = True
assert tg.cancel_scope.cancel_called

assert len(exc.value.exceptions) == 1
assert str(exc.value.exceptions[0]) == "child task failed"


def test_unhandled_exception_group(caplog: pytest.LogCaptureFixture) -> None:
def crash() -> NoReturn:
raise KeyboardInterrupt
Expand Down