Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions pytest_trio/_tests/test_async_yield_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,59 @@ def test_after():
result.stdout.re_match_lines(
[r'E\W+RuntimeError: Crash during fixture teardown']
)


def test_async_yield_fixture_crashed_then_parent_cancel(
testdir, async_yield_implementation
):
testdir.makepyfile(
async_yield_implementation(
"""
import pytest
import trio
from async_generator import asynccontextmanager, async_generator, yield_

async def die_soon(*, task_status=trio.TASK_STATUS_IGNORED):
task_status.started()
raise RuntimeError('Ooops !')

@asynccontextmanager
@async_generator
async def async_finalizer():
try:
await yield_()
finally:
await trio.sleep(0)

@asynccontextmanager
@async_generator
async def actx():
async with trio.open_nursery() as nursery:
async with async_finalizer():
async with trio.open_nursery() as nursery2:
await nursery2.start(die_soon)
await yield_()
# Comment next line to make the test pass :'(
nursery.cancel_scope.cancel()

@pytest.fixture
@async_generator
async def fixture():
async with actx():
await yield_()

@pytest.mark.trio
async def test_with_fixture(fixture):
await trio.sleep_forever()

@pytest.mark.trio
async def test_without_fixture():
async with actx():
await trio.sleep_forever()
"""
)
)

result = testdir.runpytest()

result.assert_outcomes(failed=2)
22 changes: 20 additions & 2 deletions pytest_trio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ class TrioTestContext:
def __init__(self):
self.crashed = False
self.test_cancel_scope = None
self.error_list = []
self.error_list = set()

def crash(self, exc):
if exc is not None:
self.error_list.append(exc)
self.error_list.add(exc)
self.crashed = True
if self.test_cancel_scope is not None:
self.test_cancel_scope.cancel()
Expand Down Expand Up @@ -284,6 +284,19 @@ async def run(self, test_ctx, contextvars_ctx):
except BaseException as exc:
assert isinstance(exc, trio.Cancelled)
test_ctx.crash(None)

# If we are unlucky, nursery cancellation during fixture
# teardown can silence the original exception (fixture teardown
# doesn't know about the original exception, and nursery will
# get a Cancelled exception from the cancellation, hence
# considering everything went smoothly, see
# https://github.com/python-trio/pytest-trio/issues/77)
# To avoid this, we scavenge the exceptions that occurred in our
# children nursery, just in case they got forgotten otherwise...
for child_nursery in task.child_nurseries:
for exc in child_nursery._pending_excs:
test_ctx.crash(exc)

with trio.CancelScope(shield=True):
for event in self.user_done_events:
await event.wait()
Expand Down Expand Up @@ -341,6 +354,11 @@ async def _bootstrap_fixtures_and_run_test(**kwargs):

if test_ctx.error_list:
raise trio.MultiError(test_ctx.error_list)
elif test_ctx.crashed: # pragma: no cover
raise trio.TrioInternalError(
"Test has crashed, but we couldn't recover the error "
"(see https://github.com/python-trio/pytest-trio/issues/75)"
)

_bootstrap_fixtures_and_run_test._trio_test_runner_wrapped = True
return _bootstrap_fixtures_and_run_test
Expand Down