| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- """Tests for the publish-only event bus and subscriber."""
- import os
- import shutil
- import socket
- import tempfile
- import threading
- import time
- import pytest
- from stt_daemon import events
- from stt_daemon.bus import EventPublisher, EventSubscriber
- @pytest.fixture
- def sock_path():
- # Short path: AF_UNIX paths are limited to ~108 bytes.
- directory = tempfile.mkdtemp(prefix="sttb-")
- try:
- yield os.path.join(directory, "s.sock")
- finally:
- shutil.rmtree(directory, ignore_errors=True)
- def _wait(predicate, timeout=3.0):
- deadline = time.time() + timeout
- while time.time() < deadline:
- if predicate():
- return True
- time.sleep(0.01)
- return predicate()
- class _Collector:
- def __init__(self, subscriber):
- self.events = []
- self._thread = threading.Thread(target=self._run, args=(subscriber,), daemon=True)
- self._thread.start()
- def _run(self, subscriber):
- try:
- for event in subscriber.events():
- self.events.append(event)
- except Exception: # noqa: BLE001 - collector must never crash the test thread
- pass
- def types(self):
- return [e["type"] for e in self.events]
- def wait_for(self, predicate, timeout=3.0):
- return _wait(lambda: predicate(self.events), timeout)
- def _has(event_list, type_):
- return any(e["type"] == type_ for e in event_list)
- def _count(event_list, type_):
- return sum(1 for e in event_list if e["type"] == type_)
- def test_publish_subscribe_in_order(sock_path):
- pub = EventPublisher(sock_path, ring_size=10)
- pub.start()
- sub = EventSubscriber(sock_path, reconnect_delay=0.05)
- collector = _Collector(sub)
- assert collector.wait_for(lambda e: _has(e, events.STATUS))
- for _ in range(5):
- pub.emit(events.SPEECH_START)
- assert collector.wait_for(lambda e: _count(e, events.SPEECH_START) == 5)
- seqs = [e["seq"] for e in collector.events if e["type"] == events.SPEECH_START]
- assert seqs == sorted(seqs)
- sub.close()
- pub.close()
- def test_emit_assigns_monotonic_seq(sock_path):
- pub = EventPublisher(sock_path, ring_size=10)
- pub.start()
- sub = EventSubscriber(sock_path, reconnect_delay=0.05)
- collector = _Collector(sub)
- assert collector.wait_for(lambda e: _has(e, events.STATUS))
- for _ in range(3):
- pub.emit(events.HEARTBEAT)
- assert collector.wait_for(lambda e: _count(e, events.HEARTBEAT) == 3)
- seqs = [e["seq"] for e in collector.events if e["type"] == events.HEARTBEAT]
- assert seqs == sorted(seqs)
- assert len(set(seqs)) == 3
- sub.close()
- pub.close()
- def test_ring_replay_on_attach(sock_path):
- pub = EventPublisher(sock_path, ring_size=10)
- pub.start()
- for i in range(3):
- pub.emit(events.TRANSCRIPT, text=f"t{i}")
- assert _wait(lambda: pub.ring_len() == 3)
- sub = EventSubscriber(sock_path, reconnect_delay=0.05)
- collector = _Collector(sub)
- assert collector.wait_for(lambda e: _has(e, events.RING_END))
- types = collector.types()
- assert types[0] == events.STATUS
- ring_end_index = types.index(events.RING_END)
- assert types[1:ring_end_index] == [events.TRANSCRIPT] * 3
- sub.close()
- pub.close()
- def test_two_subscribers_each_get_their_own_replay(sock_path):
- pub = EventPublisher(sock_path, ring_size=10)
- pub.start()
- pub.emit(events.TRANSCRIPT, text="a")
- assert _wait(lambda: pub.ring_len() == 1)
- subs = [EventSubscriber(sock_path, reconnect_delay=0.05) for _ in range(2)]
- collectors = [_Collector(s) for s in subs]
- for collector in collectors:
- assert collector.wait_for(lambda e: _has(e, events.RING_END))
- types = collector.types()
- assert types[0] == events.STATUS
- assert types[1:types.index(events.RING_END)] == [events.TRANSCRIPT]
- for sub in subs:
- sub.close()
- pub.close()
- def test_publisher_never_blocks_on_slow_subscriber(sock_path):
- pub = EventPublisher(sock_path, ring_size=50)
- pub.start()
- # A raw client that connects and never reads: its writer blocks in sendall.
- slow = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- slow.connect(sock_path)
- assert _wait(lambda: pub.client_count() == 1)
- total = 20000
- start = time.perf_counter()
- for _ in range(total):
- pub.emit(events.HEARTBEAT, rms=0.01)
- elapsed = time.perf_counter() - start
- assert elapsed < 2.0, f"publish path blocked: {elapsed:.3f}s for {total} events"
- slow.close()
- assert _wait(lambda: pub.client_count() == 0)
- # The publisher is still healthy: a fresh subscriber attaches promptly.
- sub = EventSubscriber(sock_path, reconnect_delay=0.05)
- collector = _Collector(sub)
- assert collector.wait_for(lambda e: _has(e, events.RING_END))
- sub.close()
- pub.close()
- def test_subscriber_reconnects_after_restart(sock_path):
- pub = EventPublisher(sock_path, ring_size=10)
- pub.start()
- sub = EventSubscriber(sock_path, reconnect_delay=0.05)
- collector = _Collector(sub)
- assert collector.wait_for(lambda e: _has(e, events.STATUS))
- pub.emit(events.TRANSCRIPT, text="one")
- assert collector.wait_for(lambda e: _count(e, events.TRANSCRIPT) == 1)
- pub.close()
- pub2 = EventPublisher(sock_path, ring_size=10)
- pub2.start()
- assert collector.wait_for(lambda e: _count(e, events.STATUS) >= 2, timeout=5.0)
- pub2.emit(events.TRANSCRIPT, text="two")
- assert collector.wait_for(lambda e: _count(e, events.TRANSCRIPT) == 2, timeout=5.0)
- sub.close()
- pub2.close()
- def test_socket_is_private_and_removed_on_close(sock_path):
- pub = EventPublisher(sock_path, ring_size=10)
- pub.start()
- assert os.path.exists(sock_path)
- assert os.stat(sock_path).st_mode & 0o777 == 0o600
- pub.close()
- assert not os.path.exists(sock_path)
- def test_status_carries_provider_fields(sock_path):
- pub = EventPublisher(
- sock_path,
- ring_size=10,
- status_provider=lambda: {"capture": "VirtualMicSink.monitor", "segments_done": 7},
- )
- pub.start()
- sub = EventSubscriber(sock_path, reconnect_delay=0.05)
- collector = _Collector(sub)
- assert collector.wait_for(lambda e: _has(e, events.STATUS))
- status = next(e for e in collector.events if e["type"] == events.STATUS)
- assert status["capture"] == "VirtualMicSink.monitor"
- assert status["segments_done"] == 7
- assert "ring_seq" in status
- sub.close()
- pub.close()
|