Skip to content

Commit

Permalink
Merge pull request #121 from njsmith/fix-120
Browse files Browse the repository at this point in the history
  • Loading branch information
pquentin committed May 7, 2021
2 parents 59da535 + b338795 commit e7debcb
Show file tree
Hide file tree
Showing 5 changed files with 85 additions and 28 deletions.
5 changes: 3 additions & 2 deletions docs-requirements.in
Expand Up @@ -5,8 +5,9 @@ sphinxcontrib-trio
# Workaround for this weird issue:
# https://travis-ci.org/python-trio/pytest-trio/jobs/407495415
attrs >= 17.4.0
# != 19.9.0rc1 for https://github.com/twisted/towncrier/issues/180
towncrier != 19.9.0rc1
# != 19.9.0 for https://github.com/twisted/towncrier/issues/180
# != 21.3.0 for https://github.com/twisted/towncrier/issues/346
towncrier != 19.9.0,!= 21.3.0

# pytest-trio's own dependencies
trio >= 0.15.0
Expand Down
10 changes: 3 additions & 7 deletions docs-requirements.txt
Expand Up @@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file=docs-requirements.txt docs-requirements.in
# pip-compile docs-requirements.in
#
alabaster==0.7.12
# via sphinx
Expand All @@ -22,12 +22,8 @@ certifi==2020.12.5
# via requests
chardet==4.0.0
# via requests
click-default-group==1.2.2
# via towncrier
click==7.1.2
# via
# click-default-group
# towncrier
# via towncrier
docutils==0.16
# via
# sphinx
Expand Down Expand Up @@ -101,7 +97,7 @@ toml==0.10.2
# via
# pytest
# towncrier
towncrier==21.3.0
towncrier==19.2.0
# via -r docs-requirements.in
trio==0.18.0
# via -r docs-requirements.in
Expand Down
3 changes: 3 additions & 0 deletions newsfragments/120.bugfix.rst
@@ -0,0 +1,3 @@
Fix an issue where if two fixtures are being set up concurrently, and
one crashes and the other hangs, then the test as a whole would hang,
rather than being cancelled and unwound after the crash.
46 changes: 44 additions & 2 deletions pytest_trio/_tests/test_fixture_ordering.py
Expand Up @@ -154,13 +154,15 @@ def test_error_collection(testdir):
@trio_fixture
async def crash_nongen():
await trio.sleep(2)
with trio.CancelScope(shield=True):
await trio.sleep(2)
raise RuntimeError("crash_nongen".upper())
@trio_fixture
@async_generator
async def crash_early_agen():
await trio.sleep(2)
with trio.CancelScope(shield=True):
await trio.sleep(2)
raise RuntimeError("crash_early_agen".upper())
await yield_()
Expand Down Expand Up @@ -330,3 +332,43 @@ async def test_try(fixture):
result.stdout.fnmatch_lines_random([
"*OOPS*",
])


# Makes sure that
# See https://github.com/python-trio/pytest-trio/issues/120
def test_fixtures_crash_and_hang_concurrently(testdir):
testdir.makepyfile(
"""
import trio
import pytest
@pytest.fixture
async def hanging_fixture():
print("hanging_fixture:start")
await trio.Event().wait()
yield
print("hanging_fixture:end")
@pytest.fixture
async def exploding_fixture():
print("exploding_fixture:start")
raise Exception
yield
print("exploding_fixture:end")
@pytest.mark.trio
async def test_fails_right_away(exploding_fixture):
...
@pytest.mark.trio
async def test_fails_needs_some_scopes(exploding_fixture, hanging_fixture):
...
"""
)

result = testdir.runpytest()
result.assert_outcomes(passed=0, failed=2)
49 changes: 32 additions & 17 deletions pytest_trio/plugin.py
Expand Up @@ -121,19 +121,25 @@ def pytest_exception_interact(node, call, report):
# If a fixture crashes, whether during setup, teardown, or in a background
# task at any other point, then we mark the whole test run as "crashed". When
# a run is "crashed", two things happen: (1) if any fixtures or the test
# itself haven't started yet, then we don't start them. (2) if the test is
# running, we cancel it. That's all. In particular, if a fixture has a
# background crash, we don't propagate that to any other fixtures, we still
# follow the normal teardown sequence, and so on – but since the test is
# cancelled, the teardown sequence should start immediately.
# itself haven't started yet, then we don't start them, and treat them as if
# they've already exited. (2) if the test is running, we cancel it. That's
# all. In particular, if a fixture has a background crash, we don't propagate
# that to any other fixtures, we still follow the normal teardown sequence,
# and so on – but since the test is cancelled, the teardown sequence should
# start immediately.

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


class TrioTestContext:
def __init__(self):
self.crashed = False
self.test_cancel_scope = None
# This holds cancel scopes for whatever setup steps are currently
# running -- initially it's the fixtures that are in the middle of
# evaluating themselves, and then once fixtures are set up it's the
# test itself. Basically, at any given moment, it's the stuff we need
# to cancel if we want to start tearing down our fixture DAG.
self.active_cancel_scopes = set()
self.fixtures_with_errors = set()
self.fixtures_with_cancel = set()
self.error_list = []
Expand All @@ -145,8 +151,8 @@ def crash(self, fixture, exc):
self.error_list.append(exc)
self.fixtures_with_errors.add(fixture)
self.crashed = True
if self.test_cancel_scope is not None:
self.test_cancel_scope.cancel()
for cscope in self.active_cancel_scopes:
cscope.cancel()


class TrioFixture:
Expand Down Expand Up @@ -240,16 +246,17 @@ async def run(self, test_ctx, contextvars_ctx):
return

# Run actual fixture setup step
# If another fixture crashes while we're in the middle of setting
# up, we want to be cancelled immediately, so we'll save an
# encompassing cancel scope where self._crash can find it.
test_ctx.active_cancel_scopes.add(nursery_fixture.cancel_scope)
if self._is_test:
# Tests are exactly like fixtures, except that they (1) have
# to be regular async functions, (2) if there's a crash, we
# should cancel them.
# Tests are exactly like fixtures, except that they to be
# regular async functions.
assert not self.user_done_events
func_value = None
with trio.CancelScope() as cancel_scope:
test_ctx.test_cancel_scope = cancel_scope
assert not test_ctx.crashed
await self._func(**resolved_kwargs)
assert not test_ctx.crashed
await self._func(**resolved_kwargs)
else:
func_value = self._func(**resolved_kwargs)
if isinstance(func_value, Coroutine):
Expand All @@ -261,8 +268,16 @@ async def run(self, test_ctx, contextvars_ctx):
else:
# Regular synchronous function
self.fixture_value = func_value

# Notify our users that self.fixture_value is ready
# Now that we're done setting up, we don't want crashes to cancel
# us immediately; instead we want them to cancel our downstream
# dependents, and then eventually let us clean up normally. So
# remove this from the set of cancel scopes affected by self._crash.
test_ctx.active_cancel_scopes.remove(nursery_fixture.cancel_scope)

# self.fixture_value is ready, so notify users that they can
# continue. (Or, maybe we crashed and were cancelled, in which
# case our users will check test_ctx.crashed and immediately exit,
# which is fine too.)
self.setup_done.set()

# Wait for users to be finished
Expand Down

0 comments on commit e7debcb

Please sign in to comment.