Skip to content

Commit 7e67034

Browse files
committed
When fixture setup crashes, immediately cancel all other fixture setups
Fixes python-triogh-120
1 parent 59da535 commit 7e67034

File tree

3 files changed

+79
-18
lines changed

3 files changed

+79
-18
lines changed

newsfragments/120.bugfix.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix an issue where if two fixtures are being set up concurrently, and
2+
one crashes and the other hangs, then the test as a whole would hang,
3+
rather than being cancelled and unwound after the crash.

pytest_trio/_tests/test_fixture_ordering.py

+44-2
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,15 @@ def test_error_collection(testdir):
154154
155155
@trio_fixture
156156
async def crash_nongen():
157-
await trio.sleep(2)
157+
with trio.CancelScope(shield=True):
158+
await trio.sleep(2)
158159
raise RuntimeError("crash_nongen".upper())
159160
160161
@trio_fixture
161162
@async_generator
162163
async def crash_early_agen():
163-
await trio.sleep(2)
164+
with trio.CancelScope(shield=True):
165+
await trio.sleep(2)
164166
raise RuntimeError("crash_early_agen".upper())
165167
await yield_()
166168
@@ -330,3 +332,43 @@ async def test_try(fixture):
330332
result.stdout.fnmatch_lines_random([
331333
"*OOPS*",
332334
])
335+
336+
337+
# Makes sure that
338+
# See https://github.com/python-trio/pytest-trio/issues/120
339+
def test_fixtures_crash_and_hang_concurrently(testdir):
340+
testdir.makepyfile(
341+
"""
342+
import trio
343+
import pytest
344+
345+
346+
@pytest.fixture
347+
async def hanging_fixture():
348+
print("hanging_fixture:start")
349+
await trio.Event().wait()
350+
yield
351+
print("hanging_fixture:end")
352+
353+
354+
@pytest.fixture
355+
async def exploding_fixture():
356+
print("exploding_fixture:start")
357+
raise Exception
358+
yield
359+
print("exploding_fixture:end")
360+
361+
362+
@pytest.mark.trio
363+
async def test_fails_right_away(exploding_fixture):
364+
...
365+
366+
367+
@pytest.mark.trio
368+
async def test_fails_needs_some_scopes(exploding_fixture, hanging_fixture):
369+
...
370+
"""
371+
)
372+
373+
result = testdir.runpytest()
374+
result.assert_outcomes(passed=0, failed=2)

pytest_trio/plugin.py

+32-16
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,25 @@ def pytest_exception_interact(node, call, report):
121121
# If a fixture crashes, whether during setup, teardown, or in a background
122122
# task at any other point, then we mark the whole test run as "crashed". When
123123
# a run is "crashed", two things happen: (1) if any fixtures or the test
124-
# itself haven't started yet, then we don't start them. (2) if the test is
125-
# running, we cancel it. That's all. In particular, if a fixture has a
126-
# background crash, we don't propagate that to any other fixtures, we still
127-
# follow the normal teardown sequence, and so on – but since the test is
128-
# cancelled, the teardown sequence should start immediately.
124+
# itself haven't started yet, then we don't start them, and treat them as if
125+
# they've already exited. (2) if the test is running, we cancel it. That's
126+
# all. In particular, if a fixture has a background crash, we don't propagate
127+
# that to any other fixtures, we still follow the normal teardown sequence,
128+
# and so on – but since the test is cancelled, the teardown sequence should
129+
# start immediately.
129130

130131
canary = contextvars.ContextVar("pytest-trio canary")
131132

132133

133134
class TrioTestContext:
134135
def __init__(self):
135136
self.crashed = False
136-
self.test_cancel_scope = None
137+
# This holds cancel scopes for whatever setup steps are currently
138+
# running -- initially it's the fixtures that are in the middle of
139+
# evaluating themselves, and then once fixtures are set up it's the
140+
# test itself. Basically, at any given moment, it's the stuff we need
141+
# to cancel if we want to start tearing down our fixture DAG.
142+
self.active_cancel_scopes = set()
137143
self.fixtures_with_errors = set()
138144
self.fixtures_with_cancel = set()
139145
self.error_list = []
@@ -145,8 +151,8 @@ def crash(self, fixture, exc):
145151
self.error_list.append(exc)
146152
self.fixtures_with_errors.add(fixture)
147153
self.crashed = True
148-
if self.test_cancel_scope is not None:
149-
self.test_cancel_scope.cancel()
154+
for cscope in self.active_cancel_scopes:
155+
cscope.cancel()
150156

151157

152158
class TrioFixture:
@@ -240,16 +246,17 @@ async def run(self, test_ctx, contextvars_ctx):
240246
return
241247

242248
# Run actual fixture setup step
249+
# If another fixture crashes while we're in the middle of setting
250+
# up, we want to be cancelled immediately, so we'll save an
251+
# encompassing cancel scope where self._crash can find it.
252+
test_ctx.active_cancel_scopes.add(nursery_fixture.cancel_scope)
243253
if self._is_test:
244-
# Tests are exactly like fixtures, except that they (1) have
245-
# to be regular async functions, (2) if there's a crash, we
246-
# should cancel them.
254+
# Tests are exactly like fixtures, except that they to be
255+
# regular async functions.
247256
assert not self.user_done_events
248257
func_value = None
249-
with trio.CancelScope() as cancel_scope:
250-
test_ctx.test_cancel_scope = cancel_scope
251-
assert not test_ctx.crashed
252-
await self._func(**resolved_kwargs)
258+
assert not test_ctx.crashed
259+
await self._func(**resolved_kwargs)
253260
else:
254261
func_value = self._func(**resolved_kwargs)
255262
if isinstance(func_value, Coroutine):
@@ -261,8 +268,17 @@ async def run(self, test_ctx, contextvars_ctx):
261268
else:
262269
# Regular synchronous function
263270
self.fixture_value = func_value
271+
# Now that we're done setting up, we don't want crashes to cancel
272+
# us immediately; instead we want them to cancel our downstream
273+
# dependents, and then eventually let us clean up normally. So
274+
# remove this from the set of cancel scopes affected by self._crash.
275+
test_ctx.active_cancel_scopes.remove(nursery_fixture.cancel_scope)
264276

265-
# Notify our users that self.fixture_value is ready
277+
278+
# self.fixture_value is ready, so notify users that they can
279+
# continue. (Or, maybe we crashed and were cancelled, in which
280+
# case our users will check test_ctx.crashed and immediately exit,
281+
# which is fine too.)
266282
self.setup_done.set()
267283

268284
# Wait for users to be finished

0 commit comments

Comments
 (0)