test_supervisor.py 14 KB

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