Skip to content

Commit 527fd44

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 527fd44

File tree

7 files changed

+214
-0
lines changed

7 files changed

+214
-0
lines changed

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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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("trio_timeout"):
44+
item.stash[pytest_timeout_settings] = settings
45+
return True
46+
47+
48+
# No need for pytest_timeout_cancel_timer as we detect that the test loop has exited
49+
50+
51+
def trio_timeout_thread():
52+
async def run_timeouts():
53+
async with trio.open_nursery() as nursery:
54+
token = trio.lowlevel.current_trio_token()
55+
56+
async def wait_timeout(token: trio.TrioToken, timeout: float) -> None:
57+
await trio.sleep(timeout)
58+
59+
try:
60+
token.run_sync_soon(
61+
lambda: trio.lowlevel.spawn_system_task(execute_timeout)
62+
)
63+
except RuntimeError:
64+
# test has finished
65+
pass
66+
67+
def send_timeout(timeout: float):
68+
test_token = trio.lowlevel.current_trio_token()
69+
token.run_sync_soon(
70+
lambda: nursery.start_soon(wait_timeout, test_token, timeout)
71+
)
72+
73+
global send_timeout_callable
74+
send_timeout_callable = send_timeout
75+
send_timeout_callable_ready_event.set()
76+
77+
await trio.sleep_forever()
78+
79+
trio.run(run_timeouts)
80+
81+
82+
async def execute_timeout() -> None:
83+
if pytest_timeout.is_debugging():
84+
return
85+
86+
nursery = get_test_nursery()
87+
stack = "\n".join(format_recursive_nursery_stack(nursery) + ["Timeout reached"])
88+
89+
async def report():
90+
pytest.fail(stack, pytrace=False)
91+
92+
nursery.start_soon(report)
93+
94+
95+
def get_test_nursery() -> trio.Nursery:
96+
task = trio.lowlevel.current_task().parent_nursery.parent_task
97+
98+
for nursery in task.child_nurseries:
99+
for task in nursery.child_tasks:
100+
if task.name.startswith("pytest_trio.plugin._trio_test_runner_factory"):
101+
return task.child_nurseries[0]
102+
103+
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)