* test: run the suite through a watchdog that names a hang (#350) tests/run_suite.py discovers the same tests as unittest discover -s tests, with the same sys.path and exit codes (5 when none ran), writes 'start: <test id>' to stderr before each test and arms faulthandler.dump_traceback_later: 120 s per test (PM_TEST_HANG_LIMIT) and 120 s after the run (PM_TEST_SHUTDOWN_LIMIT), never cancelled. A hang in a test, its teardown, its event-loop shutdown or interpreter exit prints every thread's stack and exits non-zero instead of running to the job limit. The pre-push hook now runs it too. * ci: bound every job and run the Python suite through the watchdog (#350) Every job in test.yml and code-quality.yml gets timeout-minutes = ceil(1.5 x slowest of three green main runs) + 3, min 5, with the runs and times written above jobs:. The matrix and the coverage gate run python -m tests.run_suite. ci-workflow.test.mjs checks every job in the three CI workflows has a limit and that the matrix and coverage use the runner. Job names are unchanged. * docs: document the test runner, time limits and following a push (#350) * fix(test): keep unittest's warning filters and tighten the runner's edges (#350) Review follow-ups: the runner now shows warnings as unittest.main does (DeprecationWarning and ResourceWarning were hidden), rejects non-ASCII digit limits with the one-line exit-2 error, and starts each 'start:' line on its own line at default verbosity. The docs now say that class and module fixtures count toward the test before them, that the shutdown limit covers interpreter exit, and that exit 5 on zero tests is 3.12+ behaviour applied on every version. * test: decode the runner's stderr as UTF-8 on Windows too (#350) A child Python writes a piped stderr in the ANSI codepage on Windows, so the non-ASCII limit value in the usage-error test came back as U+FFFD. The helper now sets PYTHONIOENCODING=utf-8 for the child. * test(history): give the writer's non-blocking checks a 1 s ceiling (#350) py3.13 on macos-latest failed on PR #351 with writer.close(0) taking 0.186 s against a 0.1 s ceiling while another thread held the commit. The blocked path these checks rule out waits up to 3 s (release.wait(3)), so a 1 s ceiling still tells returning at once from waiting for the release, without failing on a loaded shared runner (TESTING.md, #242). * test(restore): wait for the cancelled preview's worker before teardown (#350) py3.13 on ubuntu-latest failed on PR #351 (run 40334794505) with 'Files still open at teardown: prompts.db, prompts.db-shm' in test_cancel_while_reading_the_live_library. Cancelling the preview cannot stop its executor thread, which goes on to open the live library; the test returned without waiting, so that open could land between teardown's close_all_instances() and assert_closed(). The test now waits for the worker, as test_cancel_while_staging already does.
148 lines
5.3 KiB
Python
148 lines
5.3 KiB
Python
"""Run the Python suite with a hang watchdog: ``python -m tests.run_suite``.
|
|
|
|
Why: on PR #349 a macOS job stopped inside the test suite and sat silent until
|
|
GitHub cancelled it after six hours, with no stack to read. This runner does
|
|
the same discovery as ``python -m unittest discover -s tests`` (same
|
|
``sys.path`` when started with ``-m`` from the repo root, same warning
|
|
filters, and the exit codes of Python 3.12+ on every version: 0, 1, and 5
|
|
when no tests ran), and adds a watchdog built on
|
|
``faulthandler.dump_traceback_later``: when the timer is not re-armed in time,
|
|
every thread's stack goes to stderr and the process exits non-zero. It works
|
|
the same on Linux, macOS and Windows.
|
|
|
|
The timer is armed before discovery, re-armed at the start of every test with
|
|
PM_TEST_HANG_LIMIT seconds (default 120), and re-armed when the run ends with
|
|
PM_TEST_SHUTDOWN_LIMIT seconds (default 120) to cover interpreter exit
|
|
(non-daemon threads, atexit handlers, coverage's save). It is never
|
|
cancelled. Class and module fixtures (setUpClass, tearDownModule, ...) run
|
|
between two ``start:`` lines, so they count toward the test before them.
|
|
120 s stays below asyncio's 300 s wait for default-executor threads on Python
|
|
3.12 and 3.13 (3.10 and 3.11 wait without limit), so a hang in an
|
|
``IsolatedAsyncioTestCase`` loop shutdown is dumped inside the test that hung.
|
|
|
|
Reading a dump: the last ``start: <test id>`` line names the test. If its
|
|
"ok" line comes after it, the hang is after the test body: its teardown or
|
|
event-loop shutdown, or a class/module fixture before the next test (the
|
|
stack shows which). A dump after the summary is a hang at interpreter exit
|
|
(a non-daemon thread or an atexit handler).
|
|
|
|
A limit is a measurement, not a dial: change it only with new measurements
|
|
written next to it (TESTING.md "Time limits and hangs").
|
|
"""
|
|
|
|
import argparse
|
|
import faulthandler
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
DEFAULT_PER_TEST_LIMIT = 120
|
|
DEFAULT_SHUTDOWN_LIMIT = 120
|
|
NO_TESTS_EXIT = 5
|
|
USAGE_EXIT = 2
|
|
|
|
|
|
class LimitError(ValueError):
|
|
"""An environment limit is not a positive integer."""
|
|
|
|
|
|
def read_limit(name, default):
|
|
"""Return the positive integer in env var ``name``, or ``default``."""
|
|
raw = os.environ.get(name)
|
|
if raw is None:
|
|
return default
|
|
text = raw.strip()
|
|
if not (text.isascii() and text.isdigit()) or int(text) <= 0:
|
|
raise LimitError(f"run_suite: {name} must be a positive integer, got {raw!r}")
|
|
return int(text)
|
|
|
|
|
|
def make_result_class(stream, per_test_limit, shutdown_limit):
|
|
"""Build a TextTestResult that names each test and re-arms the watchdog."""
|
|
|
|
def arm(seconds):
|
|
faulthandler.dump_traceback_later(seconds, exit=True, file=stream)
|
|
|
|
class WatchdogResult(unittest.TextTestResult):
|
|
def startTest(self, test):
|
|
# At default verbosity the progress dots share a line; start ours.
|
|
prefix = "\n" if self.dots else ""
|
|
stream.write(f"{prefix}start: {test.id()}\n")
|
|
stream.flush()
|
|
super().startTest(test)
|
|
arm(per_test_limit)
|
|
|
|
def stopTestRun(self):
|
|
super().stopTestRun()
|
|
arm(shutdown_limit)
|
|
|
|
return WatchdogResult
|
|
|
|
|
|
def parse_args(argv):
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m tests.run_suite",
|
|
description="Discover and run the tests with a hang watchdog.",
|
|
)
|
|
# As in unittest, -v and -q share one setting and the last one wins.
|
|
parser.set_defaults(verbosity=1)
|
|
parser.add_argument(
|
|
"-v",
|
|
"--verbose",
|
|
dest="verbosity",
|
|
action="store_const",
|
|
const=2,
|
|
help="verbose output",
|
|
)
|
|
parser.add_argument(
|
|
"-q",
|
|
"--quiet",
|
|
dest="verbosity",
|
|
action="store_const",
|
|
const=0,
|
|
help="quiet output",
|
|
)
|
|
parser.add_argument(
|
|
"-s",
|
|
"--start-directory",
|
|
default=str(Path(__file__).resolve().parent),
|
|
help="directory to start discovery (default: this tests folder)",
|
|
)
|
|
parser.add_argument(
|
|
"-p", "--pattern", default="test*.py", help="pattern to match test files"
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv=None):
|
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
# Keep the real stderr: a test may replace sys.stderr, and the watchdog
|
|
# needs a stream with a file descriptor.
|
|
stream = sys.stderr
|
|
try:
|
|
per_test_limit = read_limit("PM_TEST_HANG_LIMIT", DEFAULT_PER_TEST_LIMIT)
|
|
shutdown_limit = read_limit("PM_TEST_SHUTDOWN_LIMIT", DEFAULT_SHUTDOWN_LIMIT)
|
|
except LimitError as error:
|
|
stream.write(f"{error}\n")
|
|
return USAGE_EXIT
|
|
|
|
faulthandler.dump_traceback_later(per_test_limit, exit=True, file=stream)
|
|
start = os.path.abspath(args.start_directory)
|
|
suite = unittest.TestLoader().discover(start, args.pattern, top_level_dir=start)
|
|
runner = unittest.TextTestRunner(
|
|
stream=stream,
|
|
verbosity=args.verbosity,
|
|
# As unittest.main does: show warnings unless -W/PYTHONWARNINGS set them.
|
|
warnings=None if sys.warnoptions else "default",
|
|
resultclass=make_result_class(stream, per_test_limit, shutdown_limit),
|
|
)
|
|
result = runner.run(suite)
|
|
if result.testsRun == 0 and not result.skipped:
|
|
return NO_TESTS_EXIT
|
|
return 0 if result.wasSuccessful() else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|