Source code for ralph.testing.pytest_timeout_plugin
"""Pytest plugin enforcing Ralph's hard suite wall-clock timeout.
This plugin complements the per-test timeout in ``tests/conftest.py`` by
starting a session-wide watchdog in the controller process. Unlike cooperative
session timeout plugins, the watchdog terminates descendant worker processes and
exits the pytest process once the suite deadline is exceeded.
"""
from __future__ import annotations
import os
import sys
import threading
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
import psutil
from ralph.verify_timeout import (
DEFAULT_SUITE_TIMEOUT_SECONDS,
SUITE_TIMEOUT_ENV,
SuiteTimeoutError,
timeout_seconds_from_env,
)
if TYPE_CHECKING:
import pytest
_WATCHDOG_ATTR = "_ralph_suite_watchdog"
_TIMEOUT_EXIT_CODE = 124
@dataclass
class _SuiteWatchdog:
cancel_event: threading.Event
thread: threading.Thread
def _is_xdist_worker(config: pytest.Config) -> bool:
return hasattr(config, "workerinput")
def _emit_timeout_message(timeout_seconds: float) -> None:
message = str(SuiteTimeoutError(timeout_seconds))
sys.stdout.flush()
sys.stderr.write(f"{message}\n")
sys.stderr.flush()
def _terminate_descendants() -> None:
try:
current_process = psutil.Process(os.getpid())
descendants = current_process.children(recursive=True)
except psutil.Error:
return
for descendant in descendants:
try:
descendant.terminate()
except psutil.Error:
continue
_gone, alive = psutil.wait_procs(descendants, timeout=0.2)
for descendant in alive:
try:
descendant.kill()
except psutil.Error:
continue
psutil.wait_procs(alive, timeout=0.2)
def _watchdog_main(timeout_seconds: float, cancel_event: threading.Event) -> None:
if cancel_event.wait(timeout_seconds):
return
_emit_timeout_message(timeout_seconds)
_terminate_descendants()
os._exit(_TIMEOUT_EXIT_CODE)
[docs]
def pytest_sessionstart(session: pytest.Session) -> None:
"""Start the controller-only watchdog that enforces the suite wall-clock cap."""
config = session.config
if _is_xdist_worker(config):
return
timeout_seconds = timeout_seconds_from_env(SUITE_TIMEOUT_ENV, DEFAULT_SUITE_TIMEOUT_SECONDS)
if timeout_seconds <= 0:
return
cancel_event = threading.Event()
thread = threading.Thread(
target=_watchdog_main,
args=(timeout_seconds, cancel_event),
name="ralph-pytest-suite-watchdog",
daemon=True,
)
setattr(config, _WATCHDOG_ATTR, _SuiteWatchdog(cancel_event=cancel_event, thread=thread))
thread.start()
[docs]
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""Cancel the suite watchdog when pytest finishes normally."""
del exitstatus
watchdog = cast("_SuiteWatchdog | None", getattr(session.config, _WATCHDOG_ATTR, None))
if watchdog is None:
return
watchdog.cancel_event.set()
watchdog.thread.join(timeout=0.1)