Files
harness-tools/services/dictate/tests/test_recorder.py
T

247 lines
8.2 KiB
Python

import io
import subprocess
import threading
import unittest
from pi_dictate_service.recorder import PW_RECORD_COMMAND, PwRecorder, RecorderError
class TrackingStream(io.BytesIO):
def __init__(self, value: bytes, events: list[str], name: str) -> None:
super().__init__(value)
self._events = events
self._name = name
self.eof = threading.Event()
def read(self, size: int = -1) -> bytes:
value = super().read(size)
if not value and not self.eof.is_set():
self._events.append(f"{self._name}-eof")
self.eof.set()
return value
class FakeProcess:
def __init__(
self,
stdout: TrackingStream,
stderr: TrackingStream,
*,
returncode: int | None = None,
timeout: bool = False,
terminate_returncode: int = -15,
) -> None:
self.pid = 1234
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
self._timeout = timeout
self._terminate_returncode = terminate_returncode
self.events = stdout._events
def poll(self) -> int | None:
return self.returncode
def terminate(self) -> None:
self.events.append("terminate")
if not self._timeout:
self.returncode = self._terminate_returncode
def kill(self) -> None:
self.events.append("kill")
self.returncode = -9
self._timeout = False
def wait(self, timeout: float | None = None) -> int:
self.events.append("wait")
if self._timeout:
raise subprocess.TimeoutExpired("pw-record", timeout)
if self.returncode is None:
self.returncode = 0
return self.returncode
class ProcessFactory:
def __init__(self, process: FakeProcess) -> None:
self.process = process
self.calls: list[tuple[list[str], dict[str, object]]] = []
def __call__(self, command: list[str], **kwargs: object) -> FakeProcess:
self.calls.append((command, kwargs))
return self.process
class Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
class PwRecorderTests(unittest.TestCase):
def make_recorder(
self,
pcm: bytes = b"\x00\x00\x01\x00",
stderr: bytes = b"",
*,
returncode: int | None = None,
timeout: bool = False,
max_bytes: int = 1024,
stderr_limit: int = 64 * 1024,
clock: Clock | None = None,
terminate_returncode: int = -15,
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
events: list[str] = []
process = FakeProcess(
TrackingStream(pcm, events, "stdout"),
TrackingStream(stderr, events, "stderr"),
returncode=returncode,
timeout=timeout,
terminate_returncode=terminate_returncode,
)
factory = ProcessFactory(process)
def signal_group(pid: int, signal_number: int) -> None:
self.assertEqual(pid, process.pid)
if signal_number == 15:
process.terminate()
elif signal_number == 9:
process.kill()
else:
self.fail(f"unexpected signal: {signal_number}")
recorder = PwRecorder(
process_factory=factory,
max_bytes=max_bytes,
stderr_limit=stderr_limit,
stop_timeout=0.01,
clock=clock or Clock(),
process_group_signal=signal_group,
)
return recorder, process, factory, events
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
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
capture = recorder.stop()
self.assertEqual(factory.calls[0][0], PW_RECORD_COMMAND)
self.assertIs(factory.calls[0][1]["stdout"], subprocess.PIPE)
self.assertIs(factory.calls[0][1]["stderr"], subprocess.PIPE)
self.assertIs(factory.calls[0][1]["start_new_session"], True)
self.assertEqual(capture.pcm, b"\x00\x00\x01\x00")
self.assertEqual(capture.stderr_tail, b"23456789")
self.assertIn("stdout-eof", events)
self.assertIn("wait", events)
def test_accepts_pw_record_code_one_after_intentional_sigterm(self) -> None:
recorder, process, _factory, _events = self.make_recorder(terminate_returncode=1)
recorder.start()
capture = recorder.stop()
self.assertEqual(capture.returncode, 1)
self.assertIsNone(capture.signal)
self.assertEqual(capture.pcm, b"\x00\x00\x01\x00")
self.assertEqual(process.returncode, 1)
def test_rejects_code_one_if_process_already_exited_or_has_stderr(self) -> None:
exited, exited_process, _factory, _events = self.make_recorder(returncode=1)
exited.start()
self.assertTrue(exited_process.stdout.eof.wait(1))
with self.assertRaises(RecorderError):
exited.stop()
noisy, _process, _factory, _events = self.make_recorder(
stderr=b"pipewire failed",
terminate_returncode=1,
)
noisy.start()
with self.assertRaises(RecorderError):
noisy.stop()
def test_shutdown_timeout_escalates_to_kill_and_fails(self) -> None:
recorder, _process, _factory, events = self.make_recorder(timeout=True)
recorder.start()
with self.assertRaises(RecorderError) as failed:
recorder.stop()
self.assertEqual(failed.exception.code, "recorder_timeout")
self.assertLess(events.index("terminate"), events.index("kill"))
def test_classifies_unexpected_exit_code_and_signal(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
stderr=b"pipewire failed", returncode=7
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
failure = recorder.poll_unexpected()
self.assertIsNotNone(failure)
assert failure is not None
self.assertEqual(failure.code, "recorder_exit")
self.assertIn("code 7", failure.detail)
self.assertIn("pipewire failed", failure.detail)
signalled, signal_process, _factory, _events = self.make_recorder(returncode=-11)
signalled.start()
self.assertTrue(signal_process.stdout.eof.wait(1))
signal_failure = signalled.poll_unexpected()
assert signal_failure is not None
self.assertIn("signal 11", signal_failure.detail)
def test_duration_limit_discards_audio_and_reports_failure(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
pcm=b"\x00\x00" * 10, max_bytes=8
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
failure = recorder.poll_unexpected()
assert failure is not None
self.assertEqual(failure.code, "duration_limit")
self.assertEqual(recorder.buffered_bytes, 0)
def test_stop_reports_a_duration_limit_that_was_already_reached(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
pcm=b"\x00\x00" * 10, max_bytes=8
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
with self.assertRaises(RecorderError) as failed:
recorder.stop()
self.assertEqual(failed.exception.code, "duration_limit")
self.assertEqual(recorder.buffered_bytes, 0)
def test_duration_limit_kills_a_recorder_that_ignores_sigterm(self) -> None:
clock = Clock()
recorder, process, _factory, events = self.make_recorder(
pcm=b"\x00\x00" * 10,
max_bytes=8,
timeout=True,
clock=clock,
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
self.assertIsNone(recorder.poll_unexpected())
clock.now = 0.02
failure = recorder.poll_unexpected()
assert failure is not None
self.assertEqual(failure.code, "duration_limit")
self.assertIn("kill", events)
if __name__ == "__main__":
unittest.main()