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.
This commit is contained in:
Vito Sansevero
2026-10-26 05:58:16 -07:00
parent 3be0ada6a1
commit 5379fe8b94
3 changed files with 359 additions and 1 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ repos:
- id: tests
name: tests
entry: python -m unittest discover tests/ -v
entry: python -m tests.run_suite -q
language: system
pass_filenames: false
stages: [pre-push]
+138
View File
@@ -0,0 +1,138 @@
"""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 exit codes,
including 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 module teardown and
interpreter exit. It is never cancelled. 120 s stays below asyncio's 300 s
wait for default-executor threads on Python 3.12 and 3.13, 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 in that test's teardown or event-loop
shutdown; 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.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):
stream.write(f"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,
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())
+220
View File
@@ -0,0 +1,220 @@
"""Tests for tests/run_suite.py, the CI test runner and its hang watchdog (#350).
Each test writes a temporary folder of tiny test modules and runs
``python -m tests.run_suite -s <folder> -v`` in a subprocess from the repo
root. Every subprocess call has a timeout, so a broken watchdog fails this
file instead of hanging the suite. Probes that should hang wait a bounded
time, so a watchdog that never fires shows up as a plain assertion failure.
"""
import os
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SUBPROCESS_TIMEOUT = 60
WATCHDOG_ENV = ("PM_TEST_HANG_LIMIT", "PM_TEST_SHUTDOWN_LIMIT")
def run_suite(folder, extra_env=None, *args):
"""Run the runner on ``folder`` and return the completed process."""
env = {k: v for k, v in os.environ.items() if k not in WATCHDOG_ENV}
env.update(extra_env or {})
return subprocess.run(
[sys.executable, "-m", "tests.run_suite", "-s", str(folder), "-v", *args],
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=SUBPROCESS_TIMEOUT,
)
class RunSuiteTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="pm-run-suite-")
self.addCleanup(self._tmp.cleanup)
self.folder = Path(self._tmp.name)
def write(self, name, source):
(self.folder / name).write_text(textwrap.dedent(source), encoding="utf-8")
def assertDumped(self, result, test_id):
detail = result.stderr[-4000:]
self.assertNotEqual(result.returncode, 0, detail)
self.assertIn(f"start: {test_id}", result.stderr, detail)
self.assertIn("Timeout (", result.stderr, detail)
self.assertIn("Thread 0x", result.stderr, detail)
def test_passing_test_exits_zero_and_names_each_start(self):
self.write(
"test_probe_pass.py",
"""
import unittest
class Probe(unittest.TestCase):
def test_ok(self):
pass
""",
)
result = run_suite(self.folder)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("start: test_probe_pass.Probe.test_ok", result.stderr)
def test_start_line_is_written_at_quiet_verbosity_too(self):
self.write(
"test_probe_quiet.py",
"""
import unittest
class Probe(unittest.TestCase):
def test_ok(self):
pass
""",
)
result = run_suite(self.folder, None, "-q")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("start: test_probe_quiet.Probe.test_ok", result.stderr)
def test_failing_test_exits_one(self):
self.write(
"test_probe_fail.py",
"""
import unittest
class Probe(unittest.TestCase):
def test_fails(self):
self.fail("expected")
""",
)
result = run_suite(self.folder)
self.assertEqual(result.returncode, 1, result.stderr)
def test_pattern_option_selects_modules(self):
self.write(
"check_probe.py",
"""
import unittest
class Probe(unittest.TestCase):
def test_fails(self):
self.fail("selected by -p")
""",
)
self.assertEqual(run_suite(self.folder).returncode, 5)
result = run_suite(self.folder, None, "-p", "check_*.py")
self.assertEqual(result.returncode, 1, result.stderr)
def test_empty_folder_exits_five(self):
result = run_suite(self.folder)
self.assertEqual(result.returncode, 5, result.stderr)
def test_repo_root_modules_import_as_with_unittest_discover(self):
self.write(
"test_probe_import.py",
"""
import unittest
from utils.hashing import generate_prompt_hash
class Probe(unittest.TestCase):
def test_import(self):
same = generate_prompt_hash(" A ") == generate_prompt_hash("a")
self.assertTrue(same)
""",
)
result = run_suite(self.folder)
self.assertEqual(result.returncode, 0, result.stderr)
def test_hung_test_dumps_every_thread_and_exits(self):
self.write(
"test_probe_hang.py",
"""
import time
import unittest
class Probe(unittest.TestCase):
def test_hangs(self):
time.sleep(30)
""",
)
result = run_suite(self.folder, {"PM_TEST_HANG_LIMIT": "2"})
self.assertDumped(result, "test_probe_hang.Probe.test_hangs")
def test_hang_in_async_teardown_dumps_and_exits(self):
self.write(
"test_probe_teardown.py",
"""
import threading
import unittest
class Probe(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self):
threading.Event().wait(30)
async def test_body_passes(self):
pass
""",
)
result = run_suite(self.folder, {"PM_TEST_HANG_LIMIT": "2"})
self.assertDumped(result, "test_probe_teardown.Probe.test_body_passes")
def test_hang_in_event_loop_shutdown_after_ok_dumps_and_exits(self):
# The #349 shape: the test passes ("ok" is printed), then the loop's
# shutdown waits for a default-executor thread that never finishes.
self.write(
"test_probe_loop.py",
"""
import asyncio
import threading
import unittest
class Probe(unittest.IsolatedAsyncioTestCase):
async def test_leaves_executor_busy(self):
loop = asyncio.get_running_loop()
loop.run_in_executor(None, threading.Event().wait, 30)
""",
)
result = run_suite(self.folder, {"PM_TEST_HANG_LIMIT": "2"})
self.assertDumped(result, "test_probe_loop.Probe.test_leaves_executor_busy")
start = result.stderr.index("start: test_probe_loop")
self.assertIn("ok", result.stderr[start:], result.stderr[-4000:])
def test_hang_at_interpreter_exit_dumps_and_exits(self):
self.write(
"test_probe_exit.py",
"""
import threading
import unittest
class Probe(unittest.TestCase):
def test_leaves_thread(self):
threading.Thread(target=threading.Event().wait, args=(30,)).start()
""",
)
result = run_suite(self.folder, {"PM_TEST_SHUTDOWN_LIMIT": "2"})
detail = result.stderr[-4000:]
self.assertNotEqual(result.returncode, 0, detail)
self.assertIn("ok", result.stderr, detail)
self.assertIn("Timeout (", result.stderr, detail)
def test_invalid_limit_is_a_usage_error(self):
for name in WATCHDOG_ENV:
for value in ("abc", "0", "-3", "1.5"):
with self.subTest(name=name, value=value):
result = run_suite(self.folder, {name: value})
self.assertEqual(result.returncode, 2, result.stderr)
lines = result.stderr.strip().splitlines()
self.assertEqual(len(lines), 1, result.stderr)
self.assertIn(name, lines[0])
self.assertIn(repr(value), lines[0])
if __name__ == "__main__":
unittest.main()