Skip to content

Commit ea74f06

Browse files
Add timeout support via pytest-timeout
Whenever the trio_timeout option is enabled, this plugin will hook into requests from pytest-timeout to set a timeout. It will then start a thread in the background that, after the timeout has reached, will inject a system task in the test loop. This system task will collect stacktraces for all tasks and raise an exception that will terminate the test. The timeout thread is reused for other tests as well to not incur a startup cost for every test. Since this feature integrates with pytest-timeout, it also honors things like whether a debugger is attached or not. Drawbacks: - Ideally, whether trio does timeouts should not be a global option, but would be better suited for the timeout-method in pytest-timeout. This would require a change in pytest-timeout to let plugins register other timeout methods. - This method requires a functioning loop. Fixes #53
1 parent e930e6f commit ea74f06

File tree

9 files changed

+223
-6
lines changed

9 files changed

+223
-6
lines changed

docs-requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ towncrier != 19.9.0,!= 21.3.0
1212
trio >= 0.22.0
1313
outcome >= 1.1.0
1414
pytest >= 7.2.0
15+
pytest_timeout

docs-requirements.txt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#
2-
# This file is autogenerated by pip-compile with python 3.8
3-
# To update, run:
2+
# This file is autogenerated by pip-compile with Python 3.10
3+
# by the following command:
44
#
55
# pip-compile docs-requirements.in
66
#
@@ -40,8 +40,6 @@ idna==3.4
4040
# trio
4141
imagesize==1.4.1
4242
# via sphinx
43-
importlib-metadata==5.0.0
44-
# via sphinx
4543
incremental==22.10.0
4644
# via towncrier
4745
iniconfig==1.1.1
@@ -67,6 +65,10 @@ pygments==2.13.0
6765
pyparsing==3.0.9
6866
# via packaging
6967
pytest==7.2.0
68+
# via
69+
# -r docs-requirements.in
70+
# pytest-timeout
71+
pytest-timeout==2.1.0
7072
# via -r docs-requirements.in
7173
pytz==2022.5
7274
# via babel
@@ -109,8 +111,6 @@ trio==0.22.0
109111
# via -r docs-requirements.in
110112
urllib3==1.26.12
111113
# via requests
112-
zipp==3.10.0
113-
# via importlib-metadata
114114

115115
# The following packages are considered to be unsafe in a requirements file:
116116
# setuptools

docs/source/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ and async I/O in Python. Features include:
3333
<https://hypothesis.works/>`__ library, so your async tests can use
3434
property-based testing: just use ``@given`` like you're used to.
3535

36+
* Integration with `pytest-timeout <https://github.com/pytest-dev/pytest-timeout>`
37+
3638
* Support for testing projects that use Trio exclusively and want to
3739
use pytest-trio everywhere, and also for testing projects that
3840
support multiple async libraries and only want to enable

docs/source/reference.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,28 @@ it can be passed directly to the marker.
420420
@pytest.mark.trio(run=qtrio.run)
421421
async def test():
422422
assert True
423+
424+
425+
Configuring timeouts with pytest-timeout
426+
----------------------------------------
427+
428+
Timeouts can be configured using the ``@pytest.mark.timeout`` decorator.
429+
430+
.. code-block:: python
431+
432+
import pytest
433+
import trio
434+
435+
@pytest.mark.timeout(10)
436+
async def test():
437+
await trio.sleep_forever() # will error after 10 seconds
438+
439+
To get clean stacktraces that cover all tasks running when the timeout was triggered, enable the ``trio_timeout`` option.
440+
441+
.. code-block:: ini
442+
443+
# pytest.ini
444+
[pytest]
445+
trio_timeout = true
446+
447+
This timeout method requires a functioning loop, and hence will not be triggered if your test doesn't yield to the loop. This typically occurs when the test is stuck on some non-async piece of code.

