Skip to content

Commit 23fc351

Browse files
authored
Merge pull request #83 from njsmith/fixture-yield-is-checkpoint
Rework handling of cancelled 'yield' in fixtures
2 parents 68dad9c + 0e30148 commit 23fc351

File tree

6 files changed

+218
-8
lines changed

6 files changed

+218
-8
lines changed

docs/source/reference.rst

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,114 @@ but Trio fixtures **must be test scoped**. Class, module, and session
106106
scope are not supported.
107107

108108

109+
.. _cancel-yield:
110+
111+
An important note about ``yield`` fixtures
112+
------------------------------------------
113+
114+
Like any pytest fixture, Trio fixtures can contain both setup and
115+
teardown code separated by a ``yield``::
116+
117+
@pytest.fixture
118+
async def my_fixture():
119+
... setup code ...
120+
yield
121+
... teardown code ...
122+
123+
When pytest-trio executes this fixture, it creates a new task, and
124+
runs the setup code until it reaches the ``yield``. Then the fixture's
125+
task goes to sleep. Once the test has finished, the fixture task wakes
126+
up again and resumes at the ``yield``, so it can execute the teardown
127+
code.
128+
129+
So the ``yield`` in a fixture is sort of like calling ``await
130+
wait_for_test_to_finish()``. And in Trio, any ``await``\-able
131+
operation can be cancelled. For example, we could put a timeout on the
132+
``yield``::
133+
134+
@pytest.fixture
135+
async def my_fixture():
136+
... setup code ...
137+
with trio.move_on_after(5):
138+
yield # this yield gets cancelled after 5 seconds
139+
... teardown code ...
140+
141+
Now if the test takes more than 5 seconds to execute, this fixture
142+
will cancel the ``yield``.
143+
144+
That's kind of a strange thing to do, but there's another version of
145+
this that's extremely common. Suppose your fixture spawns a background
146+
task, and then the background task raises an exception. Whenever a
147+
background task raises an exception, it automatically cancels
148+
everything inside the nursery's scope – which includes our ``yield``::
149+
150+
@pytest.fixture
151+
async def my_fixture(nursery):
152+
nursery.start_soon(function_that_raises_exception)
153+
yield # this yield gets cancelled after the background task crashes
154+
... teardown code ...
155+
156+
If you use fixtures with background tasks, you'll probably end up
157+
cancelling one of these ``yield``\s sooner or later. So what happens
158+
if the ``yield`` gets cancelled?
159+
160+
First, pytest-trio assumes that something has gone wrong and there's
161+
no point in continuing the test. If the top-level test function is
162+
running, then it cancels it.
163+
164+
Then, pytest-trio waits for the test function to finish, and
165+
then begins tearing down fixtures as normal.
166+
167+
During this teardown process, it will eventually reach the fixture
168+
that cancelled its ``yield``. This fixture gets resumed to execute its
169+
teardown logic, but with a special twist: since the ``yield`` was
170+
cancelled, the ``yield`` raises :exc:`trio.Cancelled`.
171+
172+
Now, here's the punchline: this means that in our examples above, the
173+
teardown code might not be executed at all! **This is different from
174+
how pytest fixtures normally work.** Normally, the ``yield`` in a
175+
pytest fixture never raises an exception, so you can be certain that
176+
any code you put after it will execute as normal. But if you have a
177+
fixture with background tasks, and they crash, then your ``yield``
178+
might raise an exception, and Python will skip executing the code
179+
after the ``yield``.
180+
181+
In our experience, most fixtures are fine with this, and it prevents
182+
some `weird problems
183+
<https://github.com/python-trio/pytest-trio/issues/75>`__ that can
184+
happen otherwise. But it's something to be aware of.
185+
186+
If you have a fixture where the ``yield`` might be cancelled but you
187+
still need to run teardown code, then you can use a ``finally``
188+
block::
189+
190+
@pytest.fixture
191+
async def my_fixture(nursery):
192+
nursery.start_soon(function_that_crashes)
193+
try:
194+
# This yield could be cancelled...
195+
yield
196+
finally:
197+
# But this code will run anyway
198+
... teardown code ...
199+
200+
(But, watch out: the teardown code is still running in a cancelled
201+
context, so if it has any ``await``\s it could raise
202+
:exc:`trio.Cancelled` again.)
203+
204+
Or if you use ``with`` to handle teardown, then you don't have to
205+
worry about this because ``with`` blocks always perform cleanup even
206+
if there's an exception::
207+
208+
@pytest.fixture
209+
async def my_fixture(nursery):
210+
with get_obj_that_must_be_torn_down() as obj:
211+
nursery.start_soon(function_that_crashes, obj)
212+
# This could raise trio.Cancelled...
213+
# ...but that's OK, the 'with' block will still tear down 'obj'
214+
yield obj
215+
216+
109217
Concurrent setup/teardown
110218
-------------------------
111219

