diff --git a/Lib/asyncio/tasks.py b/Lib/asyncio/tasks.py index 9d20930dc300c67..8fd87faab6410cc 100644 --- a/Lib/asyncio/tasks.py +++ b/Lib/asyncio/tasks.py @@ -994,6 +994,10 @@ def _inner_done_callback(inner): def _outer_done_callback(outer): if not inner.done(): inner.remove_done_callback(_inner_done_callback) + # gh-155854: waiter is gone but inner lives on, clean up here + if cur_task is not None: + inner.remove_done_callback(_clear_awaited_by_callback) + futures.future_discard_from_awaited_by(inner, cur_task) # Keep only one callback to log on cancel inner.remove_done_callback(_log_on_exception) inner.add_done_callback(_log_on_exception) diff --git a/Lib/test/test_asyncio/test_tasks.py b/Lib/test/test_asyncio/test_tasks.py index ad9b09857f8fd2b..ff15a782471e53a 100644 --- a/Lib/test/test_asyncio/test_tasks.py +++ b/Lib/test/test_asyncio/test_tasks.py @@ -2150,6 +2150,19 @@ def test_shield_duplicate_log_once(self): test_utils.run_briefly(self.loop) mock_handler.assert_called_once() + def test_shield_discards_awaited_by_on_outer_cancel(self): + # gh-155854: a cancelled waiter must not stay in inner's await-graph + async def coro(): + inner = self.new_future(self.loop) + for _ in range(3): + asyncio.shield(inner).cancel() + await asyncio.sleep(0) + self.assertFalse(inner._asyncio_awaited_by) + self.assertEqual(1, len(inner._callbacks)) + inner.cancel() + + self.loop.run_until_complete(self.new_task(self.loop, coro())) + def test_shield_shortcut(self): fut = self.new_future(self.loop) fut.set_result(42) diff --git a/Misc/NEWS.d/next/Library/2026-08-15-18-30-39.gh-issue-155854.jQYOhY.rst b/Misc/NEWS.d/next/Library/2026-08-15-18-30-39.gh-issue-155854.jQYOhY.rst new file mode 100644 index 000000000000000..1b3a720859a60d3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-15-18-30-39.gh-issue-155854.jQYOhY.rst @@ -0,0 +1,2 @@ +Fix :func:`asyncio.shield` keeping cancelled waiters alive via +``awaited_by``.