Skn0tt ยท GitHub

Bug report

Bug description:

On Python 3.14, asyncio.wait(..., return_when=FIRST_COMPLETED) permanently retains every
completed waiting task when one of the awaited futures is long-lived (never resolves). Each call
registers the waiting task on the long-lived future's await-graph (_asyncio_awaited_by) but never
discards it once the wait returns, so the task is pinned until that future finally resolves โ€” which,
for a "signal" future used as a cancellation/close marker, can mean the lifetime of the process.

This is the same omission already fixed for asyncio.staggered.staggered_race in
gh-129195 (add to awaited_by โ†’ must discard), but asyncio.wait still has it.

import asyncio, gc
async def main():
    loop = asyncio.get_running_loop()
    immortal = loop.create_future()  # never resolves (e.g. a connection-close signal)
    def done_tasks():
        gc.collect()
        return sum(1 for o in gc.get_objects() if isinstance(o, asyncio.Task) and o.done())
    async def race_once():
        await asyncio.wait(
            {asyncio.ensure_future(asyncio.sleep(0)), immortal},
            return_when=asyncio.FIRST_COMPLETED,
        )
    base = done_tasks()
    for _ in range(1000):
        await asyncio.create_task(race_once())
    print("retained done tasks:", done_tasks() - base)
    immortal.cancel()
asyncio.run(main())
3.13: retained done tasks: 1
3.14: retained done tasks: 1000   # grows linearly with calls; freed only when `immortal` resolves

Related

CPython versions tested on:

CPython main branch, 3.14

Operating systems tested on:

macOS

Linked PRs

Read the original on github.com โ†—