newsfragments/53.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for pytest-timeout using our own timeout method. This timeout method can be enable via the option ``trio_timeout`` in ``pytest.ini`` and will print structured tracebacks of all tasks running when the timeout happened.

pytest_trio/plugin.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""pytest-trio implementation."""
2+
from __future__ import annotations
23
import sys
34
from functools import wraps, partial
45
from collections.abc import Coroutine, Generator
@@ -11,6 +12,8 @@
1112
from trio.abc import Clock, Instrument
1213
from trio.testing import MockClock
1314
from _pytest.outcomes import Skipped, XFailed
15+
# pytest_timeout_set_timer needs to be imported here for pluggy
16+
from .timeout import set_timeout, pytest_timeout_set_timer as pytest_timeout_set_timer
1417

1518
if sys.version_info[:2] < (3, 11):
1619
from exceptiongroup import BaseExceptionGroup
@@ -41,6 +44,12 @@ def pytest_addoption(parser):
4144
type="bool",
4245
default=False,
4346
)
47+
parser.addini(
48+
"trio_timeout",
49+
"should pytest-trio handle timeouts on async functions?",
50+
type="bool",
51+
default=False,
52+
)
4453
parser.addini(
4554
"trio_run",
4655
"what runner should pytest-trio use? [trio, qtrio]",
@@ -404,6 +413,9 @@ async def _bootstrap_fixtures_and_run_test(**kwargs):
404413
contextvars_ctx = contextvars.copy_context()
405414
contextvars_ctx.run(canary.set, "in correct context")
406415

416+
if item is not None:
417+
set_timeout(item)
418+
407419
async with trio.open_nursery() as nursery:
408420
for fixture in test.register_and_collect_dependencies():
409421
nursery.start_soon(

pytest_trio/timeout.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
from typing import Optional
3+
import warnings
4+
import threading
5+
import trio
6+
import pytest
7+
import pytest_timeout
8+
from .traceback_format import format_recursive_nursery_stack
9+
10+
11+
pytest_timeout_settings = pytest.StashKey[pytest_timeout.Settings]()
12+
send_timeout_callable = None
13+
send_timeout_callable_ready_event = threading.Event()
14+
15+
16+
def set_timeout(item: pytest.Item) -> None:
17+
try:
18+
settings = item.stash[pytest_timeout_settings]
19+
except KeyError:
20+
# No timeout or not our timeout
21+
return
22+
23+
if settings.func_only:
24+
warnings.warn(
25+
"Function only timeouts are not supported for trio based timeouts"
26+
)
27+
28+
global send_timeout_callable
29+
30+
# Shouldn't be racy, as xdist uses different processes
31+
if send_timeout_callable is None:
32+
threading.Thread(target=trio_timeout_thread, daemon=True).start()
33+
34+
send_timeout_callable_ready_event.wait()
35+
36+
send_timeout_callable(settings.timeout)
37+
38+
39+
@pytest.hookimpl()
40+
def pytest_timeout_set_timer(
41+
item: pytest.Item, settings: pytest_timeout.Settings
42+
) -> Optional[bool]:
43+
if item.get_closest_marker("trio") is not None and item.config.getini(
44+
"trio_timeout"
45+
):
46+
item.stash[pytest_timeout_settings] = settings
47+
return True
48+
49+
50+
# No need for pytest_timeout_cancel_timer as we detect that the test loop has exited
51+
52+
53+
def trio_timeout_thread():
54+
async def run_timeouts():
55+
async with trio.open_nursery() as nursery:
56+
token = trio.lowlevel.current_trio_token()
57+
58+
async def wait_timeout(token: trio.TrioToken, timeout: float) -> None:
59+
await trio.sleep(timeout)
60+
61+
try:
62+
token.run_sync_soon(
63+
lambda: trio.lowlevel.spawn_system_task(execute_timeout)
64+
)
65+
except RuntimeError:
66+
# test has finished
67+
pass
68+
69+
def send_timeout(timeout: float):
70+
test_token = trio.lowlevel.current_trio_token()
71+
token.run_sync_soon(
72+
lambda: nursery.start_soon(wait_timeout, test_token, timeout)
73+
)
74+
75+
global send_timeout_callable
76+
send_timeout_callable = send_timeout
77+
send_timeout_callable_ready_event.set()
78+
79+
await trio.sleep_forever()
80+
81+
trio.run(run_timeouts)
82+
83+
84+
async def execute_timeout() -> None:
85+
if pytest_timeout.is_debugging():
86+
return
87+
88+
nursery = get_test_nursery()
89+
stack = "\n".join(format_recursive_nursery_stack(nursery) + ["Timeout reached"])
90+
91+
async def report():
92+
pytest.fail(stack, pytrace=False)
93+
94+
nursery.start_soon(report)
95+
96+
97+
def get_test_nursery() -> trio.Nursery:
98+
task = trio.lowlevel.current_task().parent_nursery.parent_task
99+
100+
for nursery in task.child_nurseries:
101+
for task in nursery.child_tasks:
102+
if task.name.startswith("pytest_trio.plugin._trio_test_runner_factory"):
103+
return task.child_nurseries[0]
104+
105+
raise Exception("Could not find test nursery")

pytest_trio/traceback_format.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
from trio.lowlevel import Task
3+
from itertools import chain
4+
import traceback
5+
6+
7+
def format_stack_for_task(task: Task, prefix: str) -> list[str]:
8+
stack = list(task.iter_await_frames())
9+
10+
nursery_waiting_children = False
11+
12+
for i, (frame, lineno) in enumerate(stack):
13+
if frame.f_code.co_name == "_nested_child_finished":
14+
stack = stack[: i - 1]
15+
nursery_waiting_children = True
16+
break
17+
if frame.f_code.co_name == "wait_task_rescheduled":
18+
stack = stack[:i]
19+
break
20+
if frame.f_code.co_name == "checkpoint":
21+
stack = stack[:i]
22+
break
23+
24+
stack = (frame for frame in stack if "__tracebackhide__" not in frame[0].f_locals)
25+
26+
ss = traceback.StackSummary.extract(stack)
27+
formated_traceback = list(
28+
map(lambda x: prefix + x[2:], "".join(ss.format()).splitlines())
29+
)
30+
31+
if nursery_waiting_children:
32+
formated_traceback.append(prefix + "Awaiting completion of children")
33+
formated_traceback.append(prefix)
34+
35+
return formated_traceback
36+
37+
38+
def format_task(task: Task, prefix: str = "") -> list[str]:
39+
lines = []
40+
41+
subtasks = list(
42+
chain(*(child_nursery.child_tasks for child_nursery in task.child_nurseries))
43+
)
44+
45+
if subtasks:
46+
trace_prefix = prefix + "│"
47+
else:
48+
trace_prefix = prefix + " "
49+
50+
lines.extend(format_stack_for_task(task, trace_prefix))
51+
52+
for i, subtask in enumerate(subtasks):
53+
if (i + 1) != len(subtasks):
54+
lines.append(f"{prefix}{subtask.name}")
55+
lines.extend(format_task(subtask, prefix=f"{prefix}│ "))
56+
else:
57+
lines.append(f"{prefix}{subtask.name}")
58+
lines.extend(format_task(subtask, prefix=f"{prefix} "))
59+
60+
return lines
61+
62+
63+
def format_recursive_nursery_stack(nursery) -> list[str]:
64+
stack = []
65+
66+
for task in nursery.child_tasks:
67+
stack.append(task.name)
68+
stack.extend(format_task(task))
69+
70+
return stack

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"trio >= 0.22.0", # for ExceptionGroup support
2020
"outcome >= 1.1.0",
2121
"pytest >= 7.2.0", # for ExceptionGroup support
22+
"pytest_timeout",
2223
],
2324
keywords=[
2425
"async",

0 commit comments

Comments
 (0)