Skip to content

Commit 645f3a5

Browse files
committed
gh-160347: Guard async generator awaitables against concurrent entry
Protect send(), throw(), and close() on ASend and AThrow awaitables so concurrent or reentrant callers cannot corrupt the active operation. Cover same-thread reentry, direct await, and coordinated thread races.
1 parent 862d5e5 commit 645f3a5

4 files changed

Lines changed: 363 additions & 101 deletions

File tree

‎Lib/test/test_asyncgen.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,115 @@ async def agenfn():
648648
gen.send(None)
649649
self.assertEqual(cm.exception.value, 2)
650650

651+
def test_async_gen_awaitable_reentrant(self):
652+
# Rejecting reentry must not close the awaitable or release its
653+
# claim on the generator while the original call is still running.
654+
class MyError(Exception):
655+
pass
656+
657+
cases = (
658+
('asend', (None,), StopAsyncIteration),
659+
('athrow', (MyError,), MyError),
660+
('aclose', (), StopIteration),
661+
)
662+
for kind, args, completion in cases:
663+
for method in ('send', 'throw', 'close'):
664+
with self.subTest(kind=kind, method=method):
665+
errors = []
666+
running = []
667+
cleanups = []
668+
669+
def drive():
670+
if method == 'send':
671+
return aw.send(None)
672+
if method == 'throw':
673+
return aw.throw(MyError)
674+
return aw.close()
675+
676+
async def agen():
677+
try:
678+
yield 1
679+
finally:
680+
try:
681+
drive()
682+
except ValueError as exc:
683+
errors.append(str(exc))
684+
running.append(ag.ag_running)
685+
if method != 'close':
686+
await awaitable()
687+
cleanups.append(1)
688+
689+
ag = agen()
690+
self.addCleanup(ag.aclose().close)
691+
with self.assertRaises(StopIteration):
692+
ag.__anext__().send(None)
693+
aw = getattr(ag, kind)(*args)
694+
result = drive()
695+
696+
self.assertEqual(
697+
errors, ['async generator already executing'])
698+
self.assertEqual(running, [True])
699+
if method == 'close':
700+
self.assertIsNone(result)
701+
else:
702+
self.assertEqual(result, ('result',))
703+
self.assertTrue(ag.ag_running)
704+
error = MyError if method == 'throw' else completion
705+
with self.assertRaises(error):
706+
aw.send(None)
707+
self.assertEqual(cleanups, [1])
708+
self.assertIsNone(ag.ag_frame)
709+
with self.assertRaisesRegex(
710+
RuntimeError, 'cannot reuse already awaited'
711+
):
712+
aw.send(None)
713+
714+
def test_async_gen_awaitable_reentrant_await(self):
715+
# Awaiting asend() uses its am_send slot rather than its send method.
716+
class MyError(Exception):
717+
pass
718+
719+
cases = (
720+
('asend', (None,), StopAsyncIteration),
721+
('athrow', (MyError,), MyError),
722+
('aclose', (), StopIteration),
723+
)
724+
for kind, args, completion in cases:
725+
with self.subTest(kind=kind):
726+
cleanups = []
727+
728+
async def drive():
729+
await aw
730+
731+
async def agen():
732+
try:
733+
yield 1
734+
finally:
735+
reentrant = drive()
736+
with self.assertRaisesRegex(
737+
ValueError, 'async generator already executing'
738+
):
739+
reentrant.send(None)
740+
await awaitable()
741+
cleanups.append(1)
742+
743+
ag = agen()
744+
self.addCleanup(ag.aclose().close)
745+
with self.assertRaises(StopIteration):
746+
ag.__anext__().send(None)
747+
aw = getattr(ag, kind)(*args)
748+
owner = drive()
749+
try:
750+
self.assertEqual(owner.send(None), ('result',))
751+
self.assertTrue(ag.ag_running)
752+
with self.assertRaises(completion):
753+
owner.send(None)
754+
self.assertEqual(cleanups, [1])
755+
self.assertIsNone(ag.ag_frame)
756+
finally:
757+
with contextlib.suppress(RuntimeError):
758+
owner.close()
759+
651760

652761
class AsyncGenAsyncioTest(unittest.TestCase):
653762

‎Lib/test/test_free_threading/test_async_generators.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import sys
2+
import threading
23
import unittest
34

5+
from test import support
46
from test.support import threading_helper
57

68
threading_helper.requires_working_threading(module=True)
@@ -173,6 +175,80 @@ def worker():
173175
# The awaitable is closed after the operation completed.
174176
self.assertRaises(RuntimeError, aw.send, None)
175177

178+
def test_concurrent_shared_awaitable_resume(self):
179+
class MyError(Exception):
180+
pass
181+
182+
class YieldOnce:
183+
def __await__(self):
184+
yield 'suspended'
185+
186+
cases = (
187+
('asend', (None,), StopAsyncIteration),
188+
('athrow', (MyError,), MyError),
189+
('aclose', (), StopIteration),
190+
)
191+
for kind, args, completion in cases:
192+
for method in ('send', 'throw', 'close'):
193+
with self.subTest(kind=kind, method=method):
194+
entered = threading.Event()
195+
release = threading.Event()
196+
cleanups = []
197+
198+
async def agen():
199+
try:
200+
yield 1
201+
finally:
202+
entered.set()
203+
self.assertTrue(
204+
release.wait(support.SHORT_TIMEOUT))
205+
await YieldOnce()
206+
cleanups.append(1)
207+
208+
ag = agen()
209+
self.addCleanup(ag.aclose().close)
210+
with self.assertRaises(StopIteration):
211+
ag.__anext__().send(None)
212+
aw = getattr(ag, kind)(*args)
213+
214+
def owner():
215+
self.assertEqual(aw.send(None), 'suspended')
216+
217+
def contender():
218+
try:
219+
self.assertTrue(
220+
entered.wait(support.SHORT_TIMEOUT))
221+
with self.assertRaisesRegex(
222+
ValueError, 'async generator already executing'
223+
):
224+
if method == 'send':
225+
aw.send(None)
226+
elif method == 'throw':
227+
aw.throw(MyError)
228+
else:
229+
aw.close()
230+
self.assertTrue(ag.ag_running)
231+
finally:
232+
release.set()
233+
234+
threading_helper.run_concurrently([owner, contender])
235+
# Rejecting the contender must leave the owner's pending
236+
# await intact, including its claim on the generator.
237+
self.assertTrue(ag.ag_running)
238+
with self.assertRaisesRegex(
239+
RuntimeError,
240+
'asynchronous generator is already running',
241+
):
242+
ag.__anext__().send(None)
243+
with self.assertRaises(completion):
244+
aw.send(None)
245+
self.assertEqual(cleanups, [1])
246+
self.assertIsNone(ag.ag_frame)
247+
with self.assertRaisesRegex(
248+
RuntimeError, 'cannot reuse already awaited'
249+
):
250+
aw.send(None)
251+
176252
def test_concurrent_anext_athrow(self):
177253
async def agen():
178254
while True:
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Prevent concurrent or reentrant calls to the same asynchronous generator
2+
awaitable from corrupting its state. This fixes races in :meth:`~agen.aclose`,
3+
:meth:`~agen.athrow`, and :meth:`~agen.asend` that could delay cleanup or make an
4+
active awaitable unusable.

0 commit comments

Comments
 (0)