diff --git a/agent/pyproject.toml b/agent/pyproject.toml index a89e3ce4..a5608a8c 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -81,6 +81,7 @@ dev = [ "pytest", "pygments==2.20.0", "pytest-cov==7.1.0", + "pytest-timeout==2.4.0", # per-test wall-clock cap: a single hung test (network/subprocess/Bedrock without its own timeout) must fail LOUDLY with a traceback, not silently burn the whole build-verify budget (ABCA-684/686: one hang stalled the baseline build past its 3600s ceiling) "vulture==2.16", # dead-code detection (#282): unused functions/classes ruff F can't see ] @@ -122,6 +123,18 @@ ignore = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +# pytest-timeout: hard wall-clock cap PER TEST. A unit test that blocks (an +# unmocked network/subprocess/Bedrock call with no timeout of its own) otherwise +# hangs the whole `mise run build` until the 3600s build-verify ceiling kills it, +# turning one flaky test into an un-diagnosable 60-min stall (ABCA-684/686). With +# this, the offending test fails with a dumped stack in 120s and the suite moves on. +# method="signal" (SIGALRM) actually ABORTS the hung test — pytest runs +# single-threaded in the main thread here, so the alarm interrupts even a blocked +# C-level/socket call. method="thread" only PRINTS the stack and cannot interrupt a +# syscall-blocked test (proven live on ABCA-688: the thread method dumped the stack +# but the build kept hanging ~55 min until the 3600s ceiling). signal is the real fix. +timeout = 120 +timeout_method = "signal" [tool.coverage.run] branch = true diff --git a/agent/tests/test_aws_session.py b/agent/tests/test_aws_session.py index fd4e5562..96578d4d 100644 --- a/agent/tests/test_aws_session.py +++ b/agent/tests/test_aws_session.py @@ -131,7 +131,15 @@ def _slow_build(_arn: str) -> Any: return MagicMock(name="scoped") def _worker() -> None: - start.wait() + # Bounded barrier wait. A bare Barrier(20).wait() blocks FOREVER if + # even one of the 20 threads never arrives (e.g. a worker reaped under + # container memory pressure, or thread creation throttled) — every + # survivor then hangs here and the main thread hangs in join() below, + # stalling the whole `mise run build` until the 3600s ceiling. This is + # the ECS-only flaky hang chased across ABCA-684/686/688 (pytest-timeout + # only fixed the SYMPTOM; this Barrier is the ROOT cause). A timeout + # makes the barrier raise BrokenBarrierError so the test fails fast. + start.wait(timeout=30) session = get_session() with lock: results.append(session) @@ -140,8 +148,11 @@ def _worker() -> None: threads = [threading.Thread(target=_worker) for _ in range(20)] for t in threads: t.start() + # Bounded joins for the same reason — a worker that died before + # appending must not hang the suite; a leftover live thread trips the + # assertion below (results != 20) rather than blocking indefinitely. for t in threads: - t.join() + t.join(timeout=60) mk_build.assert_called_once() assert len(results) == 20 diff --git a/agent/uv.lock b/agent/uv.lock index bd23fcd8..bf07a0cb 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -159,6 +159,7 @@ dev = [ { name = "pygments" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-timeout" }, { name = "ruff" }, { name = "ty" }, { name = "vulture" }, @@ -184,6 +185,7 @@ dev = [ { name = "pygments", specifier = "==2.20.0" }, { name = "pytest" }, { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "ruff" }, { name = "ty" }, { name = "vulture", specifier = "==2.16" }, @@ -1833,6 +1835,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"