"""Tests for the wedged-gateway health probe + bounded escalation (#81642). A gateway whose event loop is stalled cannot process a graceful shutdown, so the updater's drain wait used to burn the full 180s budget ("Gateway PID X still running after 180.0s — restart may fail") and could deadlock `hermes update`. The fix probes the loop-liveness heartbeat file BEFORE draining and, only when the loop is provably dead, escalates SIGTERM → SIGKILL bounded to seconds. A busy-but-alive gateway (fresh heartbeat) must keep the full drain path — including the in-flight cron drain floor from #86684. """ import asyncio import json import os import shutil import socket import sys import tempfile import threading import time from pathlib import Path from unittest.mock import patch import pytest import hermes_cli.gateway as gateway_cli from gateway.shutdown_watchdog import ( get_loop_heartbeat_path, get_loop_tick_socket_path, loop_heartbeat_forever, write_loop_heartbeat, ) # Native Windows exposes neither ``socket.AF_UNIX`` nor an asyncio UNIX # server, so the witness cases that create real socket nodes # (``_silent_socket_node``) or run the real producer # (``loop_heartbeat_forever``) cannot execute there. Only those cases are # skipped: the witness-absent contracts (mocked probes, file-only # heartbeats) are platform-independent and keep running on Windows, per # the Windows behavior pinned alongside the product-side guarantee. _NEEDS_UNIX_SOCKETS = pytest.mark.skipif( sys.platform == "win32", reason="requires real UNIX-domain sockets " "(socket.AF_UNIX / asyncio.start_unix_server), unavailable on native Windows", ) @pytest.fixture() def tmp_path(): """Short-path override for this module (macOS AF_UNIX ~104-byte limit). The loop-tick witness tests bind real UNIX sockets under HERMES_HOME. pytest's default tmp_path nests deep enough on macOS that ``state/gateway.loop-tick..sock`` exceeds the sockaddr_un limit and ``bind()`` raises ``OSError: AF_UNIX path too long``. A mkdtemp directly under the system temp root keeps the socket path well under the limit on every platform. """ path = Path(tempfile.mkdtemp(prefix="hwg-")) try: yield path finally: shutil.rmtree(path, ignore_errors=True) def _write_heartbeat(home, pid, age_s=0.0): """Write a heartbeat file for ``pid`` whose mtime is ``age_s`` old.""" path = get_loop_heartbeat_path(home) write_loop_heartbeat(pid=pid, home=home) if age_s: stamp = time.time() - age_s os.utime(path, (stamp, stamp)) return path def _mark_witness_flag(home, armed, age_s=0.0): """Set ``loop_tick_socket`` on the heartbeat payload; re-stamp mtime.""" path = get_loop_heartbeat_path(home) payload = json.loads(path.read_text(encoding="utf-8")) payload["loop_tick_socket"] = armed path.write_text(json.dumps(payload), encoding="utf-8") if age_s: stamp = time.time() - age_s os.utime(path, (stamp, stamp)) return path def _silent_socket_node(path): """Create a socket node at ``path`` that never answers. Bind + listen, then close the listener WITHOUT unlinking: the node stays, so a probe's connect() gets ECONNREFUSED — a witness that exists but is silent, exactly like a dead listener (or a loop that stopped scheduling). """ path.parent.mkdir(parents=True, exist_ok=True) srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: srv.bind(str(path)) srv.listen(1) finally: srv.close() def _start_freezeable_producer(tmp_path, block_s, errors, write_stall_s=1.5): """Run the real heartbeat producer on a loop that can be frozen on demand. Returns ``(state, ready)``: - ``state["trigger"]`` — freezes the gateway loop synchronously for ``block_s`` seconds. While frozen, NO task runs on the loop — not the heartbeat writer, not the tick-socket handler — which is exactly the "loop not scheduling" condition the probe must detect (sustained). - ``state["thread"]`` — the producer thread; join it in ``finally``. - ``ready`` — set once the loop is running (socket may still be arming). The heartbeat write is patched to stall ``write_stall_s`` after the first write, so the file goes stale while the loop keeps dispatching. """ freeze_evt = asyncio.Event() state: dict = {"loop": None, "trigger": None, "thread": None} ready = threading.Event() def stalling_write(**_kwargs): if not get_loop_heartbeat_path(tmp_path).exists(): return write_loop_heartbeat(**_kwargs) time.sleep(write_stall_s) return write_loop_heartbeat(**_kwargs) async def freeze_gate() -> None: while True: await freeze_evt.wait() freeze_evt.clear() # Synchronous sleep: freezes the entire loop for block_s. time.sleep(block_s) async def producer() -> None: loop = asyncio.get_running_loop() state["loop"] = loop state["trigger"] = lambda: loop.call_soon_threadsafe(freeze_evt.set) with patch("gateway.shutdown_watchdog.write_loop_heartbeat", stalling_write): task = asyncio.create_task( loop_heartbeat_forever(interval_s=1.0, home=tmp_path) ) gate = asyncio.create_task(freeze_gate()) try: ready.set() while True: await asyncio.sleep(3600) finally: task.cancel() gate.cancel() try: await task except asyncio.CancelledError: pass try: await gate except asyncio.CancelledError: pass def run_producer() -> None: try: asyncio.run(producer()) except Exception as exc: # surfaced via errors after join errors.append(exc) thread = threading.Thread(target=run_producer, daemon=True) thread.start() state["thread"] = thread return state, ready def _wait_heartbeat_stale(tmp_path, stale_after, timeout_s=5.0): """Block until the heartbeat file is older than ``stale_after``.""" hb_path = get_loop_heartbeat_path(tmp_path) deadline = time.monotonic() + timeout_s while True: try: age = time.time() - hb_path.stat().st_mtime except FileNotFoundError: # The socket is armed before the first write lands; the file # appears a tick later. age = 0.0 if age > stale_after: return assert time.monotonic() < deadline, "heartbeat never went stale" time.sleep(0.02) def _launchd_harness(monkeypatch, tmp_path, pid): """Patch the launchd_restart path so the REAL probe drives it. Returns the ``events`` list: "probe" is recorded by the probe wrapper below, "escalate" by ``_escalate_wedged_gateway``, ``("drain", t)`` by the graceful drain wait, "kickstart" by the relaunch. The probe itself is the real ``probe_gateway_loop_liveness`` (home resolved through the patched ``_process_hermes_home``). """ events = [] monkeypatch.setattr(gateway_cli, "get_launchd_label", lambda: "ai.hermes.gateway") monkeypatch.setattr(gateway_cli, "_launchd_domain", lambda: "gui/501") monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 180.0) monkeypatch.setattr("gateway.status.get_running_pid", lambda *a, **k: pid) monkeypatch.setattr( gateway_cli, "_request_gateway_self_restart", lambda pid: False ) real_probe = gateway_cli.probe_gateway_loop_liveness def recording_probe(pid, **kw): events.append("probe") return real_probe(pid, **kw) monkeypatch.setattr(gateway_cli, "probe_gateway_loop_liveness", recording_probe) monkeypatch.setattr( gateway_cli, "_escalate_wedged_gateway", lambda pid, **kw: events.append("escalate") or True, ) monkeypatch.setattr( gateway_cli, "terminate_pid", lambda pid, force=False, **kwargs: events.append(("kill" if force else "term", pid)), ) # Never let a real SIGUSR1 escape to the live test PID — the drain path # goes through _graceful_restart_via_sigusr1 (in-place restart) before # any exit-wait, and these tests feed launchd_restart os.getpid(). monkeypatch.setattr( gateway_cli, "_graceful_restart_via_sigusr1", lambda pid, timeout: events.append(("drain", pid, timeout)) or True, ) monkeypatch.setattr( gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: events.append(("drain", timeout)) or True, ) monkeypatch.setattr( gateway_cli, "_wait_for_launchd_service_pid", lambda label, old_pid, timeout=10.0, *, domain: events.append("observe") or True, ) monkeypatch.setattr( gateway_cli.subprocess, "run", lambda *a, **k: events.append("kickstart") or __import__("types").SimpleNamespace(returncode=0, stdout="", stderr=""), ) monkeypatch.setattr(gateway_cli, "_clear_launchd_unsupported_marker", lambda: None) monkeypatch.setattr( "gateway.shutdown_watchdog._process_hermes_home", lambda: tmp_path ) return events class TestProbeGatewayLoopLiveness: def test_fresh_heartbeat_is_alive(self, tmp_path): """A gateway that refreshed its heartbeat recently is busy, not wedged.""" _write_heartbeat(tmp_path, pid=4242, age_s=5.0) assert ( gateway_cli.probe_gateway_loop_liveness(4242, home=tmp_path) == gateway_cli.GATEWAY_LOOP_ALIVE ) def test_stale_heartbeat_is_wedged(self, tmp_path): """A heartbeat several missed beats old proves the loop is dead.""" _write_heartbeat(tmp_path, pid=4242, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness(4242, home=tmp_path) == gateway_cli.GATEWAY_LOOP_WEDGED ) def test_heartbeat_just_inside_budget_is_alive(self, tmp_path): """Boundary: age below the stale budget must NOT classify as wedged.""" _write_heartbeat(tmp_path, pid=4242, age_s=60.0) assert ( gateway_cli.probe_gateway_loop_liveness( 4242, stale_after=90.0, home=tmp_path ) == gateway_cli.GATEWAY_LOOP_ALIVE ) def test_missing_heartbeat_is_unknown(self, tmp_path): """No heartbeat file (older gateway, fresh start) is not evidence.""" assert ( gateway_cli.probe_gateway_loop_liveness(4242, home=tmp_path) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_pid_mismatch_is_unknown_even_when_stale(self, tmp_path): """A stale file from a PREVIOUS process must not condemn the new PID.""" _write_heartbeat(tmp_path, pid=1111, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness(4242, home=tmp_path) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_corrupt_heartbeat_is_unknown(self, tmp_path): path = get_loop_heartbeat_path(tmp_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{not json", encoding="utf-8") stamp = time.time() - 600.0 os.utime(path, (stamp, stamp)) assert ( gateway_cli.probe_gateway_loop_liveness(4242, home=tmp_path) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_nonpositive_pid_is_unknown(self, tmp_path): _write_heartbeat(tmp_path, pid=4242, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness(0, home=tmp_path) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_invalid_stale_after_falls_back_to_default(self, tmp_path): _write_heartbeat(tmp_path, pid=4242, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( 4242, stale_after="bogus", home=tmp_path ) == gateway_cli.GATEWAY_LOOP_WEDGED ) def test_probe_never_raises_on_unreadable_path(self, monkeypatch): monkeypatch.setattr( "gateway.shutdown_watchdog.get_loop_heartbeat_path", lambda home=None: (_ for _ in ()).throw(OSError("boom")), ) assert ( gateway_cli.probe_gateway_loop_liveness(4242) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) class TestEscalateWedgedGateway: def test_sigterm_grace_suffices_without_sigkill(self, monkeypatch): """If SIGTERM lands (signal-handler thread alive), no SIGKILL is sent.""" signals = [] monkeypatch.setattr( gateway_cli, "terminate_pid", lambda pid, force=False, **kwargs: signals.append(("kill" if force else "term", pid)), ) monkeypatch.setattr( gateway_cli, "_wait_for_pid_exit", lambda pid, timeout: True ) assert gateway_cli._escalate_wedged_gateway(4242) is True assert signals == [("term", 4242)] def test_escalates_to_sigkill_when_sigterm_ignored(self, monkeypatch): signals = [] waits = [] def fake_wait(pid, timeout): waits.append(timeout) # First wait (SIGTERM grace) times out; second (post-SIGKILL) succeeds. return len(waits) > 1 monkeypatch.setattr( gateway_cli, "terminate_pid", lambda pid, force=False, **kwargs: signals.append(("kill" if force else "term", pid)), ) monkeypatch.setattr(gateway_cli, "_wait_for_pid_exit", fake_wait) assert gateway_cli._escalate_wedged_gateway(4242) is True assert signals == [("term", 4242), ("kill", 4242)] def test_total_wait_budget_is_bounded_well_under_drain(self, monkeypatch): """Worst case must be seconds, never the 180s drain budget.""" waits = [] monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False, **kwargs: None) monkeypatch.setattr( gateway_cli, "_wait_for_pid_exit", lambda pid, timeout: waits.append(timeout) or False, ) assert gateway_cli._escalate_wedged_gateway(4242) is False assert sum(waits) < 30.0 def test_process_already_gone_is_success(self, monkeypatch): def raise_gone(pid, force=False, **kwargs): raise ProcessLookupError monkeypatch.setattr(gateway_cli, "terminate_pid", raise_gone) monkeypatch.setattr( gateway_cli, "_wait_for_pid_exit", lambda pid, timeout: True ) assert gateway_cli._escalate_wedged_gateway(4242) is True def test_sigkill_permission_error_does_not_raise(self, monkeypatch): calls = [] def term(pid, force=False, **kwargs): calls.append(force) if force: raise PermissionError monkeypatch.setattr(gateway_cli, "terminate_pid", term) monkeypatch.setattr( gateway_cli, "_wait_for_pid_exit", lambda pid, timeout: False ) assert gateway_cli._escalate_wedged_gateway(4242) is False assert calls == [False, True] class TestLaunchdRestartWedgedIntegration: """launchd_restart must skip the 180s drain only for a wedged loop.""" def _setup(self, monkeypatch, liveness): events = [] monkeypatch.setattr(gateway_cli, "get_launchd_label", lambda: "ai.hermes.gateway") monkeypatch.setattr(gateway_cli, "_launchd_domain", lambda: "gui/501") monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 180.0) # Wait budget covers after-turn deferral + drain + headroom (#77184). monkeypatch.setattr(gateway_cli, "_get_restart_exit_wait_budget", lambda: 195.0) monkeypatch.setattr("gateway.status.get_running_pid", lambda *a, **k: 4242) monkeypatch.setattr( gateway_cli, "_request_gateway_self_restart", lambda pid: False ) monkeypatch.setattr( gateway_cli, "probe_gateway_loop_liveness", lambda pid, **kw: events.append("probe") or liveness, ) monkeypatch.setattr( gateway_cli, "_escalate_wedged_gateway", lambda pid, **kw: events.append("escalate") or True, ) monkeypatch.setattr( gateway_cli, "terminate_pid", lambda pid, force=False, **kwargs: events.append("sigterm"), ) # Never let a real SIGUSR1 escape to PID 4242 during tests. monkeypatch.setattr( gateway_cli, "_graceful_restart_via_sigusr1", lambda pid, timeout: events.append(("drain", pid, timeout)) or True, ) # KeepAlive revival observed instantly — avoids the real 15s poll # (mocked subprocess.run returns empty stdout, so the PID probe # would otherwise burn the full observation timeout in time.sleep). monkeypatch.setattr( gateway_cli, "_wait_for_launchd_service_pid", lambda label, old_pid, timeout=10.0, *, domain: events.append("observe") or True, ) monkeypatch.setattr( gateway_cli.subprocess, "run", lambda *a, **k: events.append("kickstart") or __import__("types").SimpleNamespace(returncode=0, stdout="", stderr=""), ) monkeypatch.setattr( gateway_cli, "_clear_launchd_unsupported_marker", lambda: None ) return events def test_wedged_gateway_skips_drain_and_escalates(self, monkeypatch): events = self._setup(monkeypatch, gateway_cli.GATEWAY_LOOP_WEDGED) gateway_cli.launchd_restart() assert "escalate" in events # The 180s drain wait must never run for a wedged loop. assert not any(isinstance(e, tuple) and e[0] == "drain" for e in events) def test_busy_gateway_keeps_full_drain_budget(self, monkeypatch): """A busy-but-alive gateway (fresh heartbeat) must NOT be escalated — that would bypass the in-flight cron drain floor (#86684).""" events = self._setup(monkeypatch, gateway_cli.GATEWAY_LOOP_ALIVE) gateway_cli.launchd_restart() assert "escalate" not in events assert ("drain", 4242, 195.0) in events def test_unknown_liveness_keeps_full_drain_budget(self, monkeypatch): """Ambiguity (no heartbeat) must never trigger escalation.""" events = self._setup(monkeypatch, gateway_cli.GATEWAY_LOOP_UNKNOWN) gateway_cli.launchd_restart() assert "escalate" not in events assert ("drain", 4242, 195.0) in events class TestLoopTickWitness: """Two-witness liveness (#90502 review). The heartbeat write moved off-loop, so a stale file no longer proves a wedged loop and a fresh file no longer proves an alive one. The loop answers a UNIX socket instead; the probe only escalates when BOTH witnesses agree the loop stopped scheduling. """ @_NEEDS_UNIX_SOCKETS def test_stalled_heartbeat_write_never_escalates_a_running_loop( self, tmp_path, monkeypatch ): """Producer + consumer composition. While the heartbeat write is stalled longer than the stale budget, a loop that demonstrably keeps dispatching must probe ALIVE — and a restart path fed by the real probe must take the graceful drain, never the bounded escalation. This is the exact false-positive the review called out: the measured fsync stall (112.6s max) exceeds the 90s destructive-classifier threshold. """ pid = os.getpid() block_s = 1.5 stale_after = 1.0 # First write lands immediately; every later write stalls like an # fsync on the incident filesystem, so the file ages past the budget # while the loop keeps running. def stalling_write(**_kwargs): if not get_loop_heartbeat_path(tmp_path).exists(): return write_loop_heartbeat(**_kwargs) time.sleep(block_s) return write_loop_heartbeat(**_kwargs) errors = [] async def producer() -> None: with patch( "gateway.shutdown_watchdog.write_loop_heartbeat", stalling_write ): task = asyncio.create_task( loop_heartbeat_forever(interval_s=1.0, home=tmp_path) ) try: # One interval (1.0s) elapses, the second write starts and # stalls; 1.35s in the file is stale but the loop ticks. await asyncio.sleep(1.35) finally: task.cancel() try: await task except asyncio.CancelledError: pass def run_producer() -> None: try: asyncio.run(producer()) except Exception as exc: # surfaced via errors after join errors.append(exc) thread = threading.Thread(target=run_producer, daemon=True) thread.start() try: sock_path = get_loop_tick_socket_path(tmp_path, pid) deadline = time.monotonic() + 5.0 while not sock_path.exists() and time.monotonic() < deadline: time.sleep(0.02) assert sock_path.exists(), "producer never armed the tick socket" hb_path = get_loop_heartbeat_path(tmp_path) deadline = time.monotonic() + 5.0 while True: try: age = time.time() - hb_path.stat().st_mtime except FileNotFoundError: # The socket is armed before the first write lands; the # file appears a tick later. age = 0.0 if age > stale_after: break assert time.monotonic() < deadline, "heartbeat never went stale" time.sleep(0.02) # The file is stale but the loop answers: ALIVE, not WEDGED. assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, stale_after=stale_after, tick_timeout=0.25 ) == gateway_cli.GATEWAY_LOOP_ALIVE ) # The restart path fed by the REAL probe must drain, not escalate. events = [] monkeypatch.setattr( gateway_cli, "get_launchd_label", lambda: "ai.hermes.gateway" ) monkeypatch.setattr(gateway_cli, "_launchd_domain", lambda: "gui/501") monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 180.0) monkeypatch.setattr( "gateway.status.get_running_pid", lambda *a, **k: pid ) monkeypatch.setattr( gateway_cli, "_request_gateway_self_restart", lambda pid: False ) monkeypatch.setattr( gateway_cli, "_escalate_wedged_gateway", lambda pid, **kw: events.append("escalate") or True, ) monkeypatch.setattr( gateway_cli, "terminate_pid", lambda pid, force=False, **kwargs: events.append(("kill" if force else "term", pid)), ) # Never let a real SIGUSR1 escape to os.getpid() — the drain # path now goes through _graceful_restart_via_sigusr1 (in-place # restart) before any exit-wait, and this test feeds the REAL # launchd_restart our own live PID. monkeypatch.setattr( gateway_cli, "_graceful_restart_via_sigusr1", lambda pid, timeout: events.append(("drain", pid, timeout)) or True, ) monkeypatch.setattr( gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: events.append(("drain", timeout)) or True, ) monkeypatch.setattr( gateway_cli, "_wait_for_launchd_service_pid", lambda label, old_pid, timeout=10.0, *, domain: events.append("observe") or True, ) monkeypatch.setattr( gateway_cli.subprocess, "run", lambda *a, **k: events.append("kickstart") or __import__("types").SimpleNamespace( returncode=0, stdout="", stderr="" ), ) monkeypatch.setattr( gateway_cli, "_clear_launchd_unsupported_marker", lambda: None ) # The real probe resolves the state dir through this hook. monkeypatch.setattr( "gateway.shutdown_watchdog._process_hermes_home", lambda: tmp_path ) gateway_cli.launchd_restart() assert "escalate" not in events drains = [e for e in events if isinstance(e, tuple) and e[0] == "drain"] assert drains, events finally: thread.join(timeout=5.0) assert not errors, errors @_NEEDS_UNIX_SOCKETS def test_off_loop_completion_cannot_manufacture_fresh_liveness(self, tmp_path): """A write landing after the loop froze must not look alive. File fresh (a late off-loop write completed) + loop silent: the probe must NOT return ALIVE — the loop itself never answered, so freshness is not a liveness proof. UNKNOWN keeps the safe drain path while denying the false-fresh window the review described. """ pid = 4242 _silent_socket_node(get_loop_tick_socket_path(tmp_path, pid)) _write_heartbeat(tmp_path, pid, age_s=5.0) _mark_witness_flag(tmp_path, armed=True) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2 ) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) @_NEEDS_UNIX_SOCKETS def test_true_wedge_requires_sustained_witness_silence(self, tmp_path): """Stale file + armed socket silent across the whole window: WEDGED. The destructive path must still exist for genuinely dead loops — a frozen loop stops answering the socket for every attempt in the bounded window, and the file goes stale. The reviewer's sustained- proof contract (one sample is never destructive authority) is satisfied here: all ``tick_strikes`` consecutive probes miss. """ pid = 4242 _silent_socket_node(get_loop_tick_socket_path(tmp_path, pid)) _write_heartbeat(tmp_path, pid, age_s=600.0) _mark_witness_flag(tmp_path, armed=True, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2, tick_strikes=3, tick_gap_s=0.0, ) == gateway_cli.GATEWAY_LOOP_WEDGED ) def test_single_silent_probe_is_not_destructive(self, tmp_path, monkeypatch): """One miss is a transient stall, never a wedge (#90502 review). Stale file + armed socket + a loop that misses the FIRST probe but answers the second: the probe must recover to ALIVE — the loop is demonstrably dispatching, and the stale file is just a stalled write. A single silent sample must never grant the bounded kill authority. """ pid = 4242 calls = [] def flaky_probe(_pid, _home, timeout=1.0): calls.append(timeout) # First sample misses (transient synchronous stall), the loop # answers on the next attempt. return len(calls) > 1 monkeypatch.setattr(gateway_cli, "_probe_loop_tick_socket", flaky_probe) _write_heartbeat(tmp_path, pid, age_s=600.0) _mark_witness_flag(tmp_path, armed=True, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2, tick_strikes=2, tick_gap_s=0.0, ) == gateway_cli.GATEWAY_LOOP_ALIVE ) # First probe (miss) + exactly one sustained-window follow-up. assert len(calls) == 2 def test_sustained_silence_is_required_for_wedge(self, tmp_path, monkeypatch): """WEDGED only after the full bounded window of consecutive misses. With ``tick_strikes=2`` the probe must observe BOTH samples silent before granting the wedge verdict; a single silent sample alone would previously have escalated. """ pid = 4242 calls = [] monkeypatch.setattr( gateway_cli, "_probe_loop_tick_socket", lambda _pid, _home, timeout=1.0: (calls.append(timeout), False)[1], ) _write_heartbeat(tmp_path, pid, age_s=600.0) _mark_witness_flag(tmp_path, armed=True, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2, tick_strikes=2, tick_gap_s=0.0, ) == gateway_cli.GATEWAY_LOOP_WEDGED ) assert len(calls) == 2 # miss #1 (initial probe) + miss #2 (window) def test_witness_vanishing_mid_window_is_unknown(self, tmp_path, monkeypatch): """A witness that disappears mid-window is ambiguity, not a wedge. First sample silent (node existed), second sample finds no node: the witness is gone and cannot condemn the loop. UNKNOWN keeps the graceful drain path. """ pid = 4242 calls = [] monkeypatch.setattr( gateway_cli, "_probe_loop_tick_socket", lambda _pid, _home, timeout=1.0: (calls.append(timeout), False, None)[ len(calls) ], ) _write_heartbeat(tmp_path, pid, age_s=600.0) _mark_witness_flag(tmp_path, armed=True, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2, tick_strikes=2, tick_gap_s=0.0, ) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_armed_witness_unreachable_is_unknown(self, tmp_path): """Producer claims the witness is armed but no node exists: ambiguity. Never kill on it — the graceful drain remains the backstop. """ pid = 4242 _write_heartbeat(tmp_path, pid, age_s=600.0) _mark_witness_flag(tmp_path, armed=True, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2 ) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_unarmed_witness_disables_stale_escalation(self, tmp_path): """New producer whose bind failed: staleness is NOT proof. The write is off-loop, so the file can age while the loop runs; with no witness, a stale file must never escalate. """ pid = 4242 _write_heartbeat(tmp_path, pid, age_s=600.0) _mark_witness_flag(tmp_path, armed=False, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2 ) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_legacy_payload_keeps_single_witness_contract(self, tmp_path): """No witness flag = on-loop writer: staleness stays proof. Old gateways never moved the write off-loop, so their stale file still means a dead loop — the legacy WEDGED verdict is unchanged. """ pid = 4242 _write_heartbeat(tmp_path, pid, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness(pid, home=tmp_path) == gateway_cli.GATEWAY_LOOP_WEDGED ) @_NEEDS_UNIX_SOCKETS def test_legacy_fresh_file_with_dead_node_is_unknown(self, tmp_path): """A fresh legacy file stays safe under a dead-listener node. A dead-listener node for the PID (leftover from a newer process): the silent socket denies ALIVE, and UNKNOWN never escalates — the drain path keeps the full budget either way. """ pid = 4242 _write_heartbeat(tmp_path, pid, age_s=5.0) _silent_socket_node(get_loop_tick_socket_path(tmp_path, pid)) assert ( gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, tick_timeout=0.2 ) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) @_NEEDS_UNIX_SOCKETS @pytest.mark.asyncio async def test_producer_rebinds_over_stale_socket_node(self, tmp_path): """A leftover node from a dead process must not disarm the witness. os._exit(75) / SIGKILL skip the finally-unlink, and PID reuse then re-lands on the same PID-suffixed path. Without the pre-bind unlink the bind fails EADDRINUSE, the except disarms the witness (loop_tick_socket:false), and a stale heartbeat can never classify WEDGED — precisely on crash-restart loops. """ sock_path = get_loop_tick_socket_path(tmp_path) _silent_socket_node(sock_path) # dead process's leftover node assert sock_path.exists() task = asyncio.create_task( loop_heartbeat_forever(interval_s=0.2, home=tmp_path) ) try: for _ in range(50): hb = get_loop_heartbeat_path(tmp_path) if hb.exists(): payload = json.loads(hb.read_text(encoding="utf-8")) if payload.get("loop_tick_socket"): break await asyncio.sleep(0.05) else: raise AssertionError( "witness never armed over the stale socket node" ) # And it actually answers: the node is live, not the leftover. reader, writer = await asyncio.open_unix_connection(str(sock_path)) data = await asyncio.wait_for(reader.read(64), timeout=2) writer.close() assert data, "re-bound tick socket gave no answer" finally: task.cancel() try: await task except asyncio.CancelledError: pass @_NEEDS_UNIX_SOCKETS def test_transient_stall_below_wedge_budget_never_escalates( self, tmp_path, monkeypatch ): """Composed producer+consumer: transient loop stall below the budget. Heartbeat write stalled (file stale) + loop frozen for longer than one recv timeout but shorter than the wedge window: the probe must NOT return WEDGED — the loop answers once it unfreezes, inside the bounded window — and the restart path fed by the real probe must take the graceful drain, never the bounded escalation. This is the reviewer's required composed regression (#90502 review). """ pid = os.getpid() block_s = 0.5 stale_after = 1.0 tick_timeout = 0.2 tick_strikes = 3 tick_gap_s = 0.05 wedge_window = tick_strikes * tick_timeout + (tick_strikes - 1) * tick_gap_s assert tick_timeout < block_s < wedge_window, (tick_timeout, block_s, wedge_window) errors = [] state, ready = _start_freezeable_producer(tmp_path, block_s, errors) try: assert ready.wait(timeout=5.0) sock_path = get_loop_tick_socket_path(tmp_path, pid) deadline = time.monotonic() + 5.0 while not sock_path.exists() and time.monotonic() < deadline: time.sleep(0.02) assert sock_path.exists(), "producer never armed the tick socket" _wait_heartbeat_stale(tmp_path, stale_after) state["trigger"]() # freeze the loop for block_s verdict = gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, stale_after=stale_after, tick_timeout=tick_timeout, tick_strikes=tick_strikes, tick_gap_s=tick_gap_s, ) assert verdict == gateway_cli.GATEWAY_LOOP_ALIVE, verdict events = _launchd_harness(monkeypatch, tmp_path, pid) gateway_cli.launchd_restart() assert "escalate" not in events drains = [e for e in events if isinstance(e, tuple) and e[0] == "drain"] assert drains, events finally: state["thread"].join(timeout=5.0) assert not errors, errors @_NEEDS_UNIX_SOCKETS def test_sustained_stop_above_wedge_budget_still_escalates( self, tmp_path ): """Composed producer+consumer: sustained stop above the budget. The bounded-proof contract must not neuter the destructive path: a loop frozen for longer than the wedge window stays silent for every probe attempt, so the probe still returns WEDGED. (The WEDGED → bounded-escalation wiring on the restart paths is covered by ``TestLaunchdRestartWedgedIntegration``.) """ pid = os.getpid() block_s = 1.2 stale_after = 1.0 tick_timeout = 0.2 tick_strikes = 3 tick_gap_s = 0.05 wedge_window = tick_strikes * tick_timeout + (tick_strikes - 1) * tick_gap_s assert block_s > wedge_window, (block_s, wedge_window) errors = [] state, ready = _start_freezeable_producer(tmp_path, block_s, errors) try: assert ready.wait(timeout=5.0) sock_path = get_loop_tick_socket_path(tmp_path, pid) deadline = time.monotonic() + 5.0 while not sock_path.exists() and time.monotonic() < deadline: time.sleep(0.02) assert sock_path.exists(), "producer never armed the tick socket" _wait_heartbeat_stale(tmp_path, stale_after) state["trigger"]() # freeze the loop for block_s verdict = gateway_cli.probe_gateway_loop_liveness( pid, home=tmp_path, stale_after=stale_after, tick_timeout=tick_timeout, tick_strikes=tick_strikes, tick_gap_s=tick_gap_s, ) assert verdict == gateway_cli.GATEWAY_LOOP_WEDGED, verdict finally: state["thread"].join(timeout=5.0) assert not errors, errors class TestLoopTickTcpWitness: """Non-POSIX arm: the producer publishes ``loop_tick_tcp_port`` and the consumer probes 127.0.0.1: instead of the AF_UNIX node. The two-witness contract must hold identically over TCP.""" @staticmethod def _tcp_answerer(): """A loopback listener that answers b"1" — the armed, dispatching loop.""" srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind(("127.0.0.1", 0)) srv.listen(8) stop = threading.Event() def serve(): srv.settimeout(0.1) while not stop.is_set(): try: conn, _ = srv.accept() except socket.timeout: continue try: conn.sendall(b"1") finally: conn.close() thread = threading.Thread(target=serve, daemon=True) thread.start() return srv.getsockname()[1], stop, srv @staticmethod def _write_tcp_heartbeat(home, pid, port, age_s=0.0): write_loop_heartbeat( pid=pid, home=home, extra={"loop_tick_socket": True, "loop_tick_tcp_port": port}, ) if age_s: path = get_loop_heartbeat_path(home) stamp = time.time() - age_s os.utime(path, (stamp, stamp)) def test_stale_file_with_answering_tcp_witness_is_alive(self, tmp_path): """#90502 shape over TCP: a stalled write must not kill a live loop.""" port, stop, srv = self._tcp_answerer() try: self._write_tcp_heartbeat(tmp_path, 4343, port, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness(4343, home=tmp_path) == gateway_cli.GATEWAY_LOOP_ALIVE ) finally: stop.set() srv.close() def test_stale_file_with_silent_tcp_witness_is_wedged(self, tmp_path): """Armed TCP witness that never answers across the window: WEDGED.""" silent = socket.socket(socket.AF_INET, socket.SOCK_STREAM) silent.bind(("127.0.0.1", 0)) silent.listen(1) # accepts but never sends try: port = silent.getsockname()[1] self._write_tcp_heartbeat(tmp_path, 4344, port, age_s=600.0) assert ( gateway_cli.probe_gateway_loop_liveness( 4344, home=tmp_path, tick_timeout=0.2, tick_gap_s=0.05 ) == gateway_cli.GATEWAY_LOOP_WEDGED ) finally: silent.close() def test_fresh_file_with_silent_tcp_witness_is_unknown(self, tmp_path): """Fresh file + silent TCP witness: an off-loop write landed after a freeze — not proof of liveness, never destructive authority.""" silent = socket.socket(socket.AF_INET, socket.SOCK_STREAM) silent.bind(("127.0.0.1", 0)) silent.listen(1) try: port = silent.getsockname()[1] self._write_tcp_heartbeat(tmp_path, 4345, port) assert ( gateway_cli.probe_gateway_loop_liveness( 4345, home=tmp_path, tick_timeout=0.2 ) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) finally: silent.close() def test_garbage_tcp_port_falls_back_to_socket_contract(self, tmp_path): """A non-numeric port must not be treated as an armed witness.""" write_loop_heartbeat( pid=4346, home=tmp_path, extra={"loop_tick_socket": False, "loop_tick_tcp_port": "nope"}, ) path = get_loop_heartbeat_path(tmp_path) stamp = time.time() - 600.0 os.utime(path, (stamp, stamp)) # loop_tick_socket=False + no usable TCP port: witness could not be # armed, staleness is not proof -> UNKNOWN, never WEDGED. assert ( gateway_cli.probe_gateway_loop_liveness(4346, home=tmp_path) == gateway_cli.GATEWAY_LOOP_UNKNOWN ) def test_default_probe_budget_stays_inside_query_tier(): """The module doc pins the worst-case wedge-suspected probe at ~3.4s, 'far inside the 10s query tier'. Assert the strike-count math so retuning tick_timeout / tick_strikes / tick_gap_s can't silently blow past that tier (reviewer ask on #92315). Worst case: the first probe misses (tick_timeout), then the sustained window runs (tick_strikes - 1) more probes, each up to tick_timeout, with tick_gap_s sleeps between attempts. """ import inspect sig = inspect.signature(gateway_cli.probe_gateway_loop_liveness) tick_timeout = sig.parameters["tick_timeout"].default tick_strikes = sig.parameters["tick_strikes"].default tick_gap_s = sig.parameters["tick_gap_s"].default worst_case = tick_strikes * tick_timeout + (tick_strikes - 1) * tick_gap_s assert worst_case <= 5.0, ( f"default probe budget {worst_case:.1f}s exceeds half the 10s query " "tier — retune tick_timeout/tick_strikes/tick_gap_s or update the " "subprocess-timeout doc reference in hermes_cli/gateway.py" )