newsfragments/75.feature.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Incompatible change: if you use ``yield`` inside a Trio fixture, and
2+
the ``yield`` gets cancelled (for example, due to a background task
3+
crashing), then the ``yield`` will now raise :exc:`trio.Cancelled`.
4+
See :ref:`cancel-yield` for details. Also, in this same case,
5+
pytest-trio will now reliably mark the test as failed, even if the
6+
fixture doesn't go on to raise an exception.

pytest_trio/_tests/test_fixture_mistakes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,32 @@ def test_whatever(async_fixture):
117117
result.stdout.fnmatch_lines(
118118
["*: Trio fixtures can only be used by Trio tests*"]
119119
)
120+
121+
122+
@enable_trio_mode
123+
def test_fixture_cancels_test_but_doesnt_raise(testdir, enable_trio_mode):
124+
enable_trio_mode(testdir)
125+
126+
testdir.makepyfile(
127+
"""
128+
import pytest
129+
import trio
130+
from async_generator import async_generator, yield_
131+
132+
@pytest.fixture
133+
@async_generator
134+
async def async_fixture():
135+
with trio.CancelScope() as cscope:
136+
cscope.cancel()
137+
await yield_()
138+
139+
140+
async def test_whatever(async_fixture):
141+
pass
142+
"""
143+
)
144+
145+
result = testdir.runpytest()
146+
147+
result.assert_outcomes(failed=1)
148+
result.stdout.fnmatch_lines(["*async_fixture*cancelled the test*"])

pytest_trio/_tests/test_fixture_ordering.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ def test_background_crash_cancellation_propagation(bgmode, testdir):
213213
@trio_fixture
214214
def crashyfix(nursery):
215215
nursery.start_soon(crashy)
216-
yield
216+
with pytest.raises(trio.Cancelled):
217+
yield
217218
# We should be cancelled here
218219
teardown_deadlines["crashyfix"] = trio.current_effective_deadline()
219220
"""
@@ -224,7 +225,8 @@ def crashyfix(nursery):
224225
async def crashyfix():
225226
async with trio.open_nursery() as nursery:
226227
nursery.start_soon(crashy)
227-
await yield_()
228+
with pytest.raises(trio.Cancelled):
229+
await yield_()
228230
# We should be cancelled here
229231
teardown_deadlines["crashyfix"] = trio.current_effective_deadline()
230232
"""
@@ -284,3 +286,47 @@ def test_post():
284286

285287
result = testdir.runpytest()
286288
result.assert_outcomes(passed=1, failed=1)
289+
290+
291+
# See the thread starting at
292+
# https://github.com/python-trio/pytest-trio/pull/77#issuecomment-499979536
293+
# for details on the real case that this was minimized from
294+
def test_complex_cancel_interaction_regression(testdir):
295+
testdir.makepyfile(
296+
"""
297+
import pytest
298+
import trio
299+
from async_generator import asynccontextmanager, async_generator, yield_
300+
301+
async def die_soon():
302+
raise RuntimeError('oops'.upper())
303+
304+
@asynccontextmanager
305+
@async_generator
306+
async def async_finalizer():
307+
try:
308+
await yield_()
309+
finally:
310+
await trio.sleep(0)
311+
312+
@pytest.fixture
313+
@async_generator
314+
async def fixture(nursery):
315+
async with trio.open_nursery() as nursery1:
316+
async with async_finalizer():
317+
async with trio.open_nursery() as nursery2:
318+
nursery2.start_soon(die_soon)
319+
await yield_()
320+
nursery1.cancel_scope.cancel()
321+
322+
@pytest.mark.trio
323+
async def test_try(fixture):
324+
await trio.sleep_forever()
325+
"""
326+
)
327+
328+
result = testdir.runpytest()
329+
result.assert_outcomes(passed=0, failed=1)
330+
result.stdout.fnmatch_lines_random([
331+
"*OOPS*",
332+
])

pytest_trio/plugin.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Coroutine, Generator
55
from inspect import iscoroutinefunction, isgeneratorfunction
66
import contextvars
7+
import outcome
78
import pytest
89
import trio
910
from trio.testing import MockClock, trio_test
@@ -132,11 +133,16 @@ class TrioTestContext:
132133
def __init__(self):
133134
self.crashed = False
134135
self.test_cancel_scope = None
136+
self.fixtures_with_errors = set()
137+
self.fixtures_with_cancel = set()
135138
self.error_list = []
136139

137-
def crash(self, exc):
138-
if exc is not None:
140+
def crash(self, fixture, exc):
141+
if exc is None:
142+
self.fixtures_with_cancel.add(fixture)
143+
else:
139144
self.error_list.append(exc)
145+
self.fixtures_with_errors.add(fixture)
140146
self.crashed = True
141147
if self.test_cancel_scope is not None:
142148
self.test_cancel_scope.cancel()
@@ -192,7 +198,7 @@ async def _fixture_manager(self, test_ctx):
192198
finally:
193199
nursery_fixture.cancel_scope.cancel()
194200
except BaseException as exc:
195-
test_ctx.crash(exc)
201+
test_ctx.crash(self, exc)
196202
finally:
197203
self.setup_done.set()
198204
self._teardown_done.set()
@@ -278,27 +284,29 @@ async def run(self, test_ctx, contextvars_ctx):
278284
# code will get it again if it matters), and then use a shield to
279285
# keep waiting for the teardown to finish without having to worry
280286
# about cancellation.
287+
yield_outcome = outcome.Value(None)
281288
try:
282289
for event in self.user_done_events:
283290
await event.wait()
284291
except BaseException as exc:
285292
assert isinstance(exc, trio.Cancelled)
286-
test_ctx.crash(None)
293+
yield_outcome = outcome.Error(exc)
294+
test_ctx.crash(self, None)
287295
with trio.CancelScope(shield=True):
288296
for event in self.user_done_events:
289297
await event.wait()
290298

291299
# Do our teardown
292300
if isasyncgen(func_value):
293301
try:
294-
await func_value.asend(None)
302+
await yield_outcome.asend(func_value)
295303
except StopAsyncIteration:
296304
pass
297305
else:
298306
raise RuntimeError("too many yields in fixture")
299307
elif isinstance(func_value, Generator):
300308
try:
301-
func_value.send(None)
309+
yield_outcome.send(func_value)
302310
except StopIteration:
303311
pass
304312
else:
@@ -339,6 +347,18 @@ async def _bootstrap_fixtures_and_run_test(**kwargs):
339347
fixture.run, test_ctx, contextvars_ctx, name=fixture.name
340348
)
341349

350+
silent_cancellers = (
351+
test_ctx.fixtures_with_cancel - test_ctx.fixtures_with_errors
352+
)
353+
if silent_cancellers:
354+
for fixture in silent_cancellers:
355+
test_ctx.error_list.append(
356+
RuntimeError(
357+
"{} cancelled the test but didn't "
358+
"raise an error".format(fixture.name)
359+
)
360+
)
361+
342362
if test_ctx.error_list:
343363
raise trio.MultiError(test_ctx.error_list)
344364

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
install_requires=[
1919
"trio >= 0.11",
2020
"async_generator >= 1.9",
21+
"outcome",
2122
# For node.get_closest_marker
2223
"pytest >= 3.6"
2324
],

0 commit comments

Comments
 (0)