test_debug.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """Tests for stt_daemon.debug.DebugSink (per-file fakes, no conftest.py)."""
  2. import json
  3. import logging
  4. import os
  5. import threading
  6. from datetime import datetime
  7. from pathlib import Path
  8. import pytest
  9. from stt_daemon.debug import DebugSink
  10. def _read_lines(path):
  11. with open(path, encoding="utf-8") as f:
  12. return [json.loads(line) for line in f if line.strip()]
  13. def test_emit_writes_valid_jsonl_per_day(tmp_path):
  14. sink = DebugSink(str(tmp_path / "debug"), "daemon")
  15. sink.emit("heartbeat", queue_depth=0, device_ok=True)
  16. sink.emit("capture_reopen", attempt=1)
  17. day = datetime.now().strftime("%Y-%m-%d")
  18. records = _read_lines(tmp_path / "debug" / f"{day}.jsonl")
  19. assert len(records) == 2
  20. assert records[0]["proc"] == "daemon"
  21. assert records[0]["type"] == "heartbeat"
  22. assert records[0]["queue_depth"] == 0
  23. assert records[0]["device_ok"] is True
  24. assert "ts" in records[0]
  25. assert records[1]["type"] == "capture_reopen"
  26. def test_forbidden_fields_never_persisted(tmp_path):
  27. sink = DebugSink(str(tmp_path / "debug"), "assistant")
  28. sink.emit("heartbeat", text="secret transcript", audio="clip.wav", prompt="sys", rms=0.1)
  29. day = datetime.now().strftime("%Y-%m-%d")
  30. records = _read_lines(tmp_path / "debug" / f"{day}.jsonl")
  31. assert len(records) == 1
  32. assert "text" not in records[0]
  33. assert "audio" not in records[0]
  34. assert "prompt" not in records[0]
  35. assert records[0]["rms"] == 0.1
  36. def test_invalid_proc_rejected(tmp_path):
  37. with pytest.raises(ValueError):
  38. DebugSink(str(tmp_path), "worker")
  39. @pytest.mark.parametrize("proc", ["daemon", "assistant", "supervisor"])
  40. def test_all_procs_accepted(tmp_path, proc):
  41. sink = DebugSink(str(tmp_path / proc), proc)
  42. sink.emit("heartbeat")
  43. day = datetime.now().strftime("%Y-%m-%d")
  44. records = _read_lines(tmp_path / proc / f"{day}.jsonl")
  45. assert records[0]["proc"] == proc
  46. def test_disabled_path_creates_no_files(tmp_path):
  47. # Disabled path = caller holds None instead of a sink (zero overhead).
  48. debug = None
  49. target = tmp_path / "debug"
  50. if debug is not None:
  51. debug.emit("heartbeat")
  52. assert not target.exists()
  53. def test_heartbeat_gating_respected_by_caller_contract(tmp_path):
  54. # Gating lives with the caller: heartbeat lines are skipped unless enabled,
  55. # anomaly lines always flow.
  56. class FakeCaller:
  57. def __init__(self, sink, heartbeat):
  58. self.sink = sink
  59. self.heartbeat = heartbeat
  60. def tick(self, is_heartbeat, type, **fields):
  61. if is_heartbeat and not self.heartbeat:
  62. return
  63. self.sink.emit(type, **fields)
  64. sink = DebugSink(str(tmp_path / "debug"), "daemon")
  65. caller = FakeCaller(sink, heartbeat=False)
  66. caller.tick(True, "heartbeat", queue_depth=0)
  67. caller.tick(False, "capture_reopen", attempt=1)
  68. day = datetime.now().strftime("%Y-%m-%d")
  69. records = _read_lines(tmp_path / "debug" / f"{day}.jsonl")
  70. assert [r["type"] for r in records] == ["capture_reopen"]
  71. caller.heartbeat = True
  72. caller.tick(True, "heartbeat", queue_depth=0)
  73. records = _read_lines(tmp_path / "debug" / f"{day}.jsonl")
  74. assert [r["type"] for r in records] == ["capture_reopen", "heartbeat"]
  75. def test_mkdir_failure_disables_without_raising(tmp_path, caplog):
  76. blocker = tmp_path / "blocker"
  77. blocker.write_text("not a dir")
  78. with caplog.at_level(logging.WARNING):
  79. sink = DebugSink(str(blocker / "debug"), "daemon")
  80. assert sink._disabled
  81. assert "mkdir failed" in caplog.text
  82. sink.emit("heartbeat") # no-op, must not raise
  83. def test_open_failure_warns_not_raises_and_breaker_latches(tmp_path, caplog, monkeypatch):
  84. sink = DebugSink(str(tmp_path / "debug"), "daemon")
  85. def _boom(path, flags, mode=0o644):
  86. raise OSError("disk gone")
  87. monkeypatch.setattr(os, "open", _boom)
  88. with caplog.at_level(logging.WARNING):
  89. sink.emit("heartbeat", queue_depth=1)
  90. assert "write failure" in caplog.text
  91. # Breaker latched: even with os.open restored, emits are silent no-ops.
  92. monkeypatch.undo()
  93. caplog.clear()
  94. with caplog.at_level(logging.WARNING):
  95. sink.emit("heartbeat", queue_depth=2)
  96. assert caplog.text == ""
  97. day = datetime.now().strftime("%Y-%m-%d")
  98. assert not (tmp_path / "debug" / f"{day}.jsonl").exists()
  99. def test_write_failure_warns_once_then_latches(tmp_path, caplog, monkeypatch):
  100. sink = DebugSink(str(tmp_path / "debug"), "daemon")
  101. sink.emit("heartbeat", seq=0)
  102. calls = {"n": 0}
  103. real_write = os.write
  104. def _flaky(fd, data):
  105. calls["n"] += 1
  106. if calls["n"] == 1:
  107. raise OSError("transient")
  108. return real_write(fd, data)
  109. monkeypatch.setattr(os, "write", _flaky)
  110. with caplog.at_level(logging.WARNING):
  111. sink.emit("heartbeat", seq=1)
  112. assert "write failure" in caplog.text
  113. monkeypatch.undo()
  114. caplog.clear()
  115. with caplog.at_level(logging.WARNING):
  116. sink.emit("heartbeat", seq=2)
  117. assert caplog.text == ""
  118. day = datetime.now().strftime("%Y-%m-%d")
  119. records = _read_lines(tmp_path / "debug" / f"{day}.jsonl")
  120. assert [r.get("seq") for r in records] == [0]
  121. def test_concurrent_emits_produce_no_partial_lines(tmp_path):
  122. sink = DebugSink(str(tmp_path / "debug"), "daemon")
  123. threads = 8
  124. per_thread = 100
  125. def _work(tid):
  126. for i in range(per_thread):
  127. sink.emit("heartbeat", tid=tid, seq=i, pad="x" * 200)
  128. workers = [threading.Thread(target=_work, args=(t,)) for t in range(threads)]
  129. for w in workers:
  130. w.start()
  131. for w in workers:
  132. w.join()
  133. day = datetime.now().strftime("%Y-%m-%d")
  134. path = tmp_path / "debug" / f"{day}.jsonl"
  135. with open(path, encoding="utf-8") as f:
  136. raw = f.readlines()
  137. assert len(raw) == threads * per_thread
  138. seen = set()
  139. for line in raw:
  140. record = json.loads(line) # raises on any partial/interleaved line
  141. seen.add((record["tid"], record["seq"]))
  142. assert len(seen) == threads * per_thread
  143. def test_cap_skips_past_limit_with_single_warning(tmp_path, caplog):
  144. sink = DebugSink(str(tmp_path / "debug"), "daemon", max_bytes_per_day=200)
  145. with caplog.at_level(logging.WARNING):
  146. for i in range(20):
  147. sink.emit("heartbeat", seq=i, pad="y" * 50)
  148. warnings = [r for r in caplog.records if "cap reached" in r.message]
  149. assert len(warnings) == 1
  150. day = datetime.now().strftime("%Y-%m-%d")
  151. size = Path(tmp_path / "debug" / f"{day}.jsonl").stat().st_size
  152. assert size <= 200
  153. def test_cap_counter_resets_on_rollover(tmp_path):
  154. clock = {"now": datetime(2026, 9, 29, 23, 59, 59)}
  155. sink = DebugSink(str(tmp_path / "debug"), "daemon", max_bytes_per_day=200,
  156. now_fn=lambda: clock["now"])
  157. for i in range(20): # exhaust day one's cap
  158. sink.emit("heartbeat", seq=i, pad="z" * 50)
  159. clock["now"] = datetime(2026, 9, 30, 0, 0, 1)
  160. sink.emit("heartbeat", seq="next-day")
  161. records = _read_lines(tmp_path / "debug" / "2026-09-30.jsonl")
  162. assert [r.get("seq") for r in records] == ["next-day"]
  163. def test_close_is_noop(tmp_path):
  164. sink = DebugSink(str(tmp_path / "debug"), "supervisor")
  165. sink.emit("spawn", pid=123)
  166. sink.close()
  167. sink.emit("exit", code=0)
  168. day = datetime.now().strftime("%Y-%m-%d")
  169. records = _read_lines(tmp_path / "debug" / f"{day}.jsonl")
  170. assert [r["type"] for r in records] == ["spawn", "exit"]