Skip to content

Commit 61818b6

Browse files
gh-120321: fix thread safety of concurrently iterating over async generators (#155025)
1 parent af49df9 commit 61818b6

5 files changed

Lines changed: 530 additions & 126 deletions

File tree

Include/internal/pycore_interpframe_structs.h

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ struct _PyInterpreterFrame {
6666
PyObject *prefix##_qualname; \
6767
_PyErr_StackItem prefix##_exc_state; \
6868
PyObject *prefix##_origin_or_finalizer; \
69-
char prefix##_hooks_inited; \
70-
char prefix##_closed; \
71-
char prefix##_running_async; \
69+
int8_t prefix##_hooks_inited; \
70+
int8_t prefix##_closed; \
71+
int8_t prefix##_running_async; \
7272
/* The frame */ \
7373
int8_t prefix##_frame_state; \
7474
_PyInterpreterFrame prefix##_iframe; \

Lib/test/test_asyncgen.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,33 @@ async def agenfn():
619619
with self.assertRaisesRegex(RuntimeError, "coroutine ignored GeneratorExit"):
620620
gen.close()
621621

622+
def test_async_gen_athrow_send_non_none(self):
623+
# gh-120321: sending a non-None value to a just-started athrow()
624+
# awaitable must not claim the generator, so the generator stays
625+
# usable and the awaitable can still be awaited afterwards.
626+
class MyExc(Exception):
627+
pass
628+
629+
async def agenfn():
630+
try:
631+
yield 1
632+
except MyExc:
633+
yield 2
634+
635+
agen = agenfn()
636+
with self.assertRaises(StopIteration):
637+
agen.asend(None).send(None)
638+
639+
gen = agen.athrow(MyExc)
640+
with self.assertRaisesRegex(RuntimeError, "non-None value"):
641+
gen.send(42)
642+
self.assertFalse(agen.ag_running)
643+
644+
# The awaitable is still in its initial state and works normally.
645+
with self.assertRaises(StopIteration) as cm:
646+
gen.send(None)
647+
self.assertEqual(cm.exception.value, 2)
648+
622649

623650
class AsyncGenAsyncioTest(unittest.TestCase):
624651

@@ -1950,6 +1977,41 @@ class MyException(Exception):
19501977
):
19511978
nxt.throw(MyException)
19521979

1980+
def test_async_gen_send_same_athrow_coro_after_completion(self):
1981+
# gh-120321: an athrow() awaitable that needs more than one send()
1982+
# to complete must be closed on completion; sending to it again
1983+
# must raise instead of resuming the generator.
1984+
class YieldOnce:
1985+
def __await__(self):
1986+
yield
1987+
1988+
async def async_iterate():
1989+
try:
1990+
yield 1
1991+
except ValueError:
1992+
await YieldOnce()
1993+
yield 2
1994+
1995+
it = async_iterate()
1996+
with self.assertRaises(StopIteration):
1997+
it.__anext__().send(None)
1998+
1999+
nxt = it.athrow(ValueError)
2000+
# The exception handler suspends before the operation completes.
2001+
nxt.send(None)
2002+
with self.assertRaises(StopIteration) as cm:
2003+
nxt.send(None)
2004+
self.assertEqual(cm.exception.value, 2)
2005+
2006+
with self.assertRaisesRegex(
2007+
RuntimeError,
2008+
r"cannot reuse already awaited aclose\(\)/athrow\(\)"
2009+
):
2010+
nxt.send(None)
2011+
2012+
with self.assertRaises(StopIteration):
2013+
it.aclose().send(None)
2014+
19532015
def test_async_gen_aclose_twice_with_different_coros(self):
19542016
# Regression test for https://bugs.python.org/issue39606
19552017
async def async_iterate():
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import sys
2+
import unittest
3+
4+
from test.support import threading_helper
5+
6+
threading_helper.requires_working_threading(module=True)
7+
8+
9+
class TestFTAsyncGenerators(unittest.TestCase):
10+
NUM_THREADS = 4
11+
12+
def test_concurrent_anext(self):
13+
# Each yielded value must be delivered to exactly one thread.
14+
async def agen():
15+
for i in range(100):
16+
yield i
17+
18+
ag = agen()
19+
values = []
20+
21+
def drive():
22+
while True:
23+
try:
24+
ag.asend(None).send(None)
25+
except StopIteration as e:
26+
values.append(e.value)
27+
except StopAsyncIteration:
28+
break
29+
except RuntimeError:
30+
# Another thread is currently driving the generator.
31+
continue
32+
33+
threading_helper.run_concurrently(drive, self.NUM_THREADS)
34+
self.assertEqual(sorted(values), list(range(100)))
35+
36+
def test_concurrent_athrow(self):
37+
# Each thrown exception must be delivered to the generator
38+
# exactly once.
39+
received = []
40+
41+
async def agen():
42+
while True:
43+
try:
44+
yield 1
45+
except ValueError:
46+
received.append(1)
47+
48+
ag = agen()
49+
with self.assertRaises(StopIteration):
50+
ag.asend(None).send(None) # advance to the first yield
51+
52+
delivered = []
53+
54+
def worker():
55+
for _ in range(50):
56+
try:
57+
ag.athrow(ValueError).send(None)
58+
except StopIteration as e:
59+
# The generator received the exception and yielded again.
60+
delivered.append(e.value)
61+
except RuntimeError:
62+
# Another thread is currently driving the generator.
63+
pass
64+
65+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
66+
self.assertEqual(len(received), len(delivered))
67+
68+
def test_concurrent_aclose(self):
69+
# The generator must be cleaned up exactly once.
70+
cleanups = []
71+
72+
async def agen():
73+
try:
74+
while True:
75+
yield 1
76+
finally:
77+
cleanups.append(1)
78+
79+
ag = agen()
80+
with self.assertRaises(StopIteration):
81+
ag.asend(None).send(None) # advance to the first yield
82+
83+
def worker():
84+
try:
85+
ag.aclose().send(None)
86+
except StopIteration:
87+
# aclose() completed.
88+
pass
89+
except StopAsyncIteration:
90+
# The generator was already closed.
91+
pass
92+
except RuntimeError:
93+
# Another thread is currently driving the generator.
94+
pass
95+
96+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
97+
self.assertEqual(cleanups, [1])
98+
self.assertRaises(StopAsyncIteration, ag.asend(None).send, None)
99+
100+
def test_concurrent_shared_asend(self):
101+
# Multiple threads racing on a single asend awaitable: the value
102+
# must be delivered exactly once.
103+
async def agen():
104+
yield 1
105+
106+
ag = agen()
107+
aw = ag.asend(None)
108+
results = []
109+
110+
def worker():
111+
try:
112+
aw.send(None)
113+
except StopIteration as e:
114+
results.append(e.value)
115+
except (RuntimeError, ValueError, StopAsyncIteration):
116+
pass
117+
118+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
119+
self.assertEqual(results, [1])
120+
# The awaitable is closed after the operation completed.
121+
self.assertRaises(RuntimeError, aw.send, None)
122+
123+
def test_concurrent_shared_athrow(self):
124+
# Multiple threads racing on a single athrow awaitable.
125+
async def agen():
126+
while True:
127+
yield 1
128+
129+
ag = agen()
130+
with self.assertRaises(StopIteration):
131+
ag.asend(None).send(None) # advance to the first yield
132+
aw = ag.athrow(ValueError)
133+
134+
def worker():
135+
try:
136+
aw.send(None)
137+
except (RuntimeError, ValueError,
138+
StopIteration, StopAsyncIteration):
139+
pass
140+
141+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
142+
self.assertRaises(StopAsyncIteration, ag.asend(None).send, None)
143+
# The awaitable is closed after the operation completed.
144+
self.assertRaises(RuntimeError, aw.send, None)
145+
146+
def test_concurrent_shared_aclose(self):
147+
# Multiple threads racing on a single aclose awaitable: the
148+
# generator must be cleaned up exactly once.
149+
cleanups = []
150+
151+
async def agen():
152+
try:
153+
while True:
154+
yield 1
155+
finally:
156+
cleanups.append(1)
157+
158+
ag = agen()
159+
with self.assertRaises(StopIteration):
160+
ag.asend(None).send(None) # advance to the first yield
161+
aw = ag.aclose()
162+
163+
def worker():
164+
try:
165+
aw.send(None)
166+
except (RuntimeError, ValueError,
167+
StopIteration, StopAsyncIteration):
168+
pass
169+
170+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
171+
self.assertEqual(cleanups, [1])
172+
self.assertRaises(StopAsyncIteration, ag.asend(None).send, None)
173+
# The awaitable is closed after the operation completed.
174+
self.assertRaises(RuntimeError, aw.send, None)
175+
176+
def test_concurrent_anext_athrow(self):
177+
async def agen():
178+
while True:
179+
try:
180+
yield 1
181+
except ValueError:
182+
pass
183+
184+
ag = agen()
185+
186+
def worker():
187+
for i in range(100):
188+
try:
189+
if i % 2:
190+
ag.asend(None).send(None)
191+
else:
192+
ag.athrow(ValueError).send(None)
193+
except (RuntimeError, ValueError,
194+
StopIteration, StopAsyncIteration):
195+
pass
196+
197+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
198+
199+
def test_concurrent_anext_aclose(self):
200+
async def agen():
201+
for i in range(100):
202+
yield i
203+
204+
ag = agen()
205+
206+
def anext_worker():
207+
for _ in range(100):
208+
try:
209+
ag.asend(None).send(None)
210+
except (RuntimeError, ValueError,
211+
StopIteration, StopAsyncIteration):
212+
pass
213+
214+
def aclose_worker():
215+
for _ in range(100):
216+
try:
217+
ag.aclose().send(None)
218+
except (RuntimeError, ValueError,
219+
StopIteration, StopAsyncIteration):
220+
pass
221+
222+
threading_helper.run_concurrently(
223+
[anext_worker, aclose_worker, anext_worker, aclose_worker])
224+
225+
def test_firstiter_hook_called_once(self):
226+
# Racing the first iteration must invoke the firstiter hook
227+
# exactly once.
228+
async def agen():
229+
yield 1
230+
231+
ag = agen()
232+
calls = []
233+
234+
def worker():
235+
# Async generator hooks are per-thread state.
236+
sys.set_asyncgen_hooks(firstiter=calls.append)
237+
try:
238+
ag.asend(None).send(None)
239+
except (RuntimeError, ValueError,
240+
StopIteration, StopAsyncIteration):
241+
pass
242+
243+
threading_helper.run_concurrently(worker, self.NUM_THREADS)
244+
self.assertEqual(calls, [ag])
245+
246+
247+
if __name__ == "__main__":
248+
unittest.main()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix thread safety of :term:`asynchronous generators <asynchronous
2+
generator>` when iterated, closed or thrown into concurrently from multiple
3+
threads on the :term:`free threading` build. Also make an ``athrow()``
4+
awaitable single-use: reusing it after completion now raises
5+
:exc:`RuntimeError` instead of resuming the generator again.

0 commit comments

Comments
 (0)