fix: stop quit() from cancelling its own owning task through a gathered tool call - #300
Merged
Merged
Conversation
…ed tool call The quit tool runs as a child task of ToolExecutor.execute_async's own asyncio.gather() call, so asyncio.current_task() inside it is that child task, never the _run_message task registered in controller.active_tasks. quit()'s self-skip check compares against the wrong task, cancels its own ancestor, and awaits a task that is itself awaiting the gather() this code is a child of. CPython's cancellation propagation then recurses without bound. Track the owning task in a contextvar set once at the top of _run_message; child tasks copy the active context, so it resolves correctly no matter how many awaits deep quit() is invoked from. Fixes bubbuild#186
Contributor
There was a problem hiding this comment.
The ContextVar approach looks like a good minimal fix: the task to exclude is the owning turn, which may differ from asyncio.current_task() inside a gathered tool call.
A few suggestions:
Scope _owning_task with a token and try/finally: token = _owning_task.set(asyncio.current_task()), then _owning_task.reset(token) in finally. This is not necessary for the current fresh-task entrypoint, but keeps the context scoped correctly if _run_message is ever directly awaited or nested.
_run_message set the contextvar but never cleared it, so it would keep leaking the finished turn's task into a nested or re-entrant call instead of falling back to asyncio.current_task().
Contributor
Author
|
Good catch, pushed the token/finally scoping so the contextvar clears itself after each turn instead of persisting past it. |
frostming
approved these changes
Sep 11, 2026
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.
The "quit" tool's coroutine runs as a child task of
ToolExecutor.execute_async's ownasyncio.gather()(bub/tools.py), soasyncio.current_task()inside it is that child task, not the_run_messagetask registered incontroller.active_tasks.quit()'s self-skip check compares against the wrong task, cancels its own ancestor, and awaits it, but that ancestor is itself awaiting the same gather() this code is a child of, so CPython recurses cancelling it without bound.Fixed by tracking the owning task in a contextvar set once at the top of
_run_message. Child tasks, including the onesexecute_async's gather spawns, inherit a copy of that context, soquit()resolves the real owning task regardless of how many awaits deep it's invoked from.Reproduced against the real
ChannelManager/ToolExecutorclasses: same RecursionError and wedged event loop on main (990 recursive_GatheringFuture.cancel()frames), clean exit with 0 tasks cancelled on this branch, both confirmed on Python 3.12 and 3.14. The regression test runs the scenario in a subprocess with an OS-level timeout, since the hang also defeatsasyncio.wait_for's own cancellation and can't be bounded from inside the event loop itself. Full suite, ruff, and mypy pass (mypy has one pre-existing unrelated error in settings.py that's on main too).