feat(dictate): support configurable ASR backends

This commit is contained in:
jb
2026-09-03 00:08:00 -04:00
parent 67877297d0
commit 2c3ee1f014
40 changed files with 7055 additions and 489 deletions
+2
View File
@@ -0,0 +1,2 @@
{"type":"transcript","final":false,"text":"partial"}
{"type":"transcript","final":true,"text":"final fixture transcript"}
+1
View File
@@ -0,0 +1 @@
{"sessionId":"fixture-session-1"}
+4
View File
@@ -0,0 +1,4 @@
{"type":"session.created"}
{"type":"session.updated"}
{"type":"input_audio_buffer.committed","event_id":"event-fixture-1","previous_item_id":null,"item_id":"fixture-item-1"}
{"type":"conversation.item.input_audio_transcription.completed","item_id":"fixture-item-1","transcript":"final fixture transcript"}
+507
View File
@@ -0,0 +1,507 @@
from __future__ import annotations
import base64
import json
from pathlib import Path
import tempfile
import threading
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from pi_dictate_service.backend import PcmQueue, ProviderError, provider_event
from pi_dictate_service.backends.meta import (
META_ENDPOINT,
META_MAX_BACKLOG_BYTES,
META_SOCKET_IO_TIMEOUT,
MetaBackend,
_default_transport as default_meta_transport,
)
from pi_dictate_service.backends.openai import (
OPENAI_ENDPOINT,
OPENAI_SOCKET_IO_TIMEOUT,
OpenAIBackend,
_default_transport as default_openai_transport,
)
from pi_dictate_service.config import MetaConfig, OpenAIConfig
FIXTURE_ROOT = Path(__file__).parent / "fixtures"
def fixture_events(provider: str) -> list[str]:
return (FIXTURE_ROOT / provider / "events.jsonl").read_text(encoding="utf-8").splitlines()
def _capture_start_error(backend: object, errors: list[BaseException]) -> None:
try:
backend.start_turn()
except BaseException as error:
errors.append(error)
class FakeTransport:
def __init__(self, events: list[str | bytes | None]) -> None:
self.events = list(events)
self.sent: list[str | bytes] = []
self.closed = False
def send(self, value: str | bytes) -> None:
self.sent.append(value)
def recv(self, timeout: float | None = None) -> str | bytes | None:
if not self.events:
return None
return self.events.pop(0)
def close(self) -> None:
self.closed = True
class BlockingCloseTransport(FakeTransport):
def __init__(self, events: list[str | bytes | None]) -> None:
super().__init__(events)
self.close_started = threading.Event()
self.allow_close = threading.Event()
def close(self) -> None:
self.close_started.set()
self.allow_close.wait(1)
super().close()
class HttpFailure(RuntimeError):
def __init__(self, status_code: int) -> None:
super().__init__(f"HTTP {status_code}")
self.response = SimpleNamespace(status_code=status_code)
class BackendTests(unittest.TestCase):
def test_production_transports_apply_a_socket_io_deadline(self) -> None:
for factory, timeout, arguments in (
(default_meta_transport, META_SOCKET_IO_TIMEOUT, (META_ENDPOINT,)),
(
default_openai_transport,
OPENAI_SOCKET_IO_TIMEOUT,
(OPENAI_ENDPOINT,),
),
):
with self.subTest(factory=factory.__module__):
transport = SimpleNamespace(socket=Mock())
with patch("websockets.sync.client.connect", return_value=transport):
if factory is default_openai_transport:
factory(*arguments, headers={"Authorization": "Bearer test"})
else:
factory(*arguments)
transport.socket.settimeout.assert_called_once_with(timeout)
def make_files(self) -> tuple[Path, Path, tempfile.TemporaryDirectory[str]]:
temporary = tempfile.TemporaryDirectory()
root = Path(temporary.name)
key = root / "key"
key.write_text("secret", encoding="utf-8")
key.chmod(0o600)
terms = root / "terms"
terms.write_text("JSON\n", encoding="utf-8")
return key, terms, temporary
def test_meta_waits_for_ack_sends_documented_handshake_and_finalizes_after_tail(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
acknowledgement = (FIXTURE_ROOT / "meta" / "session_ack.json").read_text(encoding="utf-8").strip()
transport = FakeTransport([acknowledgement, *fixture_events("meta"), None])
connections: list[str] = []
def connect(endpoint: str) -> FakeTransport:
connections.append(endpoint)
return transport
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms, language_bias=("en",)),
transport_factory=connect,
timeout=1,
)
turn = backend.start_turn()
turn.enqueue_pcm(b"pcm")
self.assertEqual(turn.finish(), "final fixture transcript")
self.assertEqual(connections, [META_ENDPOINT])
handshake = json.loads(transport.sent[0])
self.assertEqual(handshake["authorization"]["accessToken"], "Bearer secret")
self.assertEqual(handshake["audioEncoding"], "PCM_24KHZ")
self.assertEqual(handshake["mode"], "PUSH_TO_TALK")
self.assertFalse(handshake["emitAudioProgress"])
self.assertEqual(handshake["languageBias"], ["en"])
self.assertEqual(handshake["keywords"], ["JSON"])
self.assertEqual(transport.sent[1], b"pcm")
self.assertEqual(json.loads(transport.sent[2]), {"type": "endStream"})
def test_openai_uses_ga_session_shape_and_correlates_committed_item(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(fixture_events("openai"))
connections: list[tuple[str, dict[str, str]]] = []
def connect(endpoint: str, *, headers: dict[str, str]) -> FakeTransport:
connections.append((endpoint, headers))
return transport
backend = OpenAIBackend(
OpenAIConfig(
"gpt-live-transcribe",
key,
terms,
delay="low",
prompt="Coding",
languages=("en",),
),
transport_factory=connect,
timeout=1,
)
turn = backend.start_turn()
turn.enqueue_pcm(b"pcm")
self.assertEqual(turn.finish(), "final fixture transcript")
self.assertEqual(
connections,
[(OPENAI_ENDPOINT, {"Authorization": "Bearer secret"})],
)
update = json.loads(transport.sent[0])
session = update["session"]
self.assertEqual(session["type"], "transcription")
input_audio = session["audio"]["input"]
self.assertEqual(input_audio["format"], {"type": "audio/pcm", "rate": 24000})
self.assertEqual(input_audio["transcription"]["languages"], ["en"])
self.assertEqual(input_audio["transcription"]["delay"], "low")
self.assertEqual(input_audio["transcription"]["keywords"], ["JSON"])
self.assertIsNone(input_audio["turn_detection"])
self.assertEqual(json.loads(transport.sent[-1]), {"type": "input_audio_buffer.commit"})
self.assertFalse(any(isinstance(item, str) and "response.create" in item for item in transport.sent))
def test_meta_rejects_malformed_ack_and_auth_error(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
malformed = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: FakeTransport(['{"sessionId":"s","unexpected":1}']),
timeout=0.1,
)
with self.assertRaisesRegex(ProviderError, "acknowledgement"):
malformed.start_turn()
auth = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: FakeTransport(['{"type":"error","code":"401"}']),
timeout=0.1,
)
with self.assertRaisesRegex(ProviderError, "Meta transcription service reported") as error:
auth.start_turn()
self.assertEqual(error.exception.code, "meta_auth")
with self.assertRaises(ValueError):
MetaBackend(MetaConfig("muse-voice-transcribe-1.0", key, terms), max_queue_bytes=META_MAX_BACKLOG_BYTES + 1)
def test_cloud_turn_rejects_partial_only_close_and_queue_overflow(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(['{"sessionId":"s"}', '{"type":"transcript","final":false,"text":"partial"}', None])
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: transport,
timeout=0.1,
)
turn = backend.start_turn()
with self.assertRaisesRegex(ProviderError, "final transcript"):
turn.finish()
queue = PcmQueue(max_bytes=2, max_chunks=1)
queue.put(b"12")
with self.assertRaisesRegex(ProviderError, "full"):
queue.put(b"3")
def test_cancel_before_prepare_worker_runs_prevents_connection(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
for backend in (
MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda *_args, **_kwargs: self.fail("Meta connected"),
),
OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda *_args, **_kwargs: self.fail("OpenAI connected"),
),
):
with self.subTest(backend=backend.descriptor.backend):
backend.begin_prepare()
backend.cancel_prepare()
with self.assertRaises(ProviderError) as error:
backend.start_turn()
self.assertEqual(error.exception.code, "cancelled")
def test_cancel_during_connect_prevents_authenticated_handshake(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
for backend_name in ("meta", "openai"):
with self.subTest(backend=backend_name):
entered = threading.Event()
release = threading.Event()
transport = FakeTransport([])
errors: list[BaseException] = []
def connect(*_args: object, **_kwargs: object) -> FakeTransport:
entered.set()
release.wait(1)
return transport
if backend_name == "meta":
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=connect,
timeout=0.1,
)
else:
backend = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=connect,
timeout=0.1,
)
worker = threading.Thread(
target=lambda: _capture_start_error(backend, errors), daemon=True
)
worker.start()
self.assertTrue(entered.wait(1))
backend.cancel_prepare()
release.set()
worker.join(1)
self.assertFalse(worker.is_alive())
self.assertEqual(getattr(errors[0], "code", None), "cancelled")
self.assertEqual(transport.sent, [])
def test_meta_discards_high_volume_partials_instead_of_overflowing_event_queue(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
partial = '{"type":"transcript","final":false,"text":"partial"}'
transport = FakeTransport([
'{"sessionId":"s"}',
*([partial] * 200),
'{"type":"transcript","final":true,"text":"final"}',
None,
])
turn = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: transport,
timeout=1,
).start_turn()
self.assertEqual(turn.finish(), "final")
def test_cancel_never_sends_a_provider_end_marker(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transports_and_turns = []
meta_transport = FakeTransport(['{"sessionId":"s"}'])
transports_and_turns.append((
meta_transport,
MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: meta_transport,
timeout=1,
).start_turn(),
))
openai_transport = FakeTransport([
'{"type":"session.created"}', '{"type":"session.updated"}'
])
transports_and_turns.append((
openai_transport,
OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: openai_transport,
timeout=1,
).start_turn(),
))
for transport, turn in transports_and_turns:
turn.cancel()
deadline = time.monotonic() + 1
while not turn.cleanup_complete and time.monotonic() < deadline:
turn.close()
sent_types = [
value.get("type")
for raw in transport.sent
if isinstance(raw, str) and isinstance((value := json.loads(raw)), dict)
]
self.assertNotIn("endStream", sent_types)
self.assertNotIn("input_audio_buffer.commit", sent_types)
def test_cancel_does_not_block_on_a_slow_transport_close(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = BlockingCloseTransport(['{"sessionId":"s"}'])
turn = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: transport,
timeout=0.1,
).start_turn()
started = time.monotonic()
turn.cancel()
elapsed = time.monotonic() - started
self.assertLess(elapsed, 0.05)
self.assertTrue(transport.close_started.wait(1))
self.assertFalse(turn.cleanup_complete)
transport.allow_close.set()
deadline = time.monotonic() + 1
while not turn.cleanup_complete and time.monotonic() < deadline:
turn.close()
self.assertTrue(turn.cleanup_complete)
def test_http_failures_use_websockets_response_status(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
meta = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: (_ for _ in ()).throw(HttpFailure(401)),
)
with self.assertRaises(ProviderError) as meta_error:
meta.start_turn()
self.assertEqual(meta_error.exception.code, "meta_auth")
openai = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: (_ for _ in ()).throw(HttpFailure(429)),
)
with self.assertRaises(ProviderError) as openai_error:
openai.start_turn()
self.assertEqual(openai_error.exception.code, "openai_rate_limit")
def test_cloud_turn_reports_provider_close_during_recording(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: FakeTransport([
'{"sessionId":"s"}',
None,
]),
timeout=0.1,
)
turn = backend.start_turn()
deadline = time.monotonic() + 1
failure = turn.poll_error()
while failure is None and time.monotonic() < deadline:
time.sleep(0.001)
failure = turn.poll_error()
self.assertIsNotNone(failure)
self.assertEqual(failure.code, "meta_premature_close")
def test_provider_event_rejects_recursive_values(self) -> None:
recursive: dict[str, object] = {}
recursive["self"] = recursive
with self.assertRaises(ProviderError):
provider_event(recursive)
def test_openai_rejects_malformed_error_and_unmatched_completion(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
malformed = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: FakeTransport([
'{"type":"session.created"}',
'{"type":"error","error":{"message":"bad"}}',
]),
timeout=0.1,
)
with self.assertRaisesRegex(ProviderError, "error event"):
malformed.start_turn()
transport = FakeTransport([
'{"type":"session.created"}',
'{"type":"session.updated"}',
'{"type":"input_audio_buffer.committed","item_id":"new"}',
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"old","transcript":"stale"}',
None,
])
backend = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=0.1,
)
turn = backend.start_turn()
with self.assertRaises(ProviderError) as error:
turn.finish()
self.assertIn(error.exception.code, {"openai_premature_close", "openai_timeout"})
def test_openai_commit_oriented_alternative_uses_explicit_commit(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(fixture_events("openai"))
turn = OpenAIBackend(
OpenAIConfig("gpt-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=1,
).start_turn()
turn.enqueue_pcm(b"pcm")
self.assertEqual(turn.finish(), "final fixture transcript")
update = json.loads(transport.sent[0])
self.assertEqual(
update["session"]["audio"]["input"]["transcription"]["model"],
"gpt-transcribe",
)
self.assertEqual(json.loads(transport.sent[-1]), {"type": "input_audio_buffer.commit"})
def test_openai_rejects_unknown_delay_from_direct_callers(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
with self.assertRaisesRegex(ValueError, "delay"):
OpenAIBackend(OpenAIConfig("gpt-live-transcribe", key, terms, delay="fast"))
def test_openai_receiver_failure_overrides_an_already_queued_completion(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport([
'{"type":"session.created"}',
'{"type":"session.updated"}',
'{"type":"input_audio_buffer.committed","item_id":"i"}',
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":"unsafe"}',
'{}',
])
turn = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=1,
).start_turn()
deadline = time.monotonic() + 1
while turn._receiver_error is None and time.monotonic() < deadline:
time.sleep(0.001)
with self.assertRaises(ProviderError) as error:
turn.finish()
self.assertEqual(error.exception.code, "openai_malformed_event")
def test_openai_audio_is_bounded_base64(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(
['{"type":"session.created"}', '{"type":"session.updated"}',
'{"type":"input_audio_buffer.committed","item_id":"i"}',
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":""}']
)
backend = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=1,
)
turn = backend.start_turn()
turn.enqueue_pcm(b"\x01\x02")
self.assertEqual(turn.finish(), "")
append = json.loads(transport.sent[1])
self.assertEqual(base64.b64decode(append["audio"]), b"\x01\x02")
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from pi_dictate_service.config import (
ConfigError,
CredentialError,
load_config,
parse_config,
provider_keywords,
read_credential,
)
from pi_dictate_service.recorder import audio_level_window_bytes, pw_record_command
class ConfigTests(unittest.TestCase):
def test_recorder_supports_both_backend_sample_rates(self) -> None:
self.assertEqual(pw_record_command(16_000)[5], "16000")
self.assertEqual(pw_record_command(24_000)[5], "24000")
self.assertEqual(audio_level_window_bytes(24_000), 4_800)
def test_rejects_unknown_selected_fields_and_invalid_backend_values(self) -> None:
for invalid_backend in ([], {}, 1, True, None):
with self.subTest(backend=invalid_backend):
with self.assertRaisesRegex(ConfigError, "backend must be"):
parse_config({"version": 1, "backend": invalid_backend})
base = {
"version": 1,
"backend": "openai",
"openai": {
"model": "gpt-live-transcribe",
"apiKeyFile": "~/.config/pi-dictate/credentials/openai-api-key",
"termsPath": "/tmp/terms",
},
}
with patch.dict(os.environ, {"HOME": "/tmp/pi-dictate-test"}):
invalid = {**base, "openai": {**base["openai"], "endpoint": "https://bad"}}
with self.assertRaises(ConfigError):
parse_config(invalid)
with self.assertRaises(ConfigError):
parse_config({**base, "version": 2})
configured = parse_config(
{**base, "openai": {**base["openai"], "delay": "low"}}
)
self.assertEqual(configured.openai.delay, "low")
with self.assertRaises(ConfigError):
parse_config({**base, "openai": {**base["openai"], "delay": "fast"}})
with self.assertRaises(ConfigError):
parse_config({**base, "openai": {**base["openai"], "termsPath": "relative/terms"}})
def test_reads_only_one_trailing_newline_from_a_private_credential(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "key"
path.write_text("secret\n", encoding="utf-8")
path.chmod(0o600)
self.assertEqual(read_credential(path), "secret")
path.chmod(0o640)
with self.assertRaises(CredentialError):
read_credential(path)
path.chmod(0o600)
path.write_text("secret\nextra", encoding="utf-8")
with self.assertRaises(CredentialError):
read_credential(path)
def test_rejects_a_credential_reached_through_a_symlinked_directory(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
actual = root / "actual"
actual.mkdir()
key = actual / "key"
key.write_text("secret", encoding="utf-8")
key.chmod(0o600)
linked = root / "credentials"
linked.symlink_to(actual, target_is_directory=True)
with self.assertRaisesRegex(CredentialError, "ancestry"):
read_credential(linked / "key")
def test_config_file_is_regular_private_and_provider_terms_reject_cr(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config_path = root / "config.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"backend": "local",
"local": {
"adapter": "nemo-parakeet",
"modelPath": "/tmp/model.nemo",
"modelSha256": "0" * 64,
"termsPath": "/tmp/terms",
"boostAlpha": 1.0,
},
}
),
encoding="utf-8",
)
config_path.chmod(0o600)
self.assertEqual(load_config(config_path).backend, "local")
terms = root / "terms"
terms.write_bytes(b"bad\rterm")
with self.assertRaises(ConfigError):
provider_keywords(terms)
terms.write_bytes(b"safe\nbad\x00term\n")
with self.assertRaises(ConfigError):
provider_keywords(terms)
if __name__ == "__main__":
unittest.main()
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "install.sh"
SYSTEMD_ROOT = SCRIPT.parents[2] / "systemd/user"
LEGACY_SERVICE = """# Managed by pi-dictate install.sh
[Unit]
Description=Pi local Parakeet dictation service
Requires=pi-dictate.socket
After=pipewire.service
[Service]
Type=simple
ExecStart=%h/.local/share/pi-dictate/venv/bin/pi-dictate-service
Environment=PI_DICTATE_MODEL_PATH=%h/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo
Environment=PI_DICTATE_TERMS_PATH=%h/.local/share/pi-dictate/config/coding-terms.txt
Environment=PI_DICTATE_BOOST_ALPHA=1.0
Environment=PI_DICTATE_IDLE_SECONDS=1800
Environment=HF_HUB_OFFLINE=1
Environment=TRANSFORMERS_OFFLINE=1
Environment=NEMO_HOME=/tmp/pi-dictate-nemo
Environment=HF_HOME=/tmp/pi-dictate-huggingface
Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton
Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba
Environment=PYTHONDONTWRITEBYTECODE=1
UMask=0077
LimitCORE=0
MemorySwapMax=0
NoNewPrivileges=yes
PrivateTmp=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectSystem=strict
ProtectHome=read-only
RestrictAddressFamilies=AF_UNIX
IPAddressDeny=any
LockPersonality=yes
# The socket unit owns %t/pi-dictate.sock. The service must not unlink it.
"""
class InstallFlowTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.root = Path(self.directory.name)
self.home = self.root / "home"
self.home.mkdir()
self.bin = self.root / "bin"
self.bin.mkdir()
uv = self.bin / "uv"
# A real uv sync creates the requested environment. Keep the fake
# faithful to that contract so transactional environment swapping is
# exercised without installing dependencies.
uv.write_text("#!/bin/sh\nmkdir -p \"$UV_PROJECT_ENVIRONMENT\"\nexit 0\n", encoding="utf-8")
uv.chmod(0o755)
systemctl = self.bin / "systemctl"
systemctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
systemctl.chmod(0o755)
def tearDown(self) -> None:
self.directory.cleanup()
def run_installer(self, *arguments: str) -> subprocess.CompletedProcess[str]:
environment = {
**os.environ,
"HOME": str(self.home),
"XDG_CONFIG_HOME": str(self.home / ".config"),
"PATH": f"{self.bin}:{os.environ['PATH']}",
}
return subprocess.run(
[str(SCRIPT), *arguments],
env=environment,
capture_output=True,
text=True,
check=False,
)
def test_real_staged_cloud_venv_python_runs_after_transactional_move(self) -> None:
real_uv = shutil.which("uv")
self.assertIsNotNone(real_uv)
(self.bin / "uv").unlink()
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
result = self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
)
self.assertEqual(result.returncode, 0, result.stderr)
python = self.home / ".local/share/pi-dictate/venv/bin/python"
executed = subprocess.run(
[str(python), "-c", "import pi_dictate_service"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(executed.returncode, 0, executed.stderr)
def test_upgrades_the_previous_service_unit_and_preserves_its_idle_setting(self) -> None:
unit_dir = self.home / ".config/systemd/user"
unit_dir.mkdir(parents=True)
(unit_dir / "pi-dictate.service").write_text(LEGACY_SERVICE, encoding="utf-8")
(unit_dir / "pi-dictate.socket").write_bytes(
(SYSTEMD_ROOT / "pi-dictate.socket").read_bytes()
)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
(unit_dir / "pi-dictate.service").read_bytes(),
(SYSTEMD_ROOT / "pi-dictate.service").read_bytes(),
)
config = json.loads(
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
)
self.assertEqual(config["idleSeconds"], 1800)
def test_rejects_symlinked_install_ancestry(self) -> None:
outside = self.root / "outside"
outside.mkdir()
(self.home / ".local").symlink_to(outside, target_is_directory=True)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlinked path ancestry", result.stderr)
self.assertEqual(list(outside.iterdir()), [])
def test_cloud_install_uses_only_cloud_profile_and_managed_key(self) -> None:
source = self.root / "meta-key"
source.write_text("test-key\n", encoding="utf-8")
result = self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("test-key", result.stdout + result.stderr)
self.assertNotIn("nvidia", result.stdout)
self.assertFalse(
(self.home / ".local/share/pi-dictate/models").exists()
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(config["backend"], "meta")
self.assertEqual(config["meta"]["model"], "muse-voice-transcribe-1.0")
self.assertEqual(config["meta"]["languageBias"], [])
self.assertEqual(
(self.home / ".config/pi-dictate/credentials/meta-api-key").read_text(
encoding="utf-8"
),
"test-key",
)
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
self.assertEqual(
stat.S_IMODE(
(self.home / ".config/pi-dictate/credentials/meta-api-key").stat().st_mode
),
0o600,
)
self.assertEqual(
(self.home / ".local/share/pi-dictate/.dependency-profile").read_text(
encoding="utf-8"
),
"cloud\n",
)
def test_check_rejects_values_that_runtime_configuration_rejects(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
valid = json.loads(config_path.read_text(encoding="utf-8"))
invalid_configs = []
boolean_version = {**valid, "version": True}
invalid_configs.append(boolean_version)
unhashable_backend = {**valid, "backend": []}
invalid_configs.append(unhashable_backend)
long_model = json.loads(json.dumps(valid))
long_model["openai"]["model"] = "m" * 129
invalid_configs.append(long_model)
unhashable_delay = json.loads(json.dumps(valid))
unhashable_delay["openai"]["delay"] = []
invalid_configs.append(unhashable_delay)
relative_terms = json.loads(json.dumps(valid))
relative_terms["openai"]["termsPath"] = "relative/terms.txt"
invalid_configs.append(relative_terms)
for invalid in invalid_configs:
with self.subTest(invalid=invalid):
config_path.write_text(json.dumps(invalid), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_installer("check")
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Traceback", result.stderr)
def test_check_rejects_extra_dependency_marker_newlines(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
marker = self.home / ".local/share/pi-dictate/.dependency-profile"
marker.write_bytes(b"cloud\n\n")
marker.chmod(0o600)
result = self.run_installer("check")
self.assertNotEqual(result.returncode, 0)
self.assertIn("dependency profile", result.stderr)
def test_uninstall_removes_an_exact_openai_dropin(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
result = self.run_installer("uninstall")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(
(self.home / ".config/systemd/user/pi-dictate.service.d/10-pi-dictate-network.conf").exists()
)
def test_openai_configure_preserves_live_verified_delay(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["openai"]["delay"] = "low"
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_installer("configure", "--backend", "openai")
self.assertEqual(result.returncode, 0, result.stderr)
configured = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(configured["openai"]["delay"], "low")
def test_meta_configure_preserves_valid_language_bias(self) -> None:
source = self.root / "meta-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["meta"]["languageBias"] = ["en", "fr"]
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_installer("configure", "--backend", "meta")
self.assertEqual(result.returncode, 0, result.stderr)
configured = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(configured["meta"]["languageBias"], ["en", "fr"])
def test_local_custom_archive_requires_and_records_explicit_digest(self) -> None:
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
config = json.loads(
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
)
self.assertEqual(config["local"]["modelSha256"], digest)
installed = self.home / (
".local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/"
"parakeet-tdt-0.6b-v3.nemo"
)
self.assertEqual(installed.read_bytes(), source.read_bytes())
def test_replacing_a_custom_local_model_preserves_its_configured_path(self) -> None:
first = self.root / "first.nemo"
first.write_bytes(b"first archive")
first_digest = hashlib.sha256(first.read_bytes()).hexdigest()
self.assertEqual(
self.run_installer(
"install", "--backend", "local", "--model-source", str(first),
"--model-sha256", first_digest,
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
custom_model = self.root / "custom-models/model.nemo"
custom_model.parent.mkdir()
custom_model.write_bytes(first.read_bytes())
config["local"]["modelPath"] = str(custom_model)
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
second = self.root / "second.nemo"
second.write_bytes(b"second archive")
second_digest = hashlib.sha256(second.read_bytes()).hexdigest()
result = self.run_installer(
"configure", "--backend", "local", "--model-source", str(second),
"--model-sha256", second_digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
configured = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(configured["local"]["modelPath"], str(custom_model))
self.assertEqual(custom_model.read_bytes(), second.read_bytes())
self.assertFalse(list(custom_model.parent.glob("*.pi-dictate-rollback.*")))
def test_switching_from_cloud_to_local_accepts_a_local_archive(self) -> None:
key = self.root / "meta-key"
key.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(key)
).returncode,
0,
)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"configure",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
config = json.loads(
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
)
self.assertEqual(config["backend"], "local")
self.assertEqual(config["local"]["modelSha256"], digest)
self.assertIn("meta", config)
def test_systemd_failure_rolls_back_staged_install(self) -> None:
old_venv = self.home / ".local/share/pi-dictate/venv"
old_venv.mkdir(parents=True)
(old_venv / "previous-runtime").write_text("old", encoding="utf-8")
source = self.root / "meta-key"
source.write_text("test-key", encoding="utf-8")
(self.bin / "systemctl").write_text(
"#!/bin/sh\necho \"$*\" >> \"$HOME/systemctl.log\"\n"
"case \"$*\" in *'is-enabled'*) exit 1;; "
"*'stop pi-dictate.service'*) exit 9;; esac\nexit 0\n",
encoding="utf-8",
)
(self.bin / "systemctl").chmod(0o755)
result = self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.home / ".config/pi-dictate/config.json").exists())
self.assertFalse((self.home / ".local/share/pi-dictate/.dependency-profile").exists())
self.assertFalse((self.home / ".local/share/pi-dictate/config/coding-terms.txt").exists())
self.assertEqual((old_venv / "previous-runtime").read_text(encoding="utf-8"), "old")
systemctl_calls = (self.home / "systemctl.log").read_text(encoding="utf-8")
self.assertGreaterEqual(systemctl_calls.count("daemon-reload"), 1)
self.assertIn("disable pi-dictate.socket", systemctl_calls)
def test_invalid_cloud_key_does_not_commit_a_dependency_marker(self) -> None:
source = self.root / "invalid-key"
source.write_text("two\nlines\n", encoding="utf-8")
result = self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(
(self.home / ".local/share/pi-dictate/.dependency-profile").exists()
)
def test_dictionary_symlink_is_rejected(self) -> None:
dictionary = self.home / ".local/share/pi-dictate/config/coding-terms.txt"
dictionary.parent.mkdir(parents=True)
target = self.root / "unexpected-terms.txt"
target.write_text("unexpected", encoding="utf-8")
dictionary.symlink_to(target)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("non-regular coding dictionary", result.stderr)
def test_uninstall_preserves_config_unless_purge_is_requested(self) -> None:
source = self.root / "meta-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
).returncode,
0,
)
result = self.run_installer("uninstall")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.home / ".config/pi-dictate/config.json").exists())
self.assertFalse((self.home / ".local/share/pi-dictate").exists())
# Reinstalling from the preserved key is possible without passing a
# literal secret or a second key source.
result = self.run_installer("install", "--backend", "meta")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
self.run_installer("uninstall", "--purge-config").returncode, 0
)
self.assertFalse((self.home / ".config/pi-dictate").exists())
if __name__ == "__main__":
unittest.main()
+26 -1
View File
@@ -1,3 +1,4 @@
import hashlib
from pathlib import Path
import os
import subprocess
@@ -24,8 +25,11 @@ class InstallerTests(unittest.TestCase):
home / ".local/share/pi-dictate"
),
}
command = [str(SCRIPT), *arguments, "--dry-run"]
if not any(action in arguments for action in ("uninstall", "check")):
command.extend(("--model-source", str(model), "--model-sha256", hashlib.sha256(model.read_bytes()).hexdigest()))
return subprocess.run(
[str(SCRIPT), *arguments, "--dry-run", "--model-source", str(model)],
command,
env=environment,
capture_output=True,
text=True,
@@ -47,7 +51,9 @@ class InstallerTests(unittest.TestCase):
self.assertNotIn("ln -s", result.stdout)
self.assertIn(".nemo.install.", result.stdout)
self.assertIn("mv -f", result.stdout)
self.assertIn("uv venv --relocatable", result.stdout)
self.assertIn("uv sync --frozen --no-dev --no-editable", result.stdout)
self.assertIn("--reinstall-package pi-dictate-service", result.stdout)
self.assertIn("Would install pi-dictate", result.stdout)
self.assertFalse((home / ".local/share/pi-dictate").exists())
@@ -87,6 +93,25 @@ class InstallerTests(unittest.TestCase):
"[Socket]\nListenStream=/tmp/unrelated.sock\n",
)
def test_uninstall_refuses_a_modified_managed_unit(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
model = home / "model.nemo"
model.write_bytes(b"model")
unit = home / ".config/systemd/user/pi-dictate.socket"
unit.parent.mkdir(parents=True)
unit.write_text(
"# Managed by pi-dictate install.sh\n[Socket]\nListenStream=/tmp/tampered.sock\n",
encoding="utf-8",
)
result = self.run_installer(home, model, "uninstall")
self.assertEqual(result.returncode, 1)
self.assertIn("unmanaged systemd unit", result.stderr)
self.assertNotIn("systemctl --user disable", result.stdout)
self.assertTrue(unit.exists())
def test_reinstall_preserves_an_existing_host_dictionary(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from pi_dictate_service.live_smoke import MAX_SAMPLE_BYTES, _read_sample
class LiveSmokeTests(unittest.TestCase):
def test_reads_one_bounded_even_pcm_fixture(self) -> None:
with tempfile.TemporaryDirectory() as directory:
sample = Path(directory) / "sample.pcm"
sample.write_bytes(b"\x00\x01" * 100)
self.assertEqual(_read_sample(sample), sample.read_bytes())
def test_rejects_symlink_odd_and_oversized_fixtures(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
valid = root / "valid.pcm"
valid.write_bytes(b"\x00\x00")
link = root / "link.pcm"
link.symlink_to(valid)
odd = root / "odd.pcm"
odd.write_bytes(b"x")
oversized = root / "oversized.pcm"
oversized.write_bytes(b"x" * (MAX_SAMPLE_BYTES + 2))
for path in (link, odd, oversized):
with self.subTest(path=path), self.assertRaises((OSError, ValueError)):
_read_sample(path)
if __name__ == "__main__":
unittest.main()
+33 -1
View File
@@ -38,12 +38,20 @@ class FakeNemoModel:
class FakeConcreteParakeetModel:
restored_path: str | None = None
restored_location: object | None = None
restored_bytes: bytes | None = None
replace_before_restore: tuple[Path, bytes] | None = None
restored: FakeNemoModel | None = None
@classmethod
def restore_from(cls, restore_path: str, map_location: object) -> FakeNemoModel:
cls.restored_path = restore_path
cls.restored_location = map_location
if cls.replace_before_restore is not None:
path, replacement = cls.replace_before_restore
replacement_path = path.with_suffix(".replacement")
replacement_path.write_bytes(replacement)
replacement_path.replace(path)
cls.restored_bytes = Path(restore_path).read_bytes()
self = FakeNemoModel()
cls.restored = self
return self
@@ -57,6 +65,13 @@ class FakeNumpy(ModuleType):
class NemoParakeetModelTests(unittest.TestCase):
def setUp(self) -> None:
FakeConcreteParakeetModel.restored_path = None
FakeConcreteParakeetModel.restored_location = None
FakeConcreteParakeetModel.restored_bytes = None
FakeConcreteParakeetModel.replace_before_restore = None
FakeConcreteParakeetModel.restored = None
def test_load_is_lazy_offline_applies_coding_terms_and_transcribes_in_memory_audio(self) -> None:
with tempfile.TemporaryDirectory() as directory:
model_path = Path(directory) / "parakeet.nemo"
@@ -78,7 +93,8 @@ class NemoParakeetModelTests(unittest.TestCase):
self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1")
self.assertEqual(os.environ["TRANSFORMERS_OFFLINE"], "1")
self.assertEqual(FakeConcreteParakeetModel.restored_path, str(model_path))
self.assertRegex(FakeConcreteParakeetModel.restored_path or "", r"^/proc/self/fd/\d+$")
self.assertEqual(FakeConcreteParakeetModel.restored_bytes, b"model")
self.assertEqual(FakeConcreteParakeetModel.restored_location, "device:cuda")
self.assertTrue(FakeConcreteParakeetModel.restored.eval_called)
self.assertEqual(FakeConcreteParakeetModel.restored.to_device, "cuda")
@@ -107,6 +123,22 @@ class NemoParakeetModelTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "128 UTF-8 bytes"):
load_coding_terms(terms_path)
def test_load_uses_the_verified_inode_if_the_configured_path_is_replaced(self) -> None:
with tempfile.TemporaryDirectory() as directory:
model_path = Path(directory) / "parakeet.nemo"
model_path.write_bytes(b"verified model")
FakeConcreteParakeetModel.replace_before_restore = (model_path, b"replacement")
with patch.dict(sys.modules, self.fake_modules(), clear=False):
adapter = NemoParakeetModel(
model_path,
expected_sha256=hashlib.sha256(b"verified model").hexdigest(),
)
adapter.load()
self.assertEqual(model_path.read_bytes(), b"replacement")
self.assertEqual(FakeConcreteParakeetModel.restored_bytes, b"verified model")
def test_refuses_a_model_that_no_longer_matches_the_pinned_hash(self) -> None:
with tempfile.TemporaryDirectory() as directory:
model_path = Path(directory) / "parakeet.nemo"
+20
View File
@@ -0,0 +1,20 @@
import unittest
from pi_dictate_service.protocol import MAX_FRAME_BYTES, FrameDecoder
class FrameDecoderRegressionTests(unittest.TestCase):
def test_accepts_pipelined_bounded_frames_larger_than_aggregate_limit(self) -> None:
decoder = FrameDecoder()
frame = b'{"version":2,"type":"ping","requestId":"request"}\n'
payload = frame * ((MAX_FRAME_BYTES // len(frame)) + 1)
self.assertGreater(len(payload), MAX_FRAME_BYTES + 1)
decoded = decoder.feed(payload)
self.assertEqual(len(decoded), payload.count(b"\n"))
self.assertEqual(decoded[0]["requestId"], "request")
if __name__ == "__main__":
unittest.main()
+156 -3
View File
@@ -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(
+552 -2
View File
@@ -1,11 +1,22 @@
from concurrent.futures import Future
from collections.abc import Sequence
from dataclasses import dataclass
import errno
import selectors
import subprocess
import sys
import unittest
from unittest.mock import patch
from pi_dictate_service.protocol import MAX_TRANSCRIPT_BYTES, PROTOCOL_VERSION, parse_request
from pi_dictate_service.protocol import (
MAX_FRAME_BYTES,
MAX_TRANSCRIPT_BYTES,
PROTOCOL_VERSION,
encode_frame,
parse_request,
)
from pi_dictate_service.recorder import Capture, RecorderError
from pi_dictate_service.server import DictateServiceCore, Outbound
from pi_dictate_service.server import DictateServiceCore, Outbound, serve
class Clock:
@@ -93,6 +104,154 @@ class FakeRecorder:
return failure
class SpawnFailRecorder(FakeRecorder):
cleanup_complete = True
def start(self) -> None:
raise RecorderError("recorder_spawn", "could not start pw-record")
class SpawnFailRecorderFactory:
def __call__(self) -> SpawnFailRecorder:
return SpawnFailRecorder()
class DelayedCleanupRecorder(FakeRecorder):
def __init__(self) -> None:
super().__init__()
self.cleanup_complete = False
self.cleanup_attempts = 0
self.cleanup_started = False
def stop(self) -> Capture:
self.cleanup_started = True
raise RecorderError("recorder_timeout", "pw-record cleanup did not complete")
def poll_unexpected(self) -> RecorderError | None:
if not self.cleanup_started:
return None
self.cleanup_attempts += 1
if self.cleanup_attempts >= 3:
self.cleanup_complete = True
return None
class DelayedCleanupRecorderFactory:
def __init__(self) -> None:
self.instance = DelayedCleanupRecorder()
def __call__(self) -> DelayedCleanupRecorder:
return self.instance
class DelayedCloseTurn:
sample_rate_hz = 16_000
retain_pcm = True
def __init__(self) -> None:
self.close_calls = 0
@property
def cleanup_complete(self) -> bool:
return self.close_calls >= 3
def enqueue_pcm(self, chunk: bytes) -> None:
return None
def finish(self, capture: Capture | None = None) -> str:
return "cloud result"
def poll_error(self) -> None:
return None
def cancel(self) -> None:
return None
def close(self) -> None:
self.close_calls += 1
class StreamingTurn:
sample_rate_hz = 24_000
retain_pcm = False
def __init__(self, events: list[str]) -> None:
self.events = events
self.closed = False
@property
def cleanup_complete(self) -> bool:
return self.closed
def enqueue_pcm(self, chunk: bytes) -> None:
self.events.append(f"audio-{chunk.decode('ascii')}")
def finish(self, capture: Capture | None = None) -> str:
self.events.append("finish")
self.assert_capture = capture
return "cloud result"
def poll_error(self) -> None:
return None
def cancel(self) -> None:
self.events.append("cancel")
def close(self) -> None:
self.closed = True
class StreamingRecorder(FakeRecorder):
def __init__(self, sink: StreamingTurn, events: list[str]) -> None:
super().__init__()
self.sink = sink
self.events = events
self.capture = Capture(b"", -15, 15, b"")
def start(self) -> None:
super().start()
self.sink.enqueue_pcm(b"a")
self.sink.enqueue_pcm(b"b")
def stop(self) -> Capture:
self.events.append("recorder-stop")
return super().stop()
class StreamingRecorderFactory:
def __init__(self, events: list[str]) -> None:
self.events = events
self.sample_rate_hz: int | None = None
self.instance: StreamingRecorder | None = None
def __call__(
self,
*,
sample_rate_hz: int,
pcm_sink: StreamingTurn,
retain_pcm: bool,
) -> StreamingRecorder:
self.sample_rate_hz = sample_rate_hz
if retain_pcm:
raise AssertionError("cloud capture must not retain PCM")
self.instance = StreamingRecorder(pcm_sink, self.events)
return self.instance
class FakeBackend:
sample_rate_hz = 16_000
descriptor = type("Descriptor", (), {"backend": "fake", "model": "fake-model"})()
def __init__(self, turn: DelayedCloseTurn | StreamingTurn) -> None:
self.turn = turn
def start_turn(self) -> DelayedCloseTurn:
return self.turn
def cancel_prepare(self) -> None:
return None
class RecorderFactory:
def __init__(self) -> None:
self.instances: list[FakeRecorder] = []
@@ -271,6 +430,121 @@ class StopAndInferenceTests(CoreTestCase):
self.assertEqual(completed[0]["transcript"], "dictated text")
self.assertEqual(completed[1]["state"], "idle")
def test_realtime_backend_streams_ordered_pcm_and_finalizes_after_recorder_stop(self) -> None:
events: list[str] = []
turn = StreamingTurn(events)
recorders = StreamingRecorderFactory(events)
core = DictateServiceCore(
backend=FakeBackend(turn),
recorder_factory=recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.assertEqual(recorders.sample_rate_hz, 24_000)
self.assertEqual(events, ["audio-a", "audio-b"])
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "stop",
"requestId": "stop",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.executor.run_next()
core.poll()
self.assertEqual(events, ["audio-a", "audio-b", "recorder-stop", "finish"])
result = [event.frame for event in core.drain_events() if event.frame["type"] == "result"]
self.assertEqual(result[0]["transcript"], "cloud result")
self.assertIsNone(core.active_generation_id)
def test_cancel_after_prepare_completion_tracks_turn_cleanup_before_release(self) -> None:
events: list[str] = []
turn = StreamingTurn(events)
core = DictateServiceCore(
backend=FakeBackend(turn),
recorder_factory=StreamingRecorderFactory(events),
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
# The future has completed, but the socket loop has not yet applied it.
core.handle_request("a", parse_request({
"version": 2,
"type": "cancel",
"requestId": "cancel",
"generationId": "g1",
}))
core.poll()
self.assertIn("cancel", events)
self.assertTrue(turn.cleanup_complete)
self.assertIsNone(core.active_generation_id)
def test_completed_turn_retries_bounded_cleanup_before_releasing_generation(self) -> None:
turn = DelayedCloseTurn()
core = DictateServiceCore(
backend=FakeBackend(turn),
recorder_factory=self.recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "stop",
"requestId": "stop",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.executor.run_next()
core.poll()
self.assertEqual(core.active_generation_id, "g1")
self.assertEqual(turn.close_calls, 2)
core.poll()
self.assertIsNone(core.active_generation_id)
self.assertEqual(turn.close_calls, 3)
def test_empty_transcript_is_a_result(self) -> None:
self.model.transcript = ""
self.load_to_listening()
@@ -321,6 +595,8 @@ class StopAndInferenceTests(CoreTestCase):
recorder = self.load_to_listening()
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 3")
self.core.poll()
self.executor.run_next()
self.core.poll()
failed = self.frames(self.core.drain_events(), "a")
@@ -332,12 +608,286 @@ class StopAndInferenceTests(CoreTestCase):
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 0")
failed = self.request("a", "stop", "stop", "g1")
self.executor.run_next()
self.core.poll()
failed.extend(self.frames(self.core.drain_events(), "a"))
self.assertEqual(failed[0]["code"], "recorder_exit")
self.assertEqual(failed[-1]["state"], "idle")
self.assertEqual(self.executor.pending, [])
def test_recorder_spawn_failure_releases_generation_without_cleanup_loop(self) -> None:
core = DictateServiceCore(
model=self.model,
recorder_factory=SpawnFailRecorderFactory(),
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({
"version": 2, "type": "hello", "requestId": "hello"
}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
events = self.frames(core.drain_events(), "a")
self.assertEqual(events[-2]["code"], "recorder_spawn")
self.assertEqual(events[-1]["state"], "idle")
self.assertIsNone(core.active_generation_id)
self.assertEqual(self.executor.pending, [])
def test_unreaped_recorder_keeps_generation_busy_until_cleanup_completes(self) -> None:
recorders = DelayedCleanupRecorderFactory()
core = DictateServiceCore(
model=self.model,
recorder_factory=recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({
"version": 2, "type": "hello", "requestId": "hello"
}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "stop",
"requestId": "stop",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.assertEqual(core.active_generation_id, "g1")
# First reap the cancellation worker scheduled by the terminal stop.
self.executor.run_next()
core.poll()
for attempt in range(1, 4):
self.executor.run_next()
core.poll()
if attempt < 3:
self.assertEqual(core.active_generation_id, "g1")
self.assertIsNone(core.active_generation_id)
self.assertEqual(recorders.instance.cleanup_attempts, 3)
class SocketLoopRegressionTests(unittest.TestCase):
def test_output_overflow_does_not_use_the_closed_socket_from_same_selector_event(self) -> None:
frame = {"padding": "x" * (MAX_FRAME_BYTES - len(encode_frame({"padding": ""})))}
self.assertEqual(len(encode_frame(frame)), MAX_FRAME_BYTES)
client = _FakeClientSocket()
listener = _FakeListener(client)
selector = _FakeSelector(client)
core = _FakeLoopCore(
Outbound("client-1", frame),
)
with patch("pi_dictate_service.server.selectors.DefaultSelector", return_value=selector):
serve(listener, core)
self.assertEqual(client.send_calls, 0)
self.assertTrue(client.closed)
self.assertTrue(listener.closed)
def test_close_does_not_wait_for_owned_executor_workers(self) -> None:
executor = _ShutdownRecordingExecutor()
with patch("pi_dictate_service.server._DaemonExecutor", return_value=executor):
core = DictateServiceCore(model=FakeModel())
core.accepted("a")
core.handle_request(
"a", parse_request({"version": PROTOCOL_VERSION, "type": "hello", "requestId": "h"})
)
core.drain_events()
core.handle_request(
"a",
parse_request(
{
"version": PROTOCOL_VERSION,
"type": "start",
"requestId": "s",
"generationId": "g",
}
),
)
core.close()
self.assertEqual(executor.shutdown_args, (False, True))
def test_real_owned_executor_does_not_block_interpreter_exit(self) -> None:
program = """
import threading
import time
from pi_dictate_service.protocol import parse_request
from pi_dictate_service.server import DictateServiceCore
class StuckModel:
loaded = False
def load(self):
threading.Event().wait()
def transcribe(self, _samples):
return ''
core = DictateServiceCore(model=StuckModel())
core.accepted('a')
core.handle_request('a', parse_request({'version': 2, 'type': 'hello', 'requestId': 'h'}))
core.drain_events()
core.handle_request('a', parse_request({
'version': 2, 'type': 'start', 'requestId': 's', 'generationId': 'g'
}))
time.sleep(0.05)
core.close()
print('closed')
"""
result = subprocess.run(
[sys.executable, "-c", program],
text=True,
capture_output=True,
timeout=2,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "closed")
@dataclass
class _FakeSelectorKey:
data: str | None
class _FakeSelector:
def __init__(self, client: "_FakeClientSocket") -> None:
self._events = [
[(_FakeSelectorKey(None), selectors.EVENT_READ)],
[(_FakeSelectorKey("client-1"), selectors.EVENT_READ | selectors.EVENT_WRITE)],
]
self.client = client
self.unregistered: list[object] = []
self.closed = False
def register(self, fileobj: object, _events: int, _data: object = None) -> None:
return None
def unregister(self, fileobj: object) -> None:
self.unregistered.append(fileobj)
def modify(self, fileobj: object, _events: int, _data: object = None) -> None:
if fileobj is self.client and self.client.closed:
raise OSError(errno.EBADF, "bad file descriptor")
def select(self, timeout: float | None = None) -> list[tuple[_FakeSelectorKey, int]]:
return self._events.pop(0)
def close(self) -> None:
self.closed = True
class _FakeClientSocket:
def __init__(self) -> None:
self.closed = False
self.send_calls = 0
self._read = True
def setblocking(self, _value: bool) -> None:
return None
def recv(self, _size: int) -> bytes:
if self._read:
self._read = False
return b"{broken}\n"
return b""
def send(self, _data: bytearray) -> int:
self.send_calls += 1
raise OSError(errno.EBADF, "bad file descriptor")
def close(self) -> None:
self.closed = True
class _FakeListener:
def __init__(self, client: _FakeClientSocket) -> None:
self.client = client
self.closed = False
self._accepted = False
def setblocking(self, _value: bool) -> None:
return None
def accept(self) -> tuple[_FakeClientSocket, object]:
if self._accepted:
raise BlockingIOError
self._accepted = True
return self.client, None
def close(self) -> None:
self.closed = True
class _FakeLoopCore:
def __init__(self, outbound: Outbound) -> None:
self.outbound = outbound
self.accepted_client: str | None = None
self._outbound_sent = False
self._exit_checks = 0
def should_exit(self) -> bool:
self._exit_checks += 1
return self._exit_checks >= 3
def poll(self) -> None:
return None
def drain_events(self) -> list[Outbound]:
if self.accepted_client is not None and not self._outbound_sent:
self._outbound_sent = True
return [self.outbound]
return []
def accepted(self, client_id: str) -> None:
self.accepted_client = client_id
def disconnect(self, _client_id: str) -> None:
return None
def close(self) -> None:
return None
class _ShutdownRecordingExecutor:
def __init__(self) -> None:
self.shutdown_args: tuple[bool, bool] | None = None
def submit(self, _function: object, *_args: object) -> Future[object]:
return Future()
def shutdown(self, *, wait: bool = True, cancel_futures: bool = False) -> None:
self.shutdown_args = (wait, cancel_futures)
if wait:
raise AssertionError("shutdown waited for a hung worker")
class IdleTests(CoreTestCase):
def test_only_valid_requests_reset_idle_and_workers_suspend_then_restart_timer(self) -> None:
self.core.accepted("a")
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from pi_dictate_service.config import ConfigError
from pi_dictate_service.startup import validate_startup
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPOSITORY_ROOT / "services/dictate/install.sh"
class SystemdProfileTests(unittest.TestCase):
def run_install(self, home: Path, fake_bin: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
environment = {
**os.environ,
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PATH": f"{fake_bin}:{os.environ['PATH']}",
}
return subprocess.run(
[str(SCRIPT), *arguments],
env=environment,
capture_output=True,
text=True,
check=False,
)
def make_fake_bin(self, root: Path) -> Path:
fake_bin = root / "bin"
fake_bin.mkdir()
uv = fake_bin / "uv"
# Match uv sync's contract so transactional environment swapping is
# exercised without installing dependencies.
uv.write_text(
"#!/bin/sh\nmkdir -p \"$UV_PROJECT_ENVIRONMENT\"\nexit 0\n",
encoding="utf-8",
)
uv.chmod(0o755)
systemctl = fake_bin / "systemctl"
systemctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
systemctl.chmod(0o755)
return fake_bin
def test_generated_cloud_dropin_replaces_offline_network_restrictions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "openai", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
dropin = (
home / ".config/systemd/user/pi-dictate.service.d/"
"10-pi-dictate-network.conf"
).read_text(encoding="utf-8")
self.assertIn("Environment=PI_DICTATE_NETWORK_MODE=cloud", dropin)
self.assertIn(
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", dropin
)
self.assertIn("IPAddressDeny=", dropin)
self.assertNotIn("IPAddressDeny=any", dropin)
self.assertNotIn("HF_HUB_OFFLINE=1", dropin)
self.assertIn(
f'BindReadOnlyPaths="{home}/.config/pi-dictate/credentials/openai-api-key"',
dropin,
)
self.assertIn(
f'BindReadOnlyPaths="{home}/.local/share/pi-dictate/config/coding-terms.txt"',
dropin,
)
def test_check_rejects_tampered_network_dropin(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
dropin = (
home / ".config/systemd/user/pi-dictate.service.d/"
"10-pi-dictate-network.conf"
)
with dropin.open("a", encoding="utf-8") as stream:
stream.write("IPAddressAllow=any\\n")
result = self.run_install(home, fake_bin, "check")
self.assertNotEqual(result.returncode, 0)
self.assertIn("expected managed policy", result.stderr)
def test_startup_requires_the_exact_validation_command(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
environment = {
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_CONFIG_PATH": str(home / ".config/pi-dictate/config.json"),
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
home / ".local/share/pi-dictate/.dependency-profile"
),
"PI_DICTATE_NETWORK_MODE": "cloud",
}
with patch.dict(os.environ, environment, clear=False):
validate_startup()
service = home / ".config/systemd/user/pi-dictate.service"
text = service.read_text(encoding="utf-8")
service.write_text(
"\n".join(
"ExecStartPre=/bin/true" if line.startswith("ExecStartPre=") else line
for line in text.splitlines()
) + "\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ConfigError, "hardening"):
validate_startup()
def test_startup_rejects_a_world_writable_socket_unit(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
environment = {
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_CONFIG_PATH": str(home / ".config/pi-dictate/config.json"),
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
home / ".local/share/pi-dictate/.dependency-profile"
),
"PI_DICTATE_NETWORK_MODE": "cloud",
}
socket_unit = home / ".config/systemd/user/pi-dictate.socket"
socket_unit.write_text(
socket_unit.read_text(encoding="utf-8").replace(
"SocketMode=0600", "SocketMode=0666"
),
encoding="utf-8",
)
with patch.dict(os.environ, environment, clear=False):
with self.assertRaisesRegex(ConfigError, "socket systemd policy"):
validate_startup()
def test_local_dropin_binds_custom_model_and_dictionary_paths(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
source = root / "source.nemo"
source.write_bytes(b"model")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_install(
home,
fake_bin,
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
custom_model = home / "custom model.nemo"
custom_terms = home / "custom terms.txt"
custom_model.write_bytes(source.read_bytes())
custom_terms.write_text("JSON\n", encoding="utf-8")
config_path = home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["local"]["modelPath"] = str(custom_model)
config["local"]["termsPath"] = str(custom_terms)
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_install(home, fake_bin, "configure", "--backend", "local")
self.assertEqual(result.returncode, 0, result.stderr)
dropin = (home / ".config/systemd/user/pi-dictate.service.d/10-pi-dictate-network.conf").read_text(encoding="utf-8")
self.assertIn(f'BindReadOnlyPaths="{custom_model}"', dropin)
self.assertIn(f'BindReadOnlyPaths="{custom_terms}"', dropin)
environment = {
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_CONFIG_PATH": str(config_path),
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
home / ".local/share/pi-dictate/.dependency-profile"
),
"PI_DICTATE_NETWORK_MODE": "offline",
}
with patch.dict(os.environ, environment, clear=False):
validate_startup()
def test_local_dropin_and_profile_deny_are_restrictive(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
model = root / "model"
model.write_bytes(b"model")
# Dry-run keeps this test independent of the pinned model bytes.
result = self.run_install(
home,
fake_bin,
"install",
"--backend",
"local",
"--model-source",
str(model),
"--model-sha256",
hashlib.sha256(model.read_bytes()).hexdigest(),
"--dry-run",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("offline network hardening", result.stdout)
profile = json.loads(
(REPOSITORY_ROOT / "profiles/nono/pi-local.json").read_text(
encoding="utf-8"
)
)
self.assertIn("$HOME/.local/share/pi-dictate", profile["filesystem"]["deny"])
# User decision: the profile is intentionally unchanged from HEAD.
self.assertNotIn("$HOME/.config/pi-dictate", profile["filesystem"]["deny"])
if __name__ == "__main__":
unittest.main()
+19 -1
View File
@@ -1,12 +1,21 @@
import hashlib
from pathlib import Path
import unittest
from pi_dictate_service.startup import EXPECTED_BASE_UNIT_SHA256
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
UNIT_DIR = REPOSITORY_ROOT / "systemd/user"
class SystemdUnitTests(unittest.TestCase):
def test_startup_hash_matches_the_checked_in_service_unit(self) -> None:
self.assertEqual(
hashlib.sha256((UNIT_DIR / "pi-dictate.service").read_bytes()).hexdigest(),
EXPECTED_BASE_UNIT_SHA256,
)
def test_socket_unit_owns_the_exact_private_runtime_path(self) -> None:
socket_unit = (UNIT_DIR / "pi-dictate.socket").read_text(encoding="utf-8")
self.assertIn("ListenStream=%t/pi-dictate.sock", socket_unit)
@@ -24,11 +33,20 @@ class SystemdUnitTests(unittest.TestCase):
"TRANSFORMERS_OFFLINE=1",
"TRITON_CACHE_DIR=/tmp/pi-dictate-triton",
"NUMBA_CACHE_DIR=/tmp/pi-dictate-numba",
"TimeoutStopSec=5s",
"LimitCORE=0",
"MemorySwapMax=0",
"ProtectSystem=strict",
"ProtectHome=read-only",
"ProtectHome=tmpfs",
"BindReadOnlyPaths=%h/.local/share/pi-dictate",
"BindReadOnlyPaths=%h/.local/share/uv/python",
"BindReadOnlyPaths=%h/.config/pi-dictate/config.json",
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.service",
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.service.d",
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.socket",
"BindReadOnlyPaths=%t/pipewire-0",
"RestrictAddressFamilies=AF_UNIX",
"SystemCallFilter=~bind listen",
"IPAddressDeny=any",
):
self.assertIn(expected, service_unit)
+12 -4
View File
@@ -173,6 +173,10 @@ class UnixLoopTests(unittest.TestCase):
class ActivationTests(unittest.TestCase):
def test_inherited_listener_requires_one_fd_for_this_process_and_wraps_fd_three(self) -> None:
listener = Mock()
listener.family = socket.AF_UNIX
listener.type = socket.SOCK_STREAM
listener.getsockopt.return_value = 1
listener.getsockname.return_value = "/run/user/1000/pi-dictate.sock"
with patch("pi_dictate_service.server.os.getpid", return_value=42), patch(
"pi_dictate_service.server.socket.socket", return_value=listener
) as socket_factory:
@@ -189,7 +193,9 @@ class ActivationTests(unittest.TestCase):
inherited_listener({"LISTEN_PID": "41", "LISTEN_FDS": "1"})
def test_main_requires_local_model_path_and_reads_idle_override(self) -> None:
with patch.dict(os.environ, {}, clear=True):
with patch.dict(os.environ, {}, clear=True), patch(
"pi_dictate_service.server.os.path.isfile", return_value=False
):
with self.assertRaisesRegex(SystemExit, "PI_DICTATE_MODEL_PATH"):
main()
@@ -201,10 +207,12 @@ class ActivationTests(unittest.TestCase):
"PI_DICTATE_MODEL_PATH": "/models/parakeet.nemo",
"PI_DICTATE_TERMS_PATH": "/config/coding-terms.txt",
"PI_DICTATE_BOOST_ALPHA": "0.5",
"PI_DICTATE_IDLE_SECONDS": "12.5",
"PI_DICTATE_IDLE_SECONDS": "120",
},
clear=True,
), patch("pi_dictate_service.server.NemoParakeetModel", return_value=model) as adapter, patch(
), patch("pi_dictate_service.server.os.path.isfile", return_value=False), patch(
"pi_dictate_service.server.NemoParakeetModel", return_value=model
) as adapter, patch(
"pi_dictate_service.server.DictateServiceCore"
) as core_factory, patch(
"pi_dictate_service.server.inherited_listener", return_value=listener
@@ -217,7 +225,7 @@ class ActivationTests(unittest.TestCase):
terms_path="/config/coding-terms.txt",
boost_alpha=0.5,
)
core_factory.assert_called_once_with(model=model, idle_seconds=12.5)
core_factory.assert_called_once_with(model=model, idle_seconds=120)
serve_loop.assert_called_once_with(listener, core)