Skip to content

Fixed child task not cancelling its task group scope when a parent scope is already cancelled - #1093

Draft
danieldorado-cmyk wants to merge 1 commit into
agronholm:masterfrom
danieldorado-cmyk:fix/787-child-task-cancel-scope
Draft

Fixed child task not cancelling its task group scope when a parent scope is already cancelled#1093
danieldorado-cmyk wants to merge 1 commit into
agronholm:masterfrom
danieldorado-cmyk:fix/787-child-task-cancel-scope

Conversation

@danieldorado-cmyk

Copy link
Copy Markdown

Changes

On the asyncio backend, the task_done callback in TaskGroup._spawn skipped
calling cancel() on the task group's cancel scope when _effectively_cancelled
returned True (i.e. any ancestor scope was already cancelled). This meant a child
task could finish with an unhandled exception without cancelling the group's scope,
leaving the host task unaware of the failure.
This is the minimal fix suggested by @gschaffner during the review of #774
(#774 (comment)), which was
deferred at the time.

Fixes #787.

Checklist

  • You've added tests (in tests/) which would fail without your patch
  • You've updated the documentation (in docs/), in case of behavior changes or new features
  • You've added a new changelog entry (in docs/versionhistory.rst).

@danieldorado-cmyk

danieldorado-cmyk commented Mar 18, 2026

Copy link
Copy Markdown
Author

We're hitting this in production:

Stack: anyio 4.12.1, CPython 3.12, LangGraph Platform (langgraph-api 0.7.4)

LangGraph uses anyio task groups internally for streaming.

When an outer scope gets cancelled (HTTP disconnect/timeout), child tasks that fail don't cancel the group's scope because _effectively_cancelled walks the parent chain and returns True.

The host task never learns the child failed, and cleanup hits:

RuntimeError: Attempted to exit cancel scope in a different task than it was entered in

The minimal fix from @gschaffner's review comment on #774, removing the _effectively_cancelled guard, resolves it. All 394 existing test_taskgroups.py tests pass.

@agronholm No, shielding is not required for this to manifest.

We're hitting it in production with no explicit shielding, just nested task groups where an outer scope gets cancelled (HTTP timeout/disconnect). See PR #1093 for the fix and details.

Langraph Workaround: We monkey-patch langgraph_api.stream.consume() to catch and suppress the RuntimeError (the agent has already completed by that point), plus a fallback that queries LangGraph's thread state when the SSE stream ends without a final event.

This one-line fix PR in task_done removes it at the source for the whole ecosystem without needing that messy workaround.

@gschaffner gschaffner left a comment

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.

Fixes #787.

This PR currently only fixes the weaker case of #787 (the stronger case is still bugged) so this probably shouldn't close #787.

Comment thread tests/test_taskgroups.py
)


@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)

Comment thread tests/test_taskgroups.py
tg.start_soon(taskfunc)
with CancelScope(shield=True):
await wait_all_tasks_blocked()
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.

@gschaffner

gschaffner commented Mar 20, 2026

Copy link
Copy Markdown
Collaborator

Disclosure about the review above: it is not an independent review—I was the author of most of this patch, so I may have some blind spots here.

Comment thread tests/test_taskgroups.py
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 :)

@gschaffner

gschaffner commented Mar 20, 2026

Copy link
Copy Markdown
Collaborator

@danieldorado-cmyk Is this PR blocking anything for you downstream? I ask because: what I would find simplest here is to

@danieldorado-cmyk

Copy link
Copy Markdown
Author

@gschaffner
I’ve concluded that our issue lies in LangGraph.

I initially worked on this PR assuming it was our problem, it may help in some cases, but it doesn’t address our specific problem.

Happy to mark this as draft if there are prior PRs to review. Thanks.

@danieldorado-cmyk
danieldorado-cmyk marked this pull request as draft March 20, 2026 08:03
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.

Child tasks don't cancel their group's scope correctly when they raise an exception on asyncio

2 participants