Fix superseded async references escaping cancellation - #1175
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1175 +/- ##
==========================================
- Coverage 86.75% 86.74% -0.02%
==========================================
Files 9 9
Lines 5336 5362 +26
==========================================
+ Hits 4629 4651 +22
- Misses 707 711 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
hoxbro
reviewed
Aug 25, 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.
Problem
When an asynchronous reference is re-resolved because one of its dependencies changed, param
is supposed to cancel the resolution it supersedes so only the newest one writes a value. In
practice only every other superseded resolution was cancelled, so stale coroutines ran to
completion and stale async generators kept streaming into the parameter.
Four resolutions are scheduled (for
x= 0, 1, 2, 3) and three of them are obsolete beforethey finish. Only
0and2were cancelled;1and3both completed and both assigned top.value. Which value survived came down to which task happened to finish last, so a slowearly resolution could clobber the result of a fast later one.
The same thing with an async generator is worse, because a superseded generator is not a
one-off stale write but an unbounded second writer: two generators interleave their emissions
into the same parameter for as long as they both keep yielding, and nothing ever stops the
orphan.
Root cause
Parameters._async_reftracks the resolution that owns a reference in_param__private.async_refs[pname], and each new resolution cancels whatever it finds there:The superseding task cancels the previous one but never takes ownership of the reference, and
the
finallyblock of the task it just cancelled removes the entry it still owns. Theregistry therefore ends up empty rather than pointing at the live task:
finallyclears it)Tasks 1 and 3 are never anybody's
running_task, so nothing cancels them. The registryalternates between "owned" and "empty" and half the resolutions slip through.
This did not show up in the existing coverage because
test_async_generator_ref_cancelledand
test_generator_ref_cancelledsupersede a ref by reassigning it, and reassignment ishandled elsewhere:
Parameter.__set__pops and cancels the running task synchronously when aref is replaced, so the registry is already empty when the new task starts and the new task
registers itself. Only the dependency-change path goes through the broken branch, and only
from the second supersession onwards.
Changes
Parameters._async_refnow takes ownership before cancelling, so the live resolution isalways the registered one:
The
finallycleanup already only clears the entry when it still points at the current task,which is now exactly right: a cancelled task finds its successor registered and leaves the
entry alone, and only the last surviving resolution clears it. It is also no longer possible
for the registry to hold a task that has already finished, which matters for the two other
places that cancel a reference (
Parameter.__set__andParameters._update_ref) since bothcancel whatever the registry holds.
That cleanup is additionally guarded with a
pname in async_refscheck. Both the old and newownership code leave the reference unregistered when
_async_refruns outside a task(
asyncio.current_task()returnsNone), and in that case the oldget(pname) is current_tasktest comparedNonetoNone, passed, and raisedKeyErroron thedel.Tests
Two tests in
tests/testrefs.py, both failing before this change:test_async_ref_cancelled_on_dependency_changeasserts that of four scheduledresolutions only the last completes. Before:
assert [1, 3] == [3].test_async_generator_ref_cancelled_on_dependency_changeasserts that after twosupersessions only the newest generator still emits. Before:
assert {1, 2} == {2}.The existing reassignment-based cancellation tests still pass, as does the rest of the suite
(1551 passed, 4 skipped, 2 xfailed).
AI Disclosure
Fixed with the aid of Claude Opus 5.