Source code for ralph.verify_timeout

"""Test timeout enforcement wrapper.

This module runs a pytest suite with per-test and full-suite timeout limits.
Per-test limit is ``DEFAULT_TEST_TIMEOUT_SECONDS`` (1 s); suite limit is
``DEFAULT_SUITE_TIMEOUT_SECONDS`` (30 s). A test that exceeds these limits is
a design defect — fix the production coupling, not the timeout.
"""

from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, cast

from ralph.executor.process import (
    TIMEOUT_EXIT_CODE,
    ProcessResult,
    ProcessRunOptions,
    run_process,
)

DEFAULT_TEST_TIMEOUT_SECONDS = 1.0
DEFAULT_SUITE_TIMEOUT_SECONDS = 120.0
TEST_TIMEOUT_ENV = "RALPH_PYTEST_TEST_TIMEOUT_SECONDS"
SUITE_TIMEOUT_ENV = "RALPH_PYTEST_SUITE_TIMEOUT_SECONDS"

if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence


_POLICY_FIX_MESSAGE = (
    "\n"
    "╔══════════════════════════════════════════════════════════════════════╗\n"
    "║         POLICY VIOLATION — ACTION REQUIRED FOR AI AGENTS            ║\n"
    "╚══════════════════════════════════════════════════════════════════════╝\n"
    "\n"
    "YOU MUST fix the slow test(s). Do NOT raise the limit. Do NOT skip or\n"
    "quarantine to hide the problem. A slow test is a design defect.\n"
    "\n"
    "Limits:\n"
    "  Per-test  : 1 second   (RALPH_PYTEST_TEST_TIMEOUT_SECONDS)\n"
    "  Full suite: 30 seconds (RALPH_PYTEST_SUITE_TIMEOUT_SECONDS)\n"
    "\n"
    "HOW TO FIX\n"
    "----------\n"
    "1. Replace real I/O with fakes.\n"
    "   Filesystem   → MemoryWorkspace or tmp_path fixture\n"
    "   Subprocesses → MockProcessExecutor or monkeypatch\n"
    "   Network/HTTP → patch at the boundary; never real sockets\n"
    "\n"
    "2. Eliminate sleep() and real wall-clock waits.\n"
    "   Inject a clock abstraction and advance it in the test.\n"
    "   Never call time.sleep(), asyncio.sleep(), or Event.wait() in tests.\n"
    "\n"
    "3. Refactor production code for testability.\n"
    "   If the production path forces real I/O, that is a coupling problem.\n"
    "   Extract I/O behind an interface; inject a fast fake in the test.\n"
    "   Do the refactor — do not work around it.\n"
    "\n"
    "4. Assert on observable behavior, not implementation internals.\n"
    "   If changing the implementation (without changing behavior) breaks a\n"
    "   test, the test is wrong. Rewrite it to test the public contract.\n"
    "\n"
    "DONE when ALL hold:\n"
    "  [ ] Every test finishes in < 1 s\n"
    "  [ ] Full suite finishes in < 30 s wall-clock\n"
    "  [ ] No test calls sleep() or polls real wall-clock time\n"
    "  [ ] No test crosses a real I/O boundary\n"
    "  [ ] Every assertion targets observable behavior\n"
    "\n"
    "Full policy: ralph/verify_timeout.py module docstring\n"
    "         or: docs/agents/testing-guide.md  §'Test Performance Policy'\n"
)


[docs] class SuiteTimeoutError(RuntimeError): def __init__(self, timeout_seconds: float) -> None: super().__init__( f"Test suite exceeded the {timeout_seconds}s wall-clock limit.\n{_POLICY_FIX_MESSAGE}" ) self.timeout_seconds = timeout_seconds
def timeout_seconds_from_env(name: str, default: float) -> float: raw_value = os.getenv(name) if raw_value is None: return default return float(raw_value) def build_timeout_env( *, base_env: Mapping[str, str] | None = None, test_timeout_seconds: float = DEFAULT_TEST_TIMEOUT_SECONDS, suite_timeout_seconds: float = DEFAULT_SUITE_TIMEOUT_SECONDS, ) -> dict[str, str]: env = dict(base_env or os.environ) env[TEST_TIMEOUT_ENV] = str(test_timeout_seconds) env[SUITE_TIMEOUT_ENV] = str(suite_timeout_seconds) return env def run_command_with_timeout( command: Sequence[str], *, cwd: Path, env: Mapping[str, str] | None = None, suite_timeout_seconds: float = DEFAULT_SUITE_TIMEOUT_SECONDS, capture_output: bool = True, ) -> ProcessResult: cmd = tuple(command) result = run_process( cmd[0], cmd[1:], options=ProcessRunOptions( cwd=cwd, env=dict(env) if env is not None else None, timeout=suite_timeout_seconds, capture_output=capture_output, ), ) if result.returncode == TIMEOUT_EXIT_CODE: raise SuiteTimeoutError(suite_timeout_seconds) return result def _parse_args(argv: Sequence[str]) -> tuple[float, list[str]]: parser = argparse.ArgumentParser() parser.add_argument("--suite-timeout", type=float, default=DEFAULT_SUITE_TIMEOUT_SECONDS) parser.add_argument("command", nargs=argparse.REMAINDER) parsed = parser.parse_args(list(argv)) suite_timeout = cast("float", parsed.suite_timeout) command = cast("list[str]", parsed.command) if command and command[0] == "--": command = command[1:] if not command: raise SystemExit("No command provided") return suite_timeout, command def main(argv: Sequence[str] | None = None) -> int: suite_timeout_seconds, command = _parse_args(argv or sys.argv[1:]) test_timeout_seconds = timeout_seconds_from_env(TEST_TIMEOUT_ENV, DEFAULT_TEST_TIMEOUT_SECONDS) env = build_timeout_env( test_timeout_seconds=test_timeout_seconds, suite_timeout_seconds=suite_timeout_seconds, ) try: result = run_command_with_timeout( command, cwd=Path.cwd(), env=env, suite_timeout_seconds=suite_timeout_seconds, capture_output=False, ) except SuiteTimeoutError as exc: print(str(exc), file=sys.stderr) return 124 return result.returncode if __name__ == "__main__": raise SystemExit(main())