From f72791d75a9adc0d761276c302c0b04ea03c3a00 Mon Sep 17 00:00:00 2001 From: sensitron Date: Sat, 5 Sep 2026 00:30:01 +0200 Subject: [PATCH] feat: add opt-in hang diagnostics for tests/ui Issue #39: tests/ui occasionally hangs during a run -- no output, no crash, no progress -- instead of failing outright, and nobody has ever captured a stack from a hung run. The three prior sightings were killed by a 120s outer timeout before any diagnostic tool was even armed. tests/ui/conftest.py now arms faulthandler.dump_traceback_later() around every test item when MATTELOOP_TEST_HANG_DUMP_SECONDS is set, the same technique matteloop.logs already uses for native crashes in the shipped app. It mirrors pytest's own built-in faulthandler_timeout plugin (_pytest/faulthandler.py: pytest_configure/pytest_runtest_protocol), but is switched by an environment variable instead of an ini option so a single measurement run can turn it on without touching pyproject.toml, and it writes to a plain file instead of the duped stderr fd so the dump survives however a caller redirects the subprocess's own stdout/stderr. The dump file is opened once for the whole pytest session and kept open, not reopened per test: closing it right after cancelling a just-armed timer reproducibly segfaulted the interpreter here when the dump raced the close (reproduced at an artificially tiny 0.01s threshold). Matches the built-in plugin's own lifetime (dup'd once in pytest_configure, closed once in pytest_unconfigure). Off by default -- the normal suite runs unaffected, verified by a full green `uv run pytest -q` (1779 passed, 4 skipped, matching the existing baseline) with the switch untouched. Refs #39 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D5KtfjmhMjKKKPyE8brrHt --- tests/ui/conftest.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/ui/conftest.py diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 0000000..7a6c43b --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1,68 @@ +"""Optional hang diagnostics for the UI test suite. + +Issue #39: tests/ui occasionally hangs -- no output, no crash, no progress +-- rather than failing outright, and nobody has ever captured a stack from +one. MATTELOOP_TEST_HANG_DUMP_SECONDS arms faulthandler.dump_traceback_later() +around every test item, the same technique matteloop.logs already uses for +native crashes in the shipped app (see _enable_fault_reports there). It +mirrors pytest's own built-in faulthandler_timeout plugin +(_pytest/faulthandler.py): open the target file once for the whole session +rather than per test -- closing it while a dump from the previous test's +timer could still be in flight reproducibly segfaulted the interpreter here +-- and switch it by an environment variable rather than an ini option so it +can be turned on for a single measurement run without touching +pyproject.toml. Writing to a plain file instead of the duped stderr fd +means the dump survives however a caller redirects the subprocess's own +stdout/stderr. + +Off by default: the normal suite is unaffected by this file. +""" + +from __future__ import annotations + +import faulthandler +import os +from collections.abc import Generator +from pathlib import Path +from typing import TextIO + +import pytest + +_SECONDS_ENV = "MATTELOOP_TEST_HANG_DUMP_SECONDS" +_FILE_ENV = "MATTELOOP_TEST_HANG_DUMP_FILE" +_DEFAULT_FILE = "tests-ui-hang-dump.log" + +_dump_file: TextIO | None = None + + +def _configured_seconds() -> float | None: + raw = os.environ.get(_SECONDS_ENV, "").strip() + return float(raw) if raw else None + + +def pytest_configure(config: pytest.Config) -> None: + global _dump_file + if _configured_seconds() is None: + return + dump_path = Path(os.environ.get(_FILE_ENV, _DEFAULT_FILE)) + _dump_file = dump_path.open("a", buffering=1, encoding="utf-8") + + +def pytest_unconfigure(config: pytest.Config) -> None: + global _dump_file + if _dump_file is not None: + _dump_file.close() + _dump_file = None + + +@pytest.hookimpl(wrapper=True, trylast=True) +def pytest_runtest_protocol(item: pytest.Item) -> Generator[None, object, object]: + seconds = _configured_seconds() + if seconds is None or _dump_file is None: + return (yield) + _dump_file.write(f"\n--- armed for {item.nodeid} ({seconds}s) ---\n") + faulthandler.dump_traceback_later(seconds, file=_dump_file, exit=False) + try: + return (yield) + finally: + faulthandler.cancel_dump_traceback_later()