test_supervisor.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. """Tests for the Supervisor class (daemon, assistant, TUI orchestration)."""
  2. import signal
  3. import subprocess
  4. import sys
  5. import types
  6. from pathlib import Path
  7. from stt_daemon.config import Config
  8. from stt_daemon.supervisor import Supervisor
  9. class FakePopen:
  10. """Fake subprocess.Popen for testing without spawning real processes."""
  11. _pid_counter = 10000
  12. def __init__(self, argv, redirect_output=False):
  13. self.argv = argv
  14. self.redirect_output = redirect_output
  15. self.pid = FakePopen._pid_counter
  16. FakePopen._pid_counter += 1
  17. self.returncode = None
  18. self._terminated = False
  19. self._killed = False
  20. def poll(self):
  21. """Return None (running) or a returncode."""
  22. return self.returncode
  23. def terminate(self):
  24. """Simulate termination."""
  25. self._terminated = True
  26. if self.returncode is None:
  27. self.returncode = -15
  28. def kill(self):
  29. """Simulate kill."""
  30. self._killed = True
  31. if self.returncode is None:
  32. self.returncode = -9
  33. def wait(self, timeout=None):
  34. """Wait for process to exit."""
  35. if self.returncode is None:
  36. self.returncode = 0
  37. return self.returncode
  38. def _cfg():
  39. """Create a test Config object."""
  40. return Config()
  41. class FakeTermios:
  42. """Records tcgetattr/tcsetattr calls instead of touching a real terminal."""
  43. TCSADRAIN = 1
  44. ISIG = 1
  45. def __init__(self, log):
  46. self._log = log
  47. def tcgetattr(self, fd):
  48. self._log.append(("get", fd))
  49. return [0, 0, 0, 0, 0, 0, []]
  50. def tcsetattr(self, fd, when, attrs):
  51. self._log.append(("set", fd, when, attrs))
  52. class FakeStdin:
  53. """Stand-in for sys.stdin so the helpers never touch pytest's captured stdin."""
  54. def fileno(self):
  55. return 7
  56. def test_supervisor_run_spawns_daemon_then_assistant_no_tui():
  57. """run() spawns daemon then assistant, not TUI when with_tui=False."""
  58. cfg = _cfg()
  59. spawned = []
  60. def fake_spawn(argv, *, redirect_output=False):
  61. spawned.append((argv, redirect_output))
  62. proc = FakePopen(argv, redirect_output=redirect_output)
  63. if len(spawned) == 1:
  64. proc.returncode = 0
  65. return proc
  66. supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
  67. result = supervisor.run()
  68. assert len(spawned) == 2
  69. assert "stt_daemon" in " ".join(spawned[0][0]) and "run" in spawned[0][0]
  70. assert "stt_daemon.assistant" in " ".join(spawned[1][0])
  71. assert "stt_daemon.tui" not in " ".join(str(x) for x in spawned)
  72. assert result == 0
  73. def test_supervisor_run_fails_when_tui_socket_timeout():
  74. """run() with_tui=True fails and terminates all if control socket timeout."""
  75. cfg = _cfg()
  76. cfg.assistant.control_socket = "/nonexistent/socket.sock"
  77. spawned = []
  78. def fake_spawn(argv, *, redirect_output=False):
  79. proc = FakePopen(argv, redirect_output=redirect_output)
  80. spawned.append(proc)
  81. return proc
  82. clock_val = [0.0]
  83. def fake_clock():
  84. return clock_val[0]
  85. def fake_sleep(s):
  86. clock_val[0] += 0.1
  87. supervisor = Supervisor(
  88. cfg,
  89. with_tui=True,
  90. spawn=fake_spawn,
  91. sleep=fake_sleep,
  92. clock=fake_clock,
  93. poll_s=0.001,
  94. wait_timeout_s=0.05,
  95. )
  96. result = supervisor.run()
  97. assert result == 1
  98. assert len(spawned) == 2
  99. for proc in spawned:
  100. assert proc._terminated or proc._killed
  101. def test_supervisor_run_returns_child_exit_code():
  102. """run() returns first child's exit code when it exits."""
  103. cfg = _cfg()
  104. spawned = []
  105. def fake_spawn(argv, *, redirect_output=False):
  106. proc = FakePopen(argv, redirect_output=redirect_output)
  107. spawned.append(proc)
  108. if len(spawned) == 1:
  109. proc.returncode = 42
  110. return proc
  111. supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
  112. result = supervisor.run()
  113. assert result == 42
  114. assert spawned[0].returncode == 42
  115. assert spawned[1]._terminated or spawned[1]._killed
  116. def test_supervisor_forwards_overrides_to_daemon():
  117. """Daemon receives device, backend, model, language overrides."""
  118. cfg = _cfg()
  119. overrides = {
  120. "device": "microphone.usb",
  121. "backend": "sounddevice",
  122. "model": "base",
  123. "language": "es",
  124. "config": "/path/to/config.toml",
  125. }
  126. spawned = []
  127. def fake_spawn(argv, *, redirect_output=False):
  128. spawned.append(argv)
  129. proc = FakePopen(argv, redirect_output=redirect_output)
  130. if len(spawned) == 1:
  131. proc.returncode = 0
  132. return proc
  133. supervisor = Supervisor(
  134. cfg, with_tui=False, overrides=overrides, spawn=fake_spawn, poll_s=0.001
  135. )
  136. supervisor.run()
  137. daemon_argv = spawned[0]
  138. assert "--device" in daemon_argv and "microphone.usb" in daemon_argv
  139. assert "--backend" in daemon_argv and "sounddevice" in daemon_argv
  140. assert "--model" in daemon_argv and "base" in daemon_argv
  141. assert "--language" in daemon_argv and "es" in daemon_argv
  142. assert "--config" in daemon_argv and "/path/to/config.toml" in daemon_argv
  143. def test_supervisor_forwards_verbose():
  144. """Verbose flag forwarded to daemon and assistant."""
  145. cfg = _cfg()
  146. spawned = []
  147. def fake_spawn(argv, *, redirect_output=False):
  148. spawned.append(argv)
  149. proc = FakePopen(argv, redirect_output=redirect_output)
  150. if len(spawned) == 1:
  151. proc.returncode = 0
  152. return proc
  153. supervisor = Supervisor(cfg, with_tui=False, verbose=True, spawn=fake_spawn, poll_s=0.001)
  154. supervisor.run()
  155. assert "-v" in spawned[0]
  156. assert "-v" in spawned[1]
  157. def test_supervisor_skips_config_when_not_given():
  158. """--config flag omitted when not in overrides."""
  159. cfg = _cfg()
  160. overrides = {"device": "test"}
  161. spawned = []
  162. def fake_spawn(argv, *, redirect_output=False):
  163. spawned.append(argv)
  164. proc = FakePopen(argv, redirect_output=redirect_output)
  165. if len(spawned) == 1:
  166. proc.returncode = 0
  167. return proc
  168. supervisor = Supervisor(
  169. cfg, with_tui=False, overrides=overrides, spawn=fake_spawn, poll_s=0.001
  170. )
  171. supervisor.run()
  172. assert "--config" not in spawned[0]
  173. assert "--device" in spawned[0]
  174. def test_supervisor_assistant_receives_only_allowed_flags():
  175. """Assistant gets config and verbose, but not device/backend/model."""
  176. cfg = _cfg()
  177. overrides = {
  178. "config": "/config.toml",
  179. "device": "ignore",
  180. "backend": "ignore",
  181. "model": "ignore",
  182. }
  183. spawned = []
  184. def fake_spawn(argv, *, redirect_output=False):
  185. spawned.append(argv)
  186. proc = FakePopen(argv, redirect_output=redirect_output)
  187. if len(spawned) == 1:
  188. proc.returncode = 0
  189. return proc
  190. supervisor = Supervisor(
  191. cfg, with_tui=False, overrides=overrides, verbose=True, spawn=fake_spawn, poll_s=0.001
  192. )
  193. supervisor.run()
  194. assistant_argv = spawned[1]
  195. assert "--config" in assistant_argv
  196. assert "/config.toml" in assistant_argv
  197. assert "-v" in assistant_argv
  198. assert "--device" not in assistant_argv
  199. assert "--backend" not in assistant_argv
  200. assert "--model" not in assistant_argv
  201. def test_supervisor_spawn_redirect_output_false_without_tui():
  202. """spawn() is called with redirect_output=False when with_tui=False."""
  203. cfg = _cfg()
  204. spawned = []
  205. def fake_spawn(argv, *, redirect_output=False):
  206. spawned.append((argv, redirect_output))
  207. proc = FakePopen(argv, redirect_output=redirect_output)
  208. if len(spawned) == 1:
  209. proc.returncode = 0
  210. return proc
  211. supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
  212. supervisor.run()
  213. for argv, redirect_output in spawned:
  214. assert redirect_output is False
  215. def test_supervisor_spawn_redirect_output_true_with_tui():
  216. """spawn() is called with redirect_output=True when with_tui=True."""
  217. cfg = _cfg()
  218. spawned = []
  219. def fake_spawn(argv, *, redirect_output=False):
  220. spawned.append((argv, redirect_output))
  221. proc = FakePopen(argv, redirect_output=redirect_output)
  222. if len(spawned) == 1:
  223. proc.returncode = 0
  224. return proc
  225. supervisor = Supervisor(cfg, with_tui=True, spawn=fake_spawn, wait_timeout_s=0.01, poll_s=0.001)
  226. supervisor.run()
  227. for argv, redirect_output in spawned:
  228. assert redirect_output is True
  229. def test_supervisor_run_spawns_tui_after_control_socket(tmp_path):
  230. """With_tui=True spawns the TUI once the control socket exists; TUI exit stops all."""
  231. cfg = _cfg()
  232. cfg.assistant.control_socket = str(tmp_path / "assistant.sock")
  233. (tmp_path / "assistant.sock").touch()
  234. spawned = []
  235. def fake_spawn(argv, *, redirect_output=False):
  236. proc = FakePopen(argv, redirect_output=redirect_output)
  237. spawned.append((argv, proc))
  238. if len(spawned) == 3:
  239. proc.returncode = 0
  240. return proc
  241. clock_val = [0.0]
  242. def fake_clock():
  243. return clock_val[0]
  244. def fake_sleep(_s):
  245. clock_val[0] += 0.1
  246. supervisor = Supervisor(
  247. cfg,
  248. with_tui=True,
  249. spawn=fake_spawn,
  250. sleep=fake_sleep,
  251. clock=fake_clock,
  252. poll_s=0.001,
  253. )
  254. result = supervisor.run()
  255. assert len(spawned) == 3
  256. assert "stt_daemon.tui" in " ".join(spawned[2][0])
  257. assert spawned[0][1].redirect_output is True
  258. assert spawned[1][1].redirect_output is True
  259. assert spawned[2][1].redirect_output is False
  260. assert result == 0
  261. for _argv, proc in spawned[:2]:
  262. assert proc._terminated or proc._killed
  263. def test_supervisor_force_kills_stubborn_children():
  264. """A child that ignores SIGTERM is force-killed after the grace period."""
  265. cfg = _cfg()
  266. class StubbornPopen(FakePopen):
  267. def terminate(self):
  268. self._terminated = True
  269. daemon = StubbornPopen(["daemon"])
  270. clock_val = [0.0]
  271. def fake_clock():
  272. return clock_val[0]
  273. def fake_sleep(_s):
  274. clock_val[0] += 0.1
  275. supervisor = Supervisor(
  276. cfg, with_tui=False, grace_s=0.05, clock=fake_clock, sleep=fake_sleep
  277. )
  278. supervisor._terminate_all([daemon])
  279. assert daemon._killed
  280. def test_supervisor_run_signal_shutdown_returns_zero(monkeypatch):
  281. """SIGINT stops the loop, terminates every child, and returns 0."""
  282. cfg = _cfg()
  283. handlers = {}
  284. monkeypatch.setattr(
  285. "stt_daemon.supervisor.signal.signal",
  286. lambda signum, handler: handlers.__setitem__(signum, handler),
  287. )
  288. spawned = []
  289. def fake_spawn(argv, *, redirect_output=False):
  290. proc = FakePopen(argv, redirect_output=redirect_output)
  291. spawned.append(proc)
  292. return proc
  293. clock_val = [0.0]
  294. def fake_clock():
  295. return clock_val[0]
  296. def fake_sleep(_s):
  297. clock_val[0] += 0.1
  298. if signal.SIGINT in handlers:
  299. handlers[signal.SIGINT](signal.SIGINT, None)
  300. supervisor = Supervisor(
  301. cfg,
  302. with_tui=False,
  303. spawn=fake_spawn,
  304. sleep=fake_sleep,
  305. clock=fake_clock,
  306. poll_s=0.001,
  307. )
  308. result = supervisor.run()
  309. assert len(spawned) == 2
  310. assert result == 0
  311. for proc in spawned:
  312. assert proc._terminated or proc._killed
  313. def test_default_spawn_discards_output_when_redirected(monkeypatch):
  314. """The default spawn must discard child output, never write a log file."""
  315. calls = []
  316. monkeypatch.setattr(
  317. "stt_daemon.supervisor.subprocess.Popen",
  318. lambda argv, **kwargs: calls.append(kwargs) or FakePopen(argv),
  319. )
  320. supervisor = Supervisor(_cfg(), with_tui=False)
  321. supervisor._spawn_with_output(["x"], redirect_output=True)
  322. supervisor._spawn_with_output(["y"], redirect_output=False)
  323. assert calls[0]["stdout"] is subprocess.DEVNULL
  324. assert calls[0]["stderr"] is subprocess.DEVNULL
  325. assert "stdout" not in calls[1]
  326. assert "stderr" not in calls[1]
  327. def test_supervisor_restores_terminal_after_tui(monkeypatch, tmp_path):
  328. """A killed TUI must not leave the terminal in raw mode."""
  329. cfg = _cfg()
  330. cfg.assistant.control_socket = str(tmp_path / "assistant.sock")
  331. (tmp_path / "assistant.sock").touch()
  332. log = []
  333. monkeypatch.setattr("stt_daemon.supervisor.termios", FakeTermios(log))
  334. monkeypatch.setattr(
  335. "stt_daemon.supervisor.sys",
  336. types.SimpleNamespace(stdin=FakeStdin(), executable=sys.executable),
  337. )
  338. spawned = []
  339. def fake_spawn(argv, *, redirect_output=False):
  340. proc = FakePopen(argv, redirect_output=redirect_output)
  341. spawned.append(proc)
  342. if len(spawned) == 3:
  343. proc.returncode = 0
  344. return proc
  345. clock_val = [0.0]
  346. def fake_clock():
  347. return clock_val[0]
  348. def fake_sleep(_s):
  349. clock_val[0] += 0.1
  350. supervisor = Supervisor(
  351. cfg,
  352. with_tui=True,
  353. spawn=fake_spawn,
  354. sleep=fake_sleep,
  355. clock=fake_clock,
  356. poll_s=0.001,
  357. )
  358. supervisor.run()
  359. assert ("get", 7) in log
  360. assert ("set", 7, FakeTermios.TCSADRAIN, [0, 0, 0, 1, 0, 0, []]) in log
  361. def test_supervisor_leaves_terminal_alone_without_tui(monkeypatch):
  362. """Without --with-tui the supervisor never touches terminal attributes."""
  363. cfg = _cfg()
  364. log = []
  365. monkeypatch.setattr("stt_daemon.supervisor.termios", FakeTermios(log))
  366. monkeypatch.setattr(
  367. "stt_daemon.supervisor.sys",
  368. types.SimpleNamespace(stdin=FakeStdin(), executable=sys.executable),
  369. )
  370. spawned = []
  371. def fake_spawn(argv, *, redirect_output=False):
  372. proc = FakePopen(argv, redirect_output=redirect_output)
  373. spawned.append(proc)
  374. if len(spawned) == 1:
  375. proc.returncode = 0
  376. return proc
  377. supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
  378. supervisor.run()
  379. assert log == []
  380. _TRIAGE_FORBIDDEN_ARGV = ("layasvc", "stt-laya", "laya")
  381. def _assert_no_triage_process(argv_lists):
  382. """No spawned argv may reference a laya service process or module."""
  383. for argv in argv_lists:
  384. joined = " ".join(argv)
  385. for marker in _TRIAGE_FORBIDDEN_ARGV:
  386. assert marker not in joined, f"triage process leaked into argv: {argv}"
  387. def test_supervisor_triage_enabled_headless_still_spawns_two():
  388. """triage.enabled=True headless still spawns exactly daemon + assistant."""
  389. cfg = _cfg()
  390. cfg.triage.enabled = True
  391. spawned = []
  392. def fake_spawn(argv, *, redirect_output=False):
  393. spawned.append(argv)
  394. proc = FakePopen(argv, redirect_output=redirect_output)
  395. if len(spawned) == 1:
  396. proc.returncode = 0
  397. return proc
  398. supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
  399. result = supervisor.run()
  400. assert len(spawned) == 2
  401. assert "stt_daemon" in " ".join(spawned[0]) and "run" in spawned[0]
  402. assert "stt_daemon.assistant" in " ".join(spawned[1])
  403. _assert_no_triage_process(spawned)
  404. assert result == 0
  405. def test_supervisor_triage_enabled_with_tui_still_spawns_three(tmp_path):
  406. """triage.enabled=True with TUI still spawns exactly daemon + assistant + TUI."""
  407. cfg = _cfg()
  408. cfg.triage.enabled = True
  409. cfg.assistant.control_socket = str(tmp_path / "assistant.sock")
  410. (tmp_path / "assistant.sock").touch()
  411. spawned = []
  412. def fake_spawn(argv, *, redirect_output=False):
  413. proc = FakePopen(argv, redirect_output=redirect_output)
  414. spawned.append(argv)
  415. if len(spawned) == 3:
  416. proc.returncode = 0
  417. return proc
  418. clock_val = [0.0]
  419. def fake_clock():
  420. return clock_val[0]
  421. def fake_sleep(_s):
  422. clock_val[0] += 0.1
  423. supervisor = Supervisor(
  424. cfg,
  425. with_tui=True,
  426. spawn=fake_spawn,
  427. sleep=fake_sleep,
  428. clock=fake_clock,
  429. poll_s=0.001,
  430. )
  431. result = supervisor.run()
  432. assert len(spawned) == 3
  433. assert "stt_daemon" in " ".join(spawned[0]) and "run" in spawned[0]
  434. assert "stt_daemon.assistant" in " ".join(spawned[1])
  435. assert "stt_daemon.tui" in " ".join(spawned[2])
  436. _assert_no_triage_process(spawned)
  437. assert result == 0
  438. def test_supervisor_source_never_spawns_a_laya_service():
  439. """Regression guard: supervisor.py itself must not reference a laya process."""
  440. source_path = Path(__file__).resolve().parents[1] / "src" / "stt_daemon" / "supervisor.py"
  441. source = source_path.read_text(encoding="utf-8")
  442. assert "layasvc" not in source
  443. assert "stt-laya" not in source