test_supervisor.py 13 KB

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