|
|
@@ -1,5 +1,6 @@
|
|
|
"""Tests for the daemon's publish-only event stream (Phase 1)."""
|
|
|
|
|
|
+import concurrent.futures
|
|
|
import logging
|
|
|
import os
|
|
|
import shutil
|
|
|
@@ -7,6 +8,7 @@ import socket
|
|
|
import tempfile
|
|
|
import threading
|
|
|
import time
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
|
import numpy as np
|
|
|
import pytest
|
|
|
@@ -15,6 +17,8 @@ from stt_daemon import events
|
|
|
from stt_daemon.bus import EventSubscriber
|
|
|
from stt_daemon.config import Config
|
|
|
from stt_daemon.daemon import Daemon
|
|
|
+from stt_daemon.segmenter import Utterance
|
|
|
+from stt_daemon.supervisor import Supervisor
|
|
|
from stt_daemon.transcriber import TranscriptResult
|
|
|
|
|
|
FRAME_SAMPLES = 480
|
|
|
@@ -165,7 +169,7 @@ def _base_config(sock_path):
|
|
|
return cfg
|
|
|
|
|
|
|
|
|
-def _start_daemon(cfg, capture, vad, transcriber):
|
|
|
+def _start_daemon(cfg, capture, vad, transcriber, debug_sink_factory=None):
|
|
|
sink = FakeSink()
|
|
|
daemon = Daemon(
|
|
|
cfg,
|
|
|
@@ -174,6 +178,7 @@ def _start_daemon(cfg, capture, vad, transcriber):
|
|
|
vad_factory=lambda _aggr: vad,
|
|
|
transcriber_factory=lambda: transcriber,
|
|
|
sink_factory=lambda: sink,
|
|
|
+ debug_sink_factory=debug_sink_factory,
|
|
|
)
|
|
|
daemon._initialize()
|
|
|
subscriber = EventSubscriber(cfg.events.socket, reconnect_delay=0.05)
|
|
|
@@ -387,6 +392,488 @@ def test_inbound_data_on_subscriber_socket_is_ignored(sock_path):
|
|
|
daemon.stop()
|
|
|
|
|
|
|
|
|
+class FakeDebugSink:
|
|
|
+ """In-memory DebugSink stand-in (duck-typed emit/close, thread-safe)."""
|
|
|
+
|
|
|
+ def __init__(self, dir="debug", proc="daemon"):
|
|
|
+ self.dir = dir
|
|
|
+ self.proc = proc
|
|
|
+ self.emitted = []
|
|
|
+ self.closed = False
|
|
|
+ self._lock = threading.Lock()
|
|
|
+
|
|
|
+ def emit(self, type_, **fields):
|
|
|
+ with self._lock:
|
|
|
+ self.emitted.append({"type": type_, **fields})
|
|
|
+
|
|
|
+ def types(self):
|
|
|
+ with self._lock:
|
|
|
+ return [e["type"] for e in self.emitted]
|
|
|
+
|
|
|
+ def of(self, type_):
|
|
|
+ with self._lock:
|
|
|
+ return [e for e in self.emitted if e["type"] == type_]
|
|
|
+
|
|
|
+ def close(self):
|
|
|
+ self.closed = True
|
|
|
+
|
|
|
+
|
|
|
+class BoomDebugSink(FakeDebugSink):
|
|
|
+ """Debug sink whose emit always throws (total-swallow check)."""
|
|
|
+
|
|
|
+ def emit(self, type_, **fields):
|
|
|
+ raise RuntimeError("disk gone")
|
|
|
+
|
|
|
+
|
|
|
+def _enable_debug(cfg, enabled=True, heartbeat=True):
|
|
|
+ cfg.debug = SimpleNamespace(enabled=enabled, heartbeat=heartbeat, dir="debug")
|
|
|
+
|
|
|
+
|
|
|
+def _wait_until(predicate, timeout=5.0):
|
|
|
+ deadline = time.time() + timeout
|
|
|
+ while time.time() < deadline:
|
|
|
+ if predicate():
|
|
|
+ return True
|
|
|
+ time.sleep(0.01)
|
|
|
+ return predicate()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_sink_none_when_disabled(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: FakeCapture([_frame()]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: FakeTranscriber(),
|
|
|
+ sink_factory=lambda: FakeSink(),
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+ assert daemon.debug_sink is None
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_heartbeat_lines_carry_health_payload(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ cfg.events.heartbeat_ms = 40
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ frames = [_frame() for _ in range(40)]
|
|
|
+ daemon, collector, subscriber, _ = _start_daemon(
|
|
|
+ cfg,
|
|
|
+ FakeCapture(frames, delay=0.02),
|
|
|
+ FakeVad([]),
|
|
|
+ FakeTranscriber(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert daemon.debug_sink is debug
|
|
|
+ assert _wait_until(lambda: bool(debug.of("heartbeat")), timeout=5.0)
|
|
|
+
|
|
|
+ beat = debug.of("heartbeat")[0]
|
|
|
+ assert beat["model_loaded"] is True
|
|
|
+ for field in ("queue_depth", "device_ok", "worker_stall_s", "rms", "uptime_s"):
|
|
|
+ assert field in beat
|
|
|
+
|
|
|
+ subscriber.close()
|
|
|
+ daemon.stop()
|
|
|
+ assert debug.closed is True
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_heartbeat_gate_off_leaves_only_anomalies(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ cfg.events.heartbeat_ms = 40
|
|
|
+ _enable_debug(cfg, heartbeat=False)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ daemon, collector, subscriber, _ = _start_daemon(
|
|
|
+ cfg,
|
|
|
+ FakeCapture([_frame() for _ in range(20)], delay=0.02),
|
|
|
+ FakeVad([]),
|
|
|
+ FakeTranscriber(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert _wait_until(
|
|
|
+ lambda: any(e["type"] == events.HEARTBEAT for e in collector.events),
|
|
|
+ timeout=5.0,
|
|
|
+ )
|
|
|
+ assert daemon.debug_sink is debug
|
|
|
+ assert debug.of("heartbeat") == []
|
|
|
+
|
|
|
+ subscriber.close()
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_reopen_attempt_success_and_fail_lines(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: FakeCapture([_frame()]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: FakeTranscriber(),
|
|
|
+ sink_factory=lambda: FakeSink(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+
|
|
|
+ calls = []
|
|
|
+
|
|
|
+ def flaky_factory():
|
|
|
+ calls.append(1)
|
|
|
+ if len(calls) == 1:
|
|
|
+ raise RuntimeError("no device")
|
|
|
+ return FakeCapture([_frame()])
|
|
|
+
|
|
|
+ daemon._capture_factory = flaky_factory
|
|
|
+ assert daemon._reopen_capture(attempts=2, delay_s=0.0) is True
|
|
|
+ assert debug.types().count("capture_reopen_attempt") == 2
|
|
|
+ fails = debug.of("capture_reopen_fail")
|
|
|
+ assert len(fails) == 1
|
|
|
+ assert "no device" in fails[0]["error"]
|
|
|
+ assert len(debug.of("capture_reopen_ok")) == 1
|
|
|
+
|
|
|
+ def dead_factory():
|
|
|
+ raise RuntimeError("still gone")
|
|
|
+
|
|
|
+ daemon._capture_factory = dead_factory
|
|
|
+ assert daemon._reopen_capture(attempts=2, delay_s=0.0) is False
|
|
|
+ assert debug.types().count("capture_reopen_attempt") == 4
|
|
|
+ assert len(debug.of("capture_reopen_fail")) == 3
|
|
|
+
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_capture_failure_emits_fatal_and_stops(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+
|
|
|
+ class BoomCapture(FakeCapture):
|
|
|
+ def frames(self):
|
|
|
+ raise RuntimeError("device lost")
|
|
|
+ yield
|
|
|
+
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: BoomCapture([]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: FakeTranscriber(),
|
|
|
+ sink_factory=lambda: FakeSink(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+
|
|
|
+ def dead_factory():
|
|
|
+ raise RuntimeError("device lost")
|
|
|
+
|
|
|
+ daemon._capture_factory = dead_factory
|
|
|
+ daemon._start_workers()
|
|
|
+
|
|
|
+ assert daemon._stop_event.wait(timeout=10.0)
|
|
|
+ daemon.stop()
|
|
|
+ assert "capture_reopen_attempt" in debug.types()
|
|
|
+ assert "reader_fatal" in debug.types()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_clean_exhaustion_emits_no_fatal(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ daemon, collector, subscriber, _ = _start_daemon(
|
|
|
+ cfg,
|
|
|
+ FakeCapture([_frame()]),
|
|
|
+ FakeVad([False]),
|
|
|
+ FakeTranscriber(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+
|
|
|
+ reader = daemon._reader_thread
|
|
|
+ assert reader is not None
|
|
|
+ assert _wait_until(lambda: not reader.is_alive(), timeout=5.0)
|
|
|
+ assert not daemon._stop_event.is_set()
|
|
|
+ assert "reader_fatal" not in debug.types()
|
|
|
+ assert "worker_fatal" not in debug.types()
|
|
|
+
|
|
|
+ subscriber.close()
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_queue_drop_and_backlog_warn(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ cfg.daemon.queue_size = 2
|
|
|
+ cfg.daemon.backlog_warn_at = 1
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: FakeCapture([_frame()]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: FakeTranscriber(),
|
|
|
+ sink_factory=lambda: FakeSink(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+
|
|
|
+ def _utt(i):
|
|
|
+ return Utterance(
|
|
|
+ audio=np.zeros(160, dtype=np.float32),
|
|
|
+ duration_s=0.01,
|
|
|
+ started_at=time.time(),
|
|
|
+ rms=0.0,
|
|
|
+ reason="end_of_turn",
|
|
|
+ seg_id=i,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert daemon._enqueue(_utt(1)) is True
|
|
|
+ assert daemon._enqueue(_utt(2)) is True
|
|
|
+ assert daemon._enqueue(_utt(3)) is True # drops seg 1
|
|
|
+ daemon._check_backlog()
|
|
|
+
|
|
|
+ drops = debug.of("queue_drop")
|
|
|
+ assert len(drops) == 1
|
|
|
+ assert drops[0]["dropped_seg_id"] == 1
|
|
|
+ warns = debug.of("backlog_warn")
|
|
|
+ assert len(warns) == 1
|
|
|
+ assert warns[0]["size"] == 2
|
|
|
+
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_speaker_fallback_on_lane_failure(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ cfg.speaker.join_timeout_s = 0.05
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: FakeCapture([_frame()]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: FakeTranscriber(),
|
|
|
+ sink_factory=lambda: FakeSink(),
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+
|
|
|
+ failed: concurrent.futures.Future = concurrent.futures.Future()
|
|
|
+ failed.set_exception(RuntimeError("lane died"))
|
|
|
+ assert daemon._resolve_speaker(failed) == (None, "none", "?", None)
|
|
|
+
|
|
|
+ pending: concurrent.futures.Future = concurrent.futures.Future()
|
|
|
+ assert daemon._resolve_speaker(pending) == (None, "none", "?", None)
|
|
|
+
|
|
|
+ fallbacks = debug.of("speaker_fallback")
|
|
|
+ assert len(fallbacks) == 2
|
|
|
+ assert "lane died" in fallbacks[0]["error"]
|
|
|
+
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_sink_closed_last_in_stop(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ _enable_debug(cfg)
|
|
|
+ order = []
|
|
|
+ transcriber = FakeTranscriber()
|
|
|
+ transcriber.close = lambda: order.append("transcriber")
|
|
|
+ sink = FakeSink()
|
|
|
+ sink.close = lambda: order.append("sink")
|
|
|
+ debug = FakeDebugSink()
|
|
|
+ orig_debug_close = debug.close
|
|
|
+
|
|
|
+ def tracked_debug_close() -> None:
|
|
|
+ order.append("debug")
|
|
|
+ orig_debug_close()
|
|
|
+
|
|
|
+ debug.close = tracked_debug_close
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: FakeCapture([_frame()]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: transcriber,
|
|
|
+ sink_factory=lambda: sink,
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+ assert daemon.publisher is not None
|
|
|
+ orig_pub_close = daemon.publisher.close
|
|
|
+
|
|
|
+ def tracked_pub_close() -> None:
|
|
|
+ order.append("publisher")
|
|
|
+ orig_pub_close()
|
|
|
+
|
|
|
+ daemon.publisher.close = tracked_pub_close
|
|
|
+
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+ assert order == ["transcriber", "sink", "publisher", "debug"]
|
|
|
+ assert debug.closed is True
|
|
|
+ assert daemon.debug_sink is None
|
|
|
+
|
|
|
+
|
|
|
+def test_debug_factory_throw_disables_sink(sock_path):
|
|
|
+ cfg = _base_config(sock_path)
|
|
|
+ _enable_debug(cfg)
|
|
|
+
|
|
|
+ def boom_factory():
|
|
|
+ raise RuntimeError("no dir")
|
|
|
+
|
|
|
+ daemon = Daemon(
|
|
|
+ cfg,
|
|
|
+ log_level=logging.WARNING,
|
|
|
+ capture_factory=lambda: FakeCapture([_frame()]),
|
|
|
+ vad_factory=lambda _aggr: FakeVad([]),
|
|
|
+ transcriber_factory=lambda: FakeTranscriber(),
|
|
|
+ sink_factory=lambda: FakeSink(),
|
|
|
+ debug_sink_factory=boom_factory,
|
|
|
+ )
|
|
|
+ daemon._initialize()
|
|
|
+ assert daemon.debug_sink is None
|
|
|
+ daemon.stop()
|
|
|
+
|
|
|
+
|
|
|
+class FakeProc:
|
|
|
+ """Minimal Popen stand-in for supervisor debug tests."""
|
|
|
+
|
|
|
+ _pid_counter = 20000
|
|
|
+
|
|
|
+ def __init__(self, exit_on_poll=None, stubborn=False):
|
|
|
+ self.pid = FakeProc._pid_counter
|
|
|
+ FakeProc._pid_counter += 1
|
|
|
+ self._exit_on_poll = exit_on_poll
|
|
|
+ self._stubborn = stubborn
|
|
|
+ self.returncode = None
|
|
|
+ self.terminated = False
|
|
|
+ self.killed = False
|
|
|
+
|
|
|
+ def poll(self):
|
|
|
+ if self.returncode is None and self._exit_on_poll is not None:
|
|
|
+ self.returncode = self._exit_on_poll
|
|
|
+ return self.returncode
|
|
|
+
|
|
|
+ def terminate(self):
|
|
|
+ self.terminated = True
|
|
|
+ if not self._stubborn and self.returncode is None:
|
|
|
+ self.returncode = -15
|
|
|
+
|
|
|
+ def kill(self):
|
|
|
+ self.killed = True
|
|
|
+ if self.returncode is None:
|
|
|
+ self.returncode = -9
|
|
|
+
|
|
|
+ def wait(self, timeout=None):
|
|
|
+ if self.returncode is None:
|
|
|
+ self.returncode = 0
|
|
|
+ return self.returncode
|
|
|
+
|
|
|
+
|
|
|
+def _supervisor_spawn(procs):
|
|
|
+ def fake_spawn(argv, *, redirect_output=False):
|
|
|
+ joined = " ".join(argv)
|
|
|
+ if "stt_daemon.assistant" in joined:
|
|
|
+ return procs["assistant"]
|
|
|
+ return procs["daemon"]
|
|
|
+
|
|
|
+ return fake_spawn
|
|
|
+
|
|
|
+
|
|
|
+def test_supervisor_debug_spawn_exit_shutdown_lines():
|
|
|
+ cfg = Config()
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink(proc="supervisor")
|
|
|
+ procs = {"daemon": FakeProc(exit_on_poll=3), "assistant": FakeProc()}
|
|
|
+ supervisor = Supervisor(
|
|
|
+ cfg,
|
|
|
+ with_tui=False,
|
|
|
+ spawn=_supervisor_spawn(procs),
|
|
|
+ sleep=lambda s: None,
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert supervisor.run() == 3
|
|
|
+ assert debug.types() == ["spawn", "spawn", "child_exit", "shutdown"]
|
|
|
+ assert debug.emitted[2]["code"] == 3
|
|
|
+ assert debug.emitted[3]["codes"] == [3, -15]
|
|
|
+
|
|
|
+
|
|
|
+def test_supervisor_debug_kill_escalation_line():
|
|
|
+ cfg = Config()
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink(proc="supervisor")
|
|
|
+ daemon_proc = FakeProc(stubborn=True)
|
|
|
+ procs = {"daemon": daemon_proc, "assistant": FakeProc(exit_on_poll=1)}
|
|
|
+ supervisor = Supervisor(
|
|
|
+ cfg,
|
|
|
+ with_tui=False,
|
|
|
+ spawn=_supervisor_spawn(procs),
|
|
|
+ sleep=lambda s: None,
|
|
|
+ grace_s=0,
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert supervisor.run() == 1
|
|
|
+ assert daemon_proc.killed is True
|
|
|
+ kills = debug.of("kill_escalation")
|
|
|
+ assert len(kills) == 1
|
|
|
+ assert kills[0]["pids"] == [daemon_proc.pid]
|
|
|
+
|
|
|
+
|
|
|
+def test_supervisor_debug_socket_timeout_line(tmp_path):
|
|
|
+ cfg = Config()
|
|
|
+ cfg.assistant.control_socket = str(tmp_path / "missing.sock")
|
|
|
+ _enable_debug(cfg)
|
|
|
+ debug = FakeDebugSink(proc="supervisor")
|
|
|
+ procs = {"daemon": FakeProc(), "assistant": FakeProc()}
|
|
|
+ supervisor = Supervisor(
|
|
|
+ cfg,
|
|
|
+ with_tui=True,
|
|
|
+ spawn=_supervisor_spawn(procs),
|
|
|
+ sleep=lambda s: None,
|
|
|
+ wait_timeout_s=0,
|
|
|
+ debug_sink_factory=lambda: debug,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert supervisor.run() == 1
|
|
|
+ assert len(debug.of("socket_timeout")) == 1
|
|
|
+ assert debug.of("shutdown")
|
|
|
+
|
|
|
+
|
|
|
+def test_supervisor_debug_disabled_never_builds_sink():
|
|
|
+ cfg = Config()
|
|
|
+ calls = []
|
|
|
+ procs = {"daemon": FakeProc(exit_on_poll=2), "assistant": FakeProc()}
|
|
|
+ supervisor = Supervisor(
|
|
|
+ cfg,
|
|
|
+ with_tui=False,
|
|
|
+ spawn=_supervisor_spawn(procs),
|
|
|
+ sleep=lambda s: None,
|
|
|
+ debug_sink_factory=lambda: calls.append(1),
|
|
|
+ )
|
|
|
+
|
|
|
+ assert supervisor.run() == 2
|
|
|
+ assert calls == []
|
|
|
+
|
|
|
+
|
|
|
+def test_supervisor_debug_emit_throw_never_breaks_shutdown():
|
|
|
+ cfg = Config()
|
|
|
+ _enable_debug(cfg)
|
|
|
+ procs = {"daemon": FakeProc(exit_on_poll=4), "assistant": FakeProc()}
|
|
|
+ supervisor = Supervisor(
|
|
|
+ cfg,
|
|
|
+ with_tui=False,
|
|
|
+ spawn=_supervisor_spawn(procs),
|
|
|
+ sleep=lambda s: None,
|
|
|
+ debug_sink_factory=lambda: BoomDebugSink(proc="supervisor"),
|
|
|
+ )
|
|
|
+
|
|
|
+ assert supervisor.run() == 4
|
|
|
+
|
|
|
+
|
|
|
def test_request_stop_shuts_down_run_loop(tmp_path):
|
|
|
"""request_stop() (the signal-handler path) must unwind run() cleanly."""
|
|
|
sock_path = str(tmp_path / "events.sock")
|