1414# in the MAIN thread during a test's *call* phase, so a deadlock in a WORKER
1515# thread, a fixture, collection, or a C-level socket read the main thread never
1616# returns from stalls the whole `mise run build` silently — up to the platform's
17- # 3600s build-verify ceiling (the ECS-only stall on ABCA-684/686/688, and again
18- # on ABCA-707: 40+ min of dead air, container never reaped).
17+ # 3600s build-verify ceiling (the ECS-only stall on ABCA-684/686/688, and the
18+ # scoped-session S3 hang the _clean_env reset below guards against: 40+ min of
19+ # dead air, container never reaped).
1920#
2021# The obvious instrument — ``faulthandler.dump_traceback_later(1200, exit=True)``
2122# — does NOT work here: faulthandler has a SINGLE internal timer, and pytest's
2223# ``faulthandler_timeout`` (pyproject.toml) RE-ARMS it at the start of every test
2324# WITHOUT ``exit=True``. So a session-level exit timer is cancelled by the first
24- # test, the per-test timer only DUMPS, and the suite hangs forever anyway
25- # (exactly what happened on ABCA-707: a 300s dump fired, no exit followed).
25+ # test, the per-test timer only DUMPS, and the suite hangs forever anyway.
2626#
2727# So own the reaper on a dedicated daemon thread pytest cannot touch. A blocked
28- # socket read (the ABCA-707 failure mode) releases the GIL, so this thread runs;
29- # it dumps every thread's stack for diagnosis and then HARD-EXITS the process, so
30- # `mise run build` returns non-zero within seconds of the deadline instead of
31- # burning to the 3600s ceiling. Deadline 600s: ~200x the whole suite's normal
32- # ~3s… well above any legitimate run (per-test cap is 300s) yet far under the
33- # build ceiling.
28+ # socket read releases the GIL, so this thread runs; it dumps every thread's
29+ # stack for diagnosis and then HARD-EXITS the process, so `mise run build`
30+ # returns non-zero within seconds of the deadline instead of burning to the
31+ # ceiling. Deadline 600s: a SESSION backstop for the whole-suite hangs SIGALRM
32+ # can't interrupt — sized well above the longest healthy run (the suite normally
33+ # finishes in seconds; the per-test pytest-timeout cap is 120s, see
34+ # pyproject.toml) yet far under the 3600s build-verify ceiling.
35+ #
36+ # ``pytest_sessionfinish`` cancels the timer on a clean finish (below), so a
37+ # legitimately slow-but-passing run that lands near 600s — e.g. still in teardown
38+ # / coverage write — is NOT hard-exited into a bewildering red (#616 review N2).
39+ # ``os._exit`` skips atexit + buffer flush, so it must only fire on a TRUE hang.
3440_HANG_REAP_DEADLINE_S = 600
3541
3642
@@ -52,6 +58,17 @@ def _reap_on_hang() -> None:
5258_hang_watchdog .start ()
5359
5460
61+ def pytest_sessionfinish (session , exitstatus ):
62+ """Cancel the hang watchdog on a clean session finish (#616 review N2).
63+
64+ Without this, a legitimately slow-but-passing suite that finishes just after
65+ the 600s deadline (e.g. during teardown / coverage write) would be hard-exited
66+ by ``_reap_on_hang`` and turn green red with a thread-dump uncorrelated to any
67+ failed test. ``Timer.cancel()`` is a no-op if the timer already fired (a true
68+ hang), so this only prevents the false-positive kill."""
69+ _hang_watchdog .cancel ()
70+
71+
5572class FakeRunCmd :
5673 """Shared fake for ``shell.run_cmd``: records argv and returns scripted results.
5774
@@ -139,15 +156,9 @@ def make_task_config(**overrides) -> TaskConfig:
139156 "LOG_GROUP_NAME" ,
140157 "MEMORY_ID" ,
141158 "ENABLE_CLI_TELEMETRY" ,
142- # Per-session IAM scoping (PR #209). When this is set, ``aws_session`` resolves
143- # a *scoped* session and ``tenant_client`` returns ``session.client(...)`` —
144- # which BYPASSES a ``@patch("boto3.client")`` mock. On the ECS substrate the
145- # task def sets this, so a test that mocks ``boto3.client`` (e.g.
146- # ``test_attachments``) instead makes a REAL S3 call that hangs on the network
147- # (no egress). Stripping it here forces every test onto the unscoped path,
148- # where the ``boto3.client`` mock actually intercepts. Resetting the session
149- # cache below is NOT enough on its own — a fresh ``get_session()`` re-resolves
150- # as scoped while this var is still set. (Live-caught on ABCA-707, 2026-07-15.)
159+ # Per-session IAM scoping (PR #209) — the scoped-session S3-hang guard.
160+ # See the ``_clean_env`` docstring below for the full rationale (why the
161+ # env strip AND the cache reset are both required).
151162 "AGENT_SESSION_ROLE_ARN" ,
152163]
153164
@@ -156,9 +167,6 @@ def make_task_config(**overrides) -> TaskConfig:
156167def _clean_env (monkeypatch ):
157168 """Remove agent-related env vars and reset the AWS session cache each test.
158169
159- The env cleanup keeps host state from leaking into agent code that reads
160- ``os.environ`` at import/call time.
161-
162170 The env cleanup + session reset TOGETHER close a scoped-session leak that
163171 hangs the suite on the ECS substrate: ``aws_session`` caches the resolved
164172 boto3 session in a MODULE GLOBAL (``_session``/``_scoped``), and
@@ -171,7 +179,7 @@ def _clean_env(monkeypatch):
171179 the var gone AND the cache reset, every test resolves the unscoped path where
172180 its ``boto3.client`` mock intercepts. Otherwise a mocked test (e.g.
173181 ``test_attachments``) makes a REAL S3 call that hangs on the ECS network
174- (no egress) in a socket read SIGALRM can't interrupt. (Live-caught ABCA-707.)
182+ (no egress) in a socket read SIGALRM can't interrupt.
175183 """
176184 for var in _AGENT_ENV_VARS :
177185 monkeypatch .delenv (var , raising = False )
0 commit comments