Skip to content

Commit 0a02966

Browse files
committed
In Trio fixtures, 'yield' now acts as a checkpoint.
1 parent 68dad9c commit 0a02966

5 files changed

Lines changed: 166 additions & 4 deletions

File tree

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
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.

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: 5 additions & 2 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
@@ -278,11 +279,13 @@ async def run(self, test_ctx, contextvars_ctx):
278279
# code will get it again if it matters), and then use a shield to
279280
# keep waiting for the teardown to finish without having to worry
280281
# about cancellation.
282+
yield_outcome = outcome.Value(None)
281283
try:
282284
for event in self.user_done_events:
283285
await event.wait()
284286
except BaseException as exc:
285287
assert isinstance(exc, trio.Cancelled)
288+
yield_outcome = outcome.Error(exc)
286289
test_ctx.crash(None)
287290
with trio.CancelScope(shield=True):
288291
for event in self.user_done_events:
@@ -291,14 +294,14 @@ async def run(self, test_ctx, contextvars_ctx):
291294
# Do our teardown
292295
if isasyncgen(func_value):
293296
try:
294-
await func_value.asend(None)
297+
await yield_outcome.asend(func_value)
295298
except StopAsyncIteration:
296299
pass
297300
else:
298301
raise RuntimeError("too many yields in fixture")
299302
elif isinstance(func_value, Generator):
300303
try:
301-
func_value.send(None)
304+
yield_outcome.send(func_value)
302305
except StopIteration:
303306
pass
304307
else:

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)