Repository navigation
Conversation
mtsolmn
force-pushed
the
fix-scope-async-generator-close
branch
from
October 31, 2026 02:20
c044acd to
ed70312
Compare
mtsolmn
marked this pull request as ready for review
October 31, 2026 02:55
mtsolmn
force-pushed
the
fix-scope-async-generator-close
branch
from
November 6, 2026 19:47
ed70312 to
c9eea82
Compare
If an async generator holds a scope open across a yield, e.g.
async def generate():
async with duet.new_scope() as scope:
async for x in duet.pmap_aiter(scope, func, items):
yield x
and the caller stops iterating early (break, or an exception) without
calling aclose(), the generator is closed synchronously when it is garbage
collected, throwing GeneratorExit into new_scope. new_scope then
interrupted the remaining tasks and awaited them, but a generator cannot
yield while handling GeneratorExit, so this failed with an unraisable
"RuntimeError: async generator ignored GeneratorExit". Worse, the abandoned
new_scope frame had already set main_task.interruptible = False and never
restored it, so the caller could no longer be interrupted, e.g. by a
timeout: a following duet.sleep() hung forever. And because the
interrupted tasks were still attached to the caller's task, their eventual
failure (ScopeExit) would interrupt the caller, which had moved on.
On GeneratorExit, interrupt the remaining tasks with ScopeExit without
awaiting them, and detach them from main_task with a new Task.detach()
method, so that their failure is dropped instead of reported to main_task.
Behaviour change: when the generator is closed with `await agen.aclose()`,
the tasks are now interrupted but finish in the background rather than
before aclose() returns.
Detached tasks are exactly the kind of tasks that no scope waits for, so
this is robust only together with the fix that re-registers ready tasks
when a root task raises in Scheduler.tick (google#103).
_DETACHED refuses interrupts, like a task that can't be interrupted, so
that once the scheduler re-posts a KeyboardInterrupt its main task can't
take (google#104), a Ctrl-C that reaches a detached task is not lost.
mtsolmn
force-pushed
the
fix-scope-async-generator-close
branch
from
November 10, 2026 08:59
c9eea82 to
5e721f1
Compare
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.
Problem
A common pattern is an async generator that holds a scope open across
yield:If the caller stops iterating early (
break, or an exception) without callingaclose(), Python closes the generator synchronously when it is garbage collected. That throwsGeneratorExitintonew_scope, which onmainhandles it like any other error: it interrupts the remaining tasks and then awaits them. Three things go wrong:GeneratorExit, so the close fails with an unraisableRuntimeError: async generator ignored GeneratorExit.new_scopeframe has already setmain_task.interruptible = Falseand never restores it, so the caller's task can no longer be interrupted, e.g. by a timeout: a laterawait duet.sleep(1)in the caller hangs forever.ScopeExit), they interrupt the caller, which has moved on.We hit this with Ctrl-C hangs in a downstream project.
Reproduction
Fix
new_scopegets a separateexcept GeneratorExit:branch. It interrupts the remaining tasks withScopeExitwithout awaiting them, then re-raises.impl.Task.detach(error)sets the task'smain_taskto a private sentinel,impl._DETACHED, whoseinterrupt()refuses every interrupt (returnsFalse), and interrupts the task. A detached task still runs until it handles the interrupt, andduet.runstill waits for it before returning, but its failure is no longer delivered to the caller.ScopeExitis aBaseException, anexcept Exception:in a detached task can't swallow it, including inside the task's own nested scopes (e.g.duet.sleep).We considered keeping the tasks attached and having the caller ignore the
ScopeExitthey echo back, but a pending echoed interrupt would makeTask.interruptdrop a real one (e.g. a timeout) arriving in the same tick.Behaviour change: with
await agen.aclose(), the generator's remaining tasks are now interrupted but finish in the background, instead of beforeaclose()returns.new_scopecan't tell an awaitedaclose()from a synchronous close (both throwGeneratorExitat the same point), and awaiting is only safe in the first case. We chose to always detach, so nothing hangs or raises unraisable errors. Detached tasks still have to finish beforeduet.runreturns, but they're no longer bounded by an enclosing scope. If you'd rather handle this differently (e.g. a way to tell an awaitedaclose()apart), happy to rework it._DETACHEDrefuses interrupts like a task that can't be interrupted. With Don't drop SIGINT that a task can't take right away #104, which re-posts aKeyboardInterruptthat a main task refuses, a Ctrl-C that reaches a detached task is then delivered to another task instead of being lost.Prerequisites: please merge #103 and #104 first. Detached tasks are exactly the kind of tasks no scope waits for, so without #103 a root-task failure in the same tick as a detached task becoming ready makes
duet.runhang; without #104 a Ctrl-C that reaches a detached task is lost.Alternative considered: installing async-generator finalizer hooks (
sys.set_asyncgen_hooks) induet.run, as asyncio does, so that a garbage-collected generator is closed by an awaitedaclose()in a new task instead of synchronously. That would letaclose()keep waiting for the scope's tasks, but it means installing and restoring per-thread global hooks in a re-entrantduet.run(and alongside asyncio in the same thread), and the generator's tasks would still finish outside the caller's scope. Detaching keeps the change local tonew_scope.Tests
TestScope.test_async_generator_with_scope_closed_when_caller_fails: apmap_aiterinside a generator, with the caller raising insideasync for.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")makes the unraisableRuntimeErrorfail the test. Fails onmain, passes here.TestScope.test_async_generator_with_scope_closed_early[del]: synchronous close viadel gen; afterwards the caller must still be interruptible and must not be interrupted by the closed scope's child. Fails onmain, passes here.TestScope.test_async_generator_with_scope_closed_early[aclose]: the same withawait gen.aclose(). It passes onmaintoo (thereaclose()awaits the child), but fails if the fix interrupts the tasks without detaching them, so it coversdetach.