Skip to content

Commit eeb76f2

Browse files
Graceful contextmanager cleanup (#85)
* added tests for contextmanager handling GeneratorExit * contextmanager handles closed child gracefully
1 parent c268dbd commit eeb76f2

File tree

2 files changed

+84
-1
lines changed

2 files changed

+84
-1
lines changed

asyncstdlib/contextlib.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,13 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
9090
raise RuntimeError("generator did not stop after __aexit__")
9191
else:
9292
try:
93-
await self.gen.athrow(exc_type, exc_val, exc_tb)
93+
# We are being closed as part of (async) generator shutdown.
94+
# Use `aclose` to have additional checks for the child to
95+
# handle shutdown properly.
96+
if exc_type is GeneratorExit:
97+
result = await self.gen.aclose()
98+
else:
99+
result = await self.gen.athrow(exc_type, exc_val, exc_tb)
94100
except StopAsyncIteration as exc:
95101
return exc is not exc_tb
96102
except RuntimeError as exc:
@@ -106,6 +112,16 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
106112
raise
107113
return False
108114
else:
115+
# During shutdown, the child generator might be cleaned up early.
116+
# In this case,
117+
# - the child will return nothing/None,
118+
# - we get cleaned up via GeneratorExit as well,
119+
# and we should go on with our own cleanup.
120+
# This might happen if the child mishandles GeneratorExit as well,
121+
# but is the closest we can get to checking the situation.
122+
# See https://github.com/maxfischer2781/asyncstdlib/issues/84
123+
if exc_type is GeneratorExit and result is None:
124+
return False
109125
raise RuntimeError("generator did not stop after throw() in __aexit__")
110126

111127

unittests/test_contextlib.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ async def valid(value):
3434

3535
@sync
3636
async def test_contextmanager_no_yield():
37+
"""Test that it is an error for a context to not yield"""
38+
3739
@a.contextmanager
3840
async def no_yield():
3941
if False:
@@ -46,6 +48,8 @@ async def no_yield():
4648

4749
@sync
4850
async def test_contextmanager_no_stop():
51+
"""Test that it is an error for a context to yield again after stopping"""
52+
4953
@a.contextmanager
5054
async def no_stop():
5155
yield
@@ -69,6 +73,8 @@ async def supress_no_stop():
6973

7074
@sync
7175
async def test_contextmanager_raise_asyncstop():
76+
"""Test that StopAsyncIteration may propagate out of a context block"""
77+
7278
@a.contextmanager
7379
async def no_raise():
7480
yield
@@ -113,6 +119,8 @@ async def replace():
113119

114120
@sync
115121
async def test_contextmanager_raise_same():
122+
"""Test that outer exceptions do not shadow inner/newer ones"""
123+
116124
@a.contextmanager
117125
async def reraise():
118126
try:
@@ -136,6 +144,65 @@ async def recreate():
136144
raise KeyError("outside")
137145

138146

147+
@sync
148+
async def test_contextmanager_raise_generatorexit():
149+
"""Test that shutdown via GeneratorExit is propagated"""
150+
151+
@a.contextmanager
152+
async def no_op():
153+
yield
154+
155+
with pytest.raises(GeneratorExit):
156+
async with no_op():
157+
raise GeneratorExit("used to tear down coroutines")
158+
159+
# during shutdown, generators may be killed in arbitrary order
160+
# make sure we do not suppress GeneratorExit
161+
with pytest.raises(GeneratorExit, match="inner"):
162+
context = no_op()
163+
async with context:
164+
# simulate cleanup closing the child early
165+
await context.gen.aclose()
166+
raise GeneratorExit("inner")
167+
168+
169+
@sync
170+
async def test_contextmanager_no_suppress_generatorexit():
171+
"""Test that GeneratorExit is not suppressed"""
172+
173+
@a.contextmanager
174+
async def no_op():
175+
yield
176+
177+
exc = GeneratorExit("GE should not be replaced normally")
178+
with pytest.raises(type(exc)) as exc_info:
179+
async with no_op():
180+
raise exc
181+
assert exc_info.value is exc
182+
183+
@a.contextmanager
184+
async def exit_ge():
185+
try:
186+
yield
187+
except GeneratorExit:
188+
pass
189+
190+
with pytest.raises(GeneratorExit):
191+
async with exit_ge():
192+
raise GeneratorExit("Resume teardown if child exited")
193+
194+
@a.contextmanager
195+
async def ignore_ge():
196+
try:
197+
yield
198+
except GeneratorExit:
199+
yield
200+
201+
with pytest.raises(RuntimeError):
202+
async with ignore_ge():
203+
raise GeneratorExit("Warn if child does not exit")
204+
205+
139206
@sync
140207
async def test_nullcontext():
141208
async with a.nullcontext(1337) as value:

0 commit comments

Comments
 (0)