"""Process-level liveness heartbeat for web/worker (app.py _heartbeat_loop / _start_heartbeat) — the fix for ProdWebContainerSilent / ProdWorkerContainerSilent false-firing on container-stdout silence that isn't a real liveness signal for either role.""" import json import time import pytest from fastapi.testclient import TestClient from core import audit from web import app as appmod @pytest.fixture(autouse=True) def _reset_heartbeat_thread(): # _HEARTBEAT_THREAD is a module-level singleton guard (mirrors notify.py's own # _STOP/_THREAD pattern) -- reset it around every test so one test starting the # thread doesn't leave _start_heartbeat() a no-op for the next. appmod._HEARTBEAT_STOP.clear() appmod._HEARTBEAT_THREAD = None yield appmod._HEARTBEAT_STOP.set() appmod._HEARTBEAT_THREAD = None @pytest.mark.parametrize("role", ["web", "worker", "all"]) def test_heartbeat_loop_tags_the_beat_with_role(monkeypatch, role): monkeypatch.setattr(appmod, "ROLE", role) calls = [] monkeypatch.setattr(audit, "log_heartbeat", lambda daemon, interval_s: calls.append((daemon, interval_s))) monkeypatch.setattr(appmod.metrics, "record_heartbeat", lambda daemon, interval_s: None) # Setting the stop event first means .wait() returns True immediately, so the # loop beats exactly once (the pre-loop beat) and exits without blocking. appmod._HEARTBEAT_STOP.set() appmod._heartbeat_loop() assert calls == [(role, appmod.HEARTBEAT_INTERVAL)] def test_start_heartbeat_respects_the_disable_flag(monkeypatch): monkeypatch.setenv("THERMOGRAPH_ENABLE_HEARTBEAT", "0") started = [] monkeypatch.setattr(appmod.threading, "Thread", lambda **kw: started.append(kw)) appmod._start_heartbeat() assert appmod._HEARTBEAT_THREAD is None assert started == [] def test_start_heartbeat_is_idempotent(monkeypatch): monkeypatch.delenv("THERMOGRAPH_ENABLE_HEARTBEAT", raising=False) starts = [] class _FakeThread: def __init__(self, **kw): starts.append(kw) def start(self): pass monkeypatch.setattr(appmod.threading, "Thread", _FakeThread) appmod._start_heartbeat() appmod._start_heartbeat() # A second call is a no-op once a thread handle already exists -- mirrors # notify.start()'s own idempotency, so a double-entry into the lifespan # (or a stray manual call) can't spawn a second beat loop. assert len(starts) == 1 def test_app_boot_emits_a_heartbeat_within_one_interval(monkeypatch, tmp_path): monkeypatch.setattr(appmod, "ROLE", "web") monkeypatch.setattr(appmod, "HEARTBEAT_INTERVAL", 0.01) monkeypatch.setattr(audit, "HEARTBEAT_DIR", str(tmp_path)) monkeypatch.delenv("THERMOGRAPH_ENABLE_HEARTBEAT", raising=False) with TestClient(appmod.app): # The pre-loop beat fires synchronously on thread start; a short sleep is # just slack for the thread to actually get scheduled. The loop itself # keeps running on HEARTBEAT_INTERVAL until lifespan shutdown sets the # stop event, so there's nothing to join here. time.sleep(0.2) files = list(tmp_path.glob("heartbeat-*.jsonl")) assert files, "expected a heartbeat-*.jsonl file to be written during app lifespan" lines = [json.loads(line) for line in files[0].read_text().splitlines() if line] assert any(rec["daemon"] == "web" and rec["tag"] == "heartbeat" for rec in lines)