feat(dictate): support configurable ASR backends
This commit is contained in:
@@ -36,6 +36,18 @@ class TrackingStream(io.BytesIO):
|
||||
return value
|
||||
|
||||
|
||||
class BlockingTrackingStream(TrackingStream):
|
||||
def __init__(self, value: bytes, events: list[str], name: str) -> None:
|
||||
super().__init__(value, events, name)
|
||||
self.allow_eof = threading.Event()
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
value = super().read(size)
|
||||
if not value:
|
||||
self.allow_eof.wait(1)
|
||||
return value
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,6 +57,7 @@ class FakeProcess:
|
||||
returncode: int | None = None,
|
||||
timeout: bool = False,
|
||||
terminate_returncode: int = -15,
|
||||
kill_stalls: bool = False,
|
||||
) -> None:
|
||||
self.pid = 1234
|
||||
self.stdout = stdout
|
||||
@@ -52,6 +65,7 @@ class FakeProcess:
|
||||
self.returncode = returncode
|
||||
self._timeout = timeout
|
||||
self._terminate_returncode = terminate_returncode
|
||||
self._kill_stalls = kill_stalls
|
||||
self.events = stdout._events
|
||||
|
||||
def poll(self) -> int | None:
|
||||
@@ -64,8 +78,9 @@ class FakeProcess:
|
||||
|
||||
def kill(self) -> None:
|
||||
self.events.append("kill")
|
||||
self.returncode = -9
|
||||
self._timeout = False
|
||||
if not self._kill_stalls:
|
||||
self.returncode = -9
|
||||
self._timeout = False
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
self.events.append("wait")
|
||||
@@ -86,6 +101,22 @@ class ProcessFactory:
|
||||
return self.process
|
||||
|
||||
|
||||
class StreamingSink:
|
||||
def __init__(self) -> None:
|
||||
self.chunks: list[bytes] = []
|
||||
self.finalized = False
|
||||
self.cancelled = False
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None:
|
||||
self.chunks.append(chunk)
|
||||
|
||||
def close_input(self) -> None:
|
||||
self.finalized = True
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
@@ -95,6 +126,16 @@ class Clock:
|
||||
|
||||
|
||||
class PwRecorderTests(unittest.TestCase):
|
||||
def test_spawn_failure_is_already_cleanup_complete(self) -> None:
|
||||
def fail_spawn(*_args: object, **_kwargs: object) -> FakeProcess:
|
||||
raise OSError("spawn failed")
|
||||
|
||||
recorder = PwRecorder(process_factory=fail_spawn)
|
||||
|
||||
with self.assertRaisesRegex(RecorderError, "could not start"):
|
||||
recorder.start()
|
||||
self.assertTrue(recorder.cleanup_complete)
|
||||
|
||||
def make_recorder(
|
||||
self,
|
||||
pcm: bytes = b"\x00\x00\x01\x00",
|
||||
@@ -107,14 +148,23 @@ class PwRecorderTests(unittest.TestCase):
|
||||
clock: Clock | None = None,
|
||||
terminate_returncode: int = -15,
|
||||
stdout_read_size: int | None = None,
|
||||
kill_stalls: bool = False,
|
||||
block_stdout_at_eof: bool = False,
|
||||
pcm_sink: StreamingSink | None = None,
|
||||
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
|
||||
events: list[str] = []
|
||||
stdout = (
|
||||
BlockingTrackingStream(pcm, events, "stdout")
|
||||
if block_stdout_at_eof
|
||||
else TrackingStream(pcm, events, "stdout", stdout_read_size)
|
||||
)
|
||||
process = FakeProcess(
|
||||
TrackingStream(pcm, events, "stdout", stdout_read_size),
|
||||
stdout,
|
||||
TrackingStream(stderr, events, "stderr"),
|
||||
returncode=returncode,
|
||||
timeout=timeout,
|
||||
terminate_returncode=terminate_returncode,
|
||||
kill_stalls=kill_stalls,
|
||||
)
|
||||
factory = ProcessFactory(process)
|
||||
def signal_group(pid: int, signal_number: int) -> None:
|
||||
@@ -133,9 +183,44 @@ class PwRecorderTests(unittest.TestCase):
|
||||
stop_timeout=0.01,
|
||||
clock=clock or Clock(),
|
||||
process_group_signal=signal_group,
|
||||
pcm_sink=pcm_sink,
|
||||
)
|
||||
return recorder, process, factory, events
|
||||
|
||||
def test_successful_stop_finalizes_streaming_sink(self) -> None:
|
||||
sink = StreamingSink()
|
||||
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
self.assertTrue(sink.finalized)
|
||||
self.assertFalse(sink.cancelled)
|
||||
|
||||
def test_cancel_aborts_streaming_sink_without_finalizing(self) -> None:
|
||||
sink = StreamingSink()
|
||||
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
recorder.cancel()
|
||||
|
||||
self.assertTrue(sink.cancelled)
|
||||
self.assertFalse(sink.finalized)
|
||||
|
||||
def test_unexpected_exit_aborts_streaming_sink_without_finalizing(self) -> None:
|
||||
sink = StreamingSink()
|
||||
recorder, process, _factory, _events = self.make_recorder(returncode=3, pcm_sink=sink)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
error = recorder.poll_unexpected()
|
||||
|
||||
self.assertIsNotNone(error)
|
||||
self.assertTrue(sink.cancelled)
|
||||
self.assertFalse(sink.finalized)
|
||||
|
||||
def test_spawns_exact_pw_record_command_and_drains_bounded_streams(self) -> None:
|
||||
recorder, process, factory, events = self.make_recorder(
|
||||
pcm=b"\x00\x00\x01\x00", stderr=b"0123456789", stderr_limit=8
|
||||
@@ -230,6 +315,39 @@ class PwRecorderTests(unittest.TestCase):
|
||||
with self.assertRaises(RecorderError):
|
||||
noisy.stop()
|
||||
|
||||
def test_shutdown_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
timeout=True,
|
||||
kill_stalls=True,
|
||||
)
|
||||
recorder.start()
|
||||
|
||||
with self.assertRaisesRegex(RecorderError, "after SIGKILL") as error:
|
||||
recorder.stop()
|
||||
|
||||
self.assertEqual(error.exception.code, "recorder_timeout")
|
||||
self.assertIn("kill", process.events)
|
||||
|
||||
def test_shutdown_retries_process_and_drainer_cleanup_after_double_timeout(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
timeout=True,
|
||||
kill_stalls=True,
|
||||
block_stdout_at_eof=True,
|
||||
)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
with self.assertRaises(RecorderError):
|
||||
recorder.stop()
|
||||
|
||||
self.assertFalse(recorder.cleanup_complete)
|
||||
process._kill_stalls = False
|
||||
process.stdout.allow_eof.set()
|
||||
|
||||
recorder.cancel()
|
||||
|
||||
self.assertTrue(recorder.cleanup_complete)
|
||||
|
||||
def test_shutdown_timeout_escalates_to_kill_and_fails(self) -> None:
|
||||
recorder, _process, _factory, events = self.make_recorder(timeout=True)
|
||||
recorder.start()
|
||||
@@ -240,6 +358,22 @@ class PwRecorderTests(unittest.TestCase):
|
||||
self.assertEqual(failed.exception.code, "recorder_timeout")
|
||||
self.assertLess(events.index("terminate"), events.index("kill"))
|
||||
|
||||
def test_stop_rejects_an_exit_observed_after_the_last_listening_poll(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder()
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
# The process was alive during the listening poll, then exited cleanly
|
||||
# before stop could signal it. A partial buffer is not a valid capture.
|
||||
self.assertIsNone(recorder.poll_unexpected())
|
||||
process.returncode = 0
|
||||
|
||||
with self.assertRaises(RecorderError) as failed:
|
||||
recorder.stop()
|
||||
|
||||
self.assertEqual(failed.exception.code, "recorder_exit")
|
||||
self.assertEqual(recorder.buffered_bytes, 0)
|
||||
|
||||
def test_classifies_unexpected_exit_code_and_signal(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
stderr=b"pipewire failed", returncode=7
|
||||
@@ -288,6 +422,25 @@ class PwRecorderTests(unittest.TestCase):
|
||||
self.assertEqual(failed.exception.code, "duration_limit")
|
||||
self.assertEqual(recorder.buffered_bytes, 0)
|
||||
|
||||
def test_duration_limit_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
|
||||
clock = Clock()
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
pcm=b"\x00\x00" * 3,
|
||||
max_bytes=4,
|
||||
timeout=True,
|
||||
kill_stalls=True,
|
||||
clock=clock,
|
||||
)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
clock.now = 1
|
||||
|
||||
failure = recorder.poll_unexpected()
|
||||
|
||||
self.assertIsNotNone(failure)
|
||||
self.assertEqual(failure.code, "recorder_timeout")
|
||||
self.assertIn("kill", process.events)
|
||||
|
||||
def test_duration_limit_kills_a_recorder_that_ignores_sigterm(self) -> None:
|
||||
clock = Clock()
|
||||
recorder, process, _factory, events = self.make_recorder(
|
||||
|
||||
Reference in New Issue
Block a user