feat(dictate): support configurable ASR backends
This commit is contained in:
@@ -3,17 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import Executor, Future, ThreadPoolExecutor
|
||||
from concurrent.futures import Executor, Future
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import os
|
||||
from queue import Empty, SimpleQueue
|
||||
from queue import Empty, Queue, SimpleQueue
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from .audio import AudioError, pcm_s16le_to_floats
|
||||
from .backend import ProviderError, TranscriptionBackend, TranscriptionTurn, backend_from_config
|
||||
from .config import ConfigError, load_config
|
||||
from .model import NemoParakeetModel, TranscriptionModel
|
||||
from .protocol import (
|
||||
MAX_FRAME_BYTES,
|
||||
@@ -34,6 +37,19 @@ MAX_CONNECTION_OUTPUT_BYTES = MAX_FRAME_BYTES
|
||||
LEVEL_EVENT_INTERVAL_SECONDS = 0.1
|
||||
|
||||
|
||||
def _looks_like_turn(value: object) -> bool:
|
||||
"""Keep injected test backends source-compatible while validating turns."""
|
||||
return all(callable(getattr(value, name, None)) for name in ("enqueue_pcm", "finish", "poll_error", "cancel", "close"))
|
||||
|
||||
|
||||
def _turn_cleanup_complete(turn: object) -> bool:
|
||||
return bool(getattr(turn, "cleanup_complete", True))
|
||||
|
||||
|
||||
def _recorder_cleanup_complete(recorder: object) -> bool:
|
||||
return bool(getattr(recorder, "cleanup_complete", True))
|
||||
|
||||
|
||||
class Recorder(Protocol):
|
||||
@property
|
||||
def audio_level(self) -> float: ...
|
||||
@@ -57,7 +73,12 @@ class _Generation:
|
||||
request_id: str
|
||||
state: str
|
||||
recorder: Recorder | None = None
|
||||
recorder_cleanup_started: bool = False
|
||||
turn: TranscriptionTurn | None = None
|
||||
tombstoned: bool = False
|
||||
failure_reported: bool = False
|
||||
cleanup_pending: bool = False
|
||||
cleanup_notify_idle: bool = False
|
||||
last_level_event_at: float | None = None
|
||||
|
||||
|
||||
@@ -68,28 +89,84 @@ class _Completion:
|
||||
future: Future[object]
|
||||
|
||||
|
||||
class _DaemonExecutor(Executor):
|
||||
"""Single-worker executor that cannot hold interpreter shutdown hostage."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: Queue[tuple[Future[object], Callable[..., object], tuple[object, ...], dict[str, object]] | None] = Queue()
|
||||
self._lock = threading.Lock()
|
||||
self._shutdown = False
|
||||
self._worker = threading.Thread(target=self._run, daemon=True, name="dictate-worker")
|
||||
self._worker.start()
|
||||
|
||||
def submit(self, fn: Callable[..., object], /, *args: object, **kwargs: object) -> Future[object]:
|
||||
future: Future[object] = Future()
|
||||
with self._lock:
|
||||
if self._shutdown:
|
||||
raise RuntimeError("cannot schedule new futures after shutdown")
|
||||
self._queue.put((future, fn, args, kwargs))
|
||||
return future
|
||||
|
||||
def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
|
||||
with self._lock:
|
||||
if not self._shutdown:
|
||||
self._shutdown = True
|
||||
if cancel_futures:
|
||||
while True:
|
||||
try:
|
||||
pending = self._queue.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
if pending is not None:
|
||||
pending[0].cancel()
|
||||
self._queue.put(None)
|
||||
if wait:
|
||||
self._worker.join()
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
work = self._queue.get()
|
||||
if work is None:
|
||||
return
|
||||
future, fn, args, kwargs = work
|
||||
if not future.set_running_or_notify_cancel():
|
||||
continue
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
except BaseException as error:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(result)
|
||||
|
||||
|
||||
class DictateServiceCore:
|
||||
"""Single-generation protocol state machine with injected host boundaries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: TranscriptionModel,
|
||||
recorder_factory: Callable[[], Recorder] = PwRecorder,
|
||||
model: TranscriptionModel | None = None,
|
||||
backend: TranscriptionBackend | None = None,
|
||||
recorder_factory: Callable[..., Recorder] = PwRecorder,
|
||||
executor: Executor | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
idle_seconds: float = DEFAULT_IDLE_SECONDS,
|
||||
) -> None:
|
||||
if model is None and backend is None:
|
||||
raise ValueError("either model or backend is required")
|
||||
if model is not None and backend is not None:
|
||||
raise ValueError("model and backend are mutually exclusive")
|
||||
self._model = model
|
||||
self._backend = backend
|
||||
self._recorder_factory = recorder_factory
|
||||
self._executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix="dictate")
|
||||
self._executor = executor or _DaemonExecutor()
|
||||
self._owns_executor = executor is None
|
||||
self._clock = clock
|
||||
self._idle_seconds = idle_seconds
|
||||
self._last_activity = clock()
|
||||
self._clients: dict[str, bool] = {}
|
||||
self._active: _Generation | None = None
|
||||
self._model_loaded = bool(getattr(model, "loaded", False))
|
||||
self._model_loaded = bool(getattr(model, "loaded", False)) if model is not None else False
|
||||
self._worker_kind: str | None = None
|
||||
self._completions: SimpleQueue[_Completion] = SimpleQueue()
|
||||
self._events: list[Outbound] = []
|
||||
@@ -98,6 +175,19 @@ class DictateServiceCore:
|
||||
def active_generation_id(self) -> str | None:
|
||||
return self._active.generation_id if self._active else None
|
||||
|
||||
def _ready(self) -> bool:
|
||||
if self._backend is None:
|
||||
return self._model_loaded
|
||||
return bool(getattr(self._backend, "ready", False))
|
||||
|
||||
def _diagnostics(self) -> dict[str, str]:
|
||||
descriptor = getattr(self._backend, "descriptor", None)
|
||||
if descriptor is None:
|
||||
return {}
|
||||
# Descriptors are fixed, bounded values supplied by the host backend,
|
||||
# never paths, endpoint URLs, or credential state.
|
||||
return {"backend": str(descriptor.backend)[:64], "model": str(descriptor.model)[:128]}
|
||||
|
||||
def accepted(self, client_id: str) -> None:
|
||||
"""Track a socket without treating acceptance as protocol activity."""
|
||||
self._clients.setdefault(client_id, False)
|
||||
@@ -116,7 +206,8 @@ class DictateServiceCore:
|
||||
"type": "pong",
|
||||
"requestId": request.request_id,
|
||||
"service": "pi-dictate",
|
||||
"ready": self._model_loaded,
|
||||
"ready": self._ready(),
|
||||
**self._diagnostics(),
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -133,8 +224,9 @@ class DictateServiceCore:
|
||||
"type": "pong",
|
||||
"requestId": request.request_id,
|
||||
"service": "pi-dictate",
|
||||
"ready": self._model_loaded,
|
||||
"ready": self._ready(),
|
||||
"busy": self._active is not None,
|
||||
**self._diagnostics(),
|
||||
},
|
||||
)
|
||||
elif request.type == "start":
|
||||
@@ -154,14 +246,38 @@ class DictateServiceCore:
|
||||
self._apply_completion(completion)
|
||||
|
||||
active = self._active
|
||||
if active and active.cleanup_pending and self._worker_kind is None:
|
||||
if active.recorder is not None and not _recorder_cleanup_complete(active.recorder):
|
||||
self._submit("cleanup", active, active.recorder.poll_unexpected)
|
||||
return
|
||||
self._finish_generation(active, notify_idle=active.cleanup_notify_idle)
|
||||
if self._active is active:
|
||||
return
|
||||
active = self._active
|
||||
if active and active.tombstoned and self._worker_kind is None:
|
||||
if active.recorder is not None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
else:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
if active and active.state == "listening" and active.recorder and self._worker_kind is None:
|
||||
if active.turn is not None:
|
||||
failure_from_turn = active.turn.poll_error()
|
||||
if failure_from_turn is not None:
|
||||
active.failure_reported = True
|
||||
self._async_error(active, failure_from_turn.code, failure_from_turn.detail)
|
||||
self._abort_active(active)
|
||||
return
|
||||
try:
|
||||
failure = active.recorder.poll_unexpected()
|
||||
except RecorderError as error:
|
||||
failure = error
|
||||
if failure is not None:
|
||||
active.failure_reported = True
|
||||
self._async_error(active, failure.code, failure.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
self._abort_active(active)
|
||||
return
|
||||
self._emit_audio_level(active)
|
||||
|
||||
@@ -175,9 +291,15 @@ class DictateServiceCore:
|
||||
if active is None or active.owner != client_id:
|
||||
return
|
||||
active.tombstoned = True
|
||||
if active.state == "listening" and active.recorder is not None:
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None and self._worker_kind is None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
elif self._worker_kind is None:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def should_exit(self) -> bool:
|
||||
if self._active is not None or self._worker_kind is not None:
|
||||
@@ -186,14 +308,30 @@ class DictateServiceCore:
|
||||
|
||||
def close(self) -> None:
|
||||
active = self._active
|
||||
if active and active.recorder and active.state == "listening":
|
||||
try:
|
||||
active.recorder.cancel()
|
||||
except RecorderError:
|
||||
pass
|
||||
if active:
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None:
|
||||
try:
|
||||
active.recorder.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
if active.turn is not None:
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not _turn_cleanup_complete(active.turn) and time.monotonic() < deadline:
|
||||
try:
|
||||
active.turn.close()
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self._active = None
|
||||
if self._owns_executor:
|
||||
self._executor.shutdown(wait=True, cancel_futures=False)
|
||||
# A recorder/provider worker may be stuck in native or network I/O.
|
||||
# The owned worker is daemonized and isn't registered with Python's
|
||||
# ThreadPoolExecutor exit hook, so this bounded shutdown also permits
|
||||
# normal interpreter exit.
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _start(self, client_id: str, request: Request) -> None:
|
||||
assert request.generation_id is not None
|
||||
@@ -202,28 +340,70 @@ class DictateServiceCore:
|
||||
return
|
||||
active = _Generation(client_id, request.generation_id, request.request_id, "loading")
|
||||
self._active = active
|
||||
if self._backend is not None:
|
||||
begin_prepare = getattr(self._backend, "begin_prepare", None)
|
||||
if callable(begin_prepare):
|
||||
begin_prepare()
|
||||
self._state(active, "loading")
|
||||
self._submit("prepare", active, self._backend.start_turn)
|
||||
return
|
||||
assert self._model is not None
|
||||
if not self._model_loaded:
|
||||
self._state(active, "loading")
|
||||
self._submit("load", active, self._model.load)
|
||||
return
|
||||
self._begin_recording(active)
|
||||
|
||||
def _begin_recording(self, active: _Generation) -> None:
|
||||
def _begin_recording(self, active: _Generation, turn: TranscriptionTurn | None = None) -> None:
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
try:
|
||||
recorder = self._recorder_factory()
|
||||
active.turn = turn
|
||||
if turn is None:
|
||||
recorder = self._recorder_factory()
|
||||
else:
|
||||
# PwRecorder accepts these options. Keep injected legacy test
|
||||
# factories source-compatible while requiring a sink for cloud.
|
||||
sink = None if turn.retain_pcm else turn
|
||||
try:
|
||||
recorder = self._recorder_factory(
|
||||
sample_rate_hz=turn.sample_rate_hz,
|
||||
pcm_sink=sink,
|
||||
retain_pcm=turn.retain_pcm,
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
recorder = self._recorder_factory(
|
||||
sample_rate_hz=turn.sample_rate_hz, sink=sink
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
recorder = self._recorder_factory(turn.sample_rate_hz, sink)
|
||||
except TypeError:
|
||||
if not turn.retain_pcm:
|
||||
raise RecorderError(
|
||||
"recorder_config",
|
||||
"realtime recorder factory must accept a PCM sink",
|
||||
)
|
||||
recorder = self._recorder_factory()
|
||||
active.recorder = recorder
|
||||
recorder.start()
|
||||
except RecorderError as error:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except OSError as error:
|
||||
self._async_error(active, "recorder_spawn", str(error))
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except Exception as error:
|
||||
self._async_error(active, "recorder_failure", str(error))
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
active.recorder = recorder
|
||||
active.last_level_event_at = self._clock()
|
||||
self._state(active, "listening")
|
||||
|
||||
@@ -263,11 +443,15 @@ class DictateServiceCore:
|
||||
failure = error
|
||||
if failure is not None:
|
||||
self._async_error(active, failure.code, failure.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
active.state = "stopping"
|
||||
self._state(active, "stopping", assign=False)
|
||||
self._submit("stop", active, self._stop_and_convert, active.recorder)
|
||||
if active.turn is None:
|
||||
self._submit("stop", active, self._stop_and_convert, active.recorder)
|
||||
else:
|
||||
self._submit("stop", active, self._stop_capture, active.recorder)
|
||||
|
||||
def _cancel(self, client_id: str, request: Request) -> None:
|
||||
active = self._owned_generation(client_id, request)
|
||||
@@ -278,6 +462,9 @@ class DictateServiceCore:
|
||||
return
|
||||
active.tombstoned = True
|
||||
active.request_id = request.request_id
|
||||
# Emit the delivery tombstone before potentially blocking provider or
|
||||
# recorder cleanup. The client must learn cancellation promptly while
|
||||
# the host remains busy reaping the old generation.
|
||||
self._emit(
|
||||
client_id,
|
||||
{
|
||||
@@ -287,9 +474,49 @@ class DictateServiceCore:
|
||||
"generationId": active.generation_id,
|
||||
},
|
||||
)
|
||||
if active.state == "listening" and active.recorder is not None:
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None and self._worker_kind is None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
elif active.recorder is None and self._worker_kind is None:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def _cancel_preparation(self, active: _Generation) -> None:
|
||||
if self._worker_kind != "prepare" or self._backend is None:
|
||||
return
|
||||
cancel_prepare = getattr(self._backend, "cancel_prepare", None)
|
||||
if callable(cancel_prepare):
|
||||
try:
|
||||
cancel_prepare()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _cancel_turn(active: _Generation) -> None:
|
||||
if active.turn is None:
|
||||
return
|
||||
try:
|
||||
active.turn.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _abort_active(self, active: _Generation) -> None:
|
||||
"""Tombstone a failed generation and always reap recorder/provider work."""
|
||||
active.tombstoned = True
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None and getattr(active.recorder, "cleanup_complete", False) is True:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
elif active.recorder is not None and self._worker_kind is None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
elif active.recorder is None and self._worker_kind is None:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def _owned_generation(self, client_id: str, request: Request) -> _Generation | None:
|
||||
assert request.generation_id is not None
|
||||
@@ -315,6 +542,10 @@ class DictateServiceCore:
|
||||
capture = recorder.stop()
|
||||
return pcm_s16le_to_floats(capture.pcm)
|
||||
|
||||
@staticmethod
|
||||
def _stop_capture(recorder: Recorder) -> Capture:
|
||||
return recorder.stop()
|
||||
|
||||
def _submit(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -339,23 +570,47 @@ class DictateServiceCore:
|
||||
try:
|
||||
result = completion.future.result()
|
||||
except RecorderError as error:
|
||||
if not active.tombstoned:
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except AudioError as error:
|
||||
if not active.tombstoned:
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
self._async_error(active, "invalid_audio", str(error))
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except ProviderError as error:
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except BaseException as error:
|
||||
if not active.tombstoned:
|
||||
code = "model_failure" if completion.kind in ("load", "infer") else "recorder_failure"
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
code = "model_failure" if completion.kind in ("load", "prepare", "infer") else "recorder_failure"
|
||||
self._async_error(active, code, str(error))
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
|
||||
if completion.kind == "load":
|
||||
if completion.kind == "prepare":
|
||||
if active.tombstoned:
|
||||
if _looks_like_turn(result):
|
||||
active.turn = result
|
||||
try:
|
||||
result.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
elif _looks_like_turn(result):
|
||||
self._begin_recording(active, result)
|
||||
else:
|
||||
self._async_error(active, "backend_failure", "backend returned an invalid transcription turn")
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
elif completion.kind == "load":
|
||||
self._model_loaded = True
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
@@ -363,14 +618,25 @@ class DictateServiceCore:
|
||||
self._begin_recording(active)
|
||||
elif completion.kind == "stop":
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
self._abort_active(active)
|
||||
return
|
||||
if active.turn is not None:
|
||||
if not isinstance(result, Capture):
|
||||
self._async_error(active, "invalid_audio", "recorder returned invalid capture")
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
self._state(active, "transcribing")
|
||||
self._submit("infer", active, active.turn.finish, result)
|
||||
return
|
||||
samples = result
|
||||
if not isinstance(samples, Sequence) or isinstance(samples, (str, bytes, bytearray)):
|
||||
self._async_error(active, "invalid_audio", "audio conversion returned invalid samples")
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
self._state(active, "transcribing")
|
||||
assert self._model is not None
|
||||
self._submit("infer", active, self._model.transcribe, samples)
|
||||
elif completion.kind == "infer":
|
||||
if active.tombstoned:
|
||||
@@ -378,29 +644,55 @@ class DictateServiceCore:
|
||||
return
|
||||
if not isinstance(result, str):
|
||||
self._async_error(active, "model_failure", "model returned a non-string transcript")
|
||||
elif len(result.encode("utf-8")) > MAX_TRANSCRIPT_BYTES:
|
||||
self._async_error(active, "transcript_too_large", "transcript exceeds output limit")
|
||||
else:
|
||||
self._emit(
|
||||
active.owner,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "result",
|
||||
"requestId": active.request_id,
|
||||
"generationId": active.generation_id,
|
||||
"transcript": result,
|
||||
},
|
||||
)
|
||||
try:
|
||||
transcript_bytes = len(result.encode("utf-8"))
|
||||
except UnicodeEncodeError:
|
||||
self._async_error(active, "model_failure", "model returned invalid Unicode")
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
return
|
||||
if transcript_bytes > MAX_TRANSCRIPT_BYTES:
|
||||
self._async_error(active, "transcript_too_large", "transcript exceeds output limit")
|
||||
else:
|
||||
self._emit(
|
||||
active.owner,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "result",
|
||||
"requestId": active.request_id,
|
||||
"generationId": active.generation_id,
|
||||
"transcript": result,
|
||||
},
|
||||
)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
elif completion.kind == "cancel":
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
elif completion.kind == "cleanup":
|
||||
self._finish_generation(active, notify_idle=active.cleanup_notify_idle)
|
||||
|
||||
def _finish_generation(self, active: _Generation, *, notify_idle: bool) -> None:
|
||||
if self._active is not active:
|
||||
return
|
||||
active.cleanup_pending = True
|
||||
active.cleanup_notify_idle = active.cleanup_notify_idle or notify_idle
|
||||
if active.recorder is not None and not _recorder_cleanup_complete(active.recorder):
|
||||
# Cleanup retries run through the executor so a stubborn child never
|
||||
# blocks the socket loop. The pending generation remains exclusive.
|
||||
return
|
||||
if active.turn is not None:
|
||||
try:
|
||||
active.turn.close()
|
||||
except Exception:
|
||||
pass
|
||||
# A provider close can be blocked by a wedged transport. Keep the
|
||||
# tombstoned generation busy until both provider workers have
|
||||
# actually exited, so no thread or transport crosses generations.
|
||||
if not _turn_cleanup_complete(active.turn):
|
||||
return
|
||||
active.cleanup_pending = False
|
||||
self._active = None
|
||||
self._last_activity = self._clock()
|
||||
if notify_idle and active.owner in self._clients:
|
||||
if (notify_idle or active.failure_reported) and active.owner in self._clients:
|
||||
self._state(active, "idle", assign=False)
|
||||
|
||||
def _state(self, active: _Generation, state: str, *, assign: bool = True) -> None:
|
||||
@@ -448,8 +740,12 @@ class _Connection:
|
||||
close_after_write: bool = False
|
||||
|
||||
|
||||
def inherited_listener(environ: dict[str, str] | None = None) -> socket.socket:
|
||||
"""Return systemd's single inherited listener at fd 3."""
|
||||
def inherited_listener(
|
||||
environ: dict[str, str] | None = None,
|
||||
*,
|
||||
expected_path: str | os.PathLike[str] | None = None,
|
||||
) -> socket.socket:
|
||||
"""Return systemd's fd 3 only when it is the expected Unix stream socket."""
|
||||
environment = os.environ if environ is None else environ
|
||||
try:
|
||||
listen_pid = int(environment.get("LISTEN_PID", "0"))
|
||||
@@ -458,9 +754,65 @@ def inherited_listener(environ: dict[str, str] | None = None) -> socket.socket:
|
||||
raise RuntimeError("invalid systemd socket activation environment") from error
|
||||
if listen_pid != os.getpid() or listen_fds != 1:
|
||||
raise RuntimeError("expected exactly one systemd socket-activated listener")
|
||||
listener = socket.socket(fileno=3)
|
||||
listener.setblocking(False)
|
||||
return listener
|
||||
listener: socket.socket | None = None
|
||||
try:
|
||||
listener = socket.socket(fileno=3)
|
||||
if listener.family != socket.AF_UNIX:
|
||||
raise RuntimeError("inherited listener must be AF_UNIX")
|
||||
socket_type = listener.type & getattr(socket, "SOCK_TYPE_MASK", 0xF)
|
||||
if socket_type != socket.SOCK_STREAM:
|
||||
raise RuntimeError("inherited listener must be SOCK_STREAM")
|
||||
try:
|
||||
accepting = listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN)
|
||||
except OSError as error:
|
||||
raise RuntimeError("could not validate inherited listener state") from error
|
||||
if accepting != 1:
|
||||
raise RuntimeError("inherited listener is not listening")
|
||||
actual = listener.getsockname()
|
||||
if isinstance(actual, bytes):
|
||||
actual = actual.decode("utf-8", errors="strict")
|
||||
if not isinstance(actual, str):
|
||||
raise RuntimeError("inherited listener has an invalid socket path")
|
||||
expected = expected_path
|
||||
if expected is None:
|
||||
runtime_dir = environment.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||
expected = os.path.join(runtime_dir, "pi-dictate.sock")
|
||||
if os.path.abspath(actual) != os.path.abspath(os.fspath(expected)):
|
||||
raise RuntimeError("inherited listener is not the expected pi-dictate socket")
|
||||
listener.setblocking(False)
|
||||
return listener
|
||||
except Exception:
|
||||
if listener is not None:
|
||||
listener.close()
|
||||
raise
|
||||
|
||||
|
||||
def _queue_connection_frame(
|
||||
selector: selectors.BaseSelector,
|
||||
connections: dict[str, _Connection],
|
||||
core: DictateServiceCore,
|
||||
client_id: str,
|
||||
frame: dict[str, object],
|
||||
) -> bool:
|
||||
"""Encode and append one frame without exceeding the per-client bound."""
|
||||
connection = connections.get(client_id)
|
||||
if connection is None:
|
||||
return False
|
||||
try:
|
||||
encoded = encode_frame(frame)
|
||||
except ProtocolError:
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
return False
|
||||
if len(connection.output) + len(encoded) > MAX_CONNECTION_OUTPUT_BYTES:
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
return False
|
||||
connection.output.extend(encoded)
|
||||
selector.modify(
|
||||
connection.socket,
|
||||
selectors.EVENT_READ | selectors.EVENT_WRITE,
|
||||
client_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
@@ -477,19 +829,8 @@ def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
connection = connections.get(outbound.client_id)
|
||||
if connection is None:
|
||||
continue
|
||||
try:
|
||||
encoded = encode_frame(outbound.frame)
|
||||
except ProtocolError:
|
||||
_close_connection(selector, connections, core, outbound.client_id)
|
||||
continue
|
||||
if len(connection.output) + len(encoded) > MAX_CONNECTION_OUTPUT_BYTES:
|
||||
_close_connection(selector, connections, core, outbound.client_id)
|
||||
continue
|
||||
connection.output.extend(encoded)
|
||||
selector.modify(
|
||||
connection.socket,
|
||||
selectors.EVENT_READ | selectors.EVENT_WRITE,
|
||||
outbound.client_id,
|
||||
_queue_connection_frame(
|
||||
selector, connections, core, outbound.client_id, outbound.frame
|
||||
)
|
||||
for key, mask in selector.select(timeout=0.1):
|
||||
if key.data is None:
|
||||
@@ -527,9 +868,19 @@ def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
for frame in connection.decoder.feed(data):
|
||||
core.handle_request(client_id, parse_request(frame))
|
||||
except ProtocolError as error:
|
||||
connection.output.extend(encode_frame(error_event(error)))
|
||||
if error.close_connection:
|
||||
if _queue_connection_frame(
|
||||
selector,
|
||||
connections,
|
||||
core,
|
||||
client_id,
|
||||
error_event(error),
|
||||
) and error.close_connection:
|
||||
connection.close_after_write = True
|
||||
# Queueing a protocol error can close this connection when its
|
||||
# output bound is exceeded. The selector event was created for
|
||||
# the old socket, so do not send to or modify that stale socket.
|
||||
if connections.get(client_id) is not connection:
|
||||
continue
|
||||
if mask & selectors.EVENT_WRITE and connection.output:
|
||||
try:
|
||||
sent = connection.socket.send(connection.output)
|
||||
@@ -573,26 +924,44 @@ def _close_connection(
|
||||
|
||||
|
||||
def main() -> None:
|
||||
model_path = os.environ.get("PI_DICTATE_MODEL_PATH")
|
||||
if not model_path:
|
||||
raise SystemExit("PI_DICTATE_MODEL_PATH must name an explicit local .nemo file")
|
||||
terms_path = os.environ.get("PI_DICTATE_TERMS_PATH")
|
||||
if not terms_path:
|
||||
raise SystemExit("PI_DICTATE_TERMS_PATH must name the host coding dictionary")
|
||||
idle_value = os.environ.get("PI_DICTATE_IDLE_SECONDS", str(DEFAULT_IDLE_SECONDS))
|
||||
boost_value = os.environ.get("PI_DICTATE_BOOST_ALPHA", "1.0")
|
||||
try:
|
||||
idle_seconds = float(idle_value)
|
||||
boost_alpha = float(boost_value)
|
||||
except ValueError as error:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS and PI_DICTATE_BOOST_ALPHA must be numeric") from error
|
||||
if idle_seconds <= 0:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be positive")
|
||||
if boost_alpha <= 0:
|
||||
raise SystemExit("PI_DICTATE_BOOST_ALPHA must be positive")
|
||||
|
||||
model = NemoParakeetModel(model_path, terms_path=terms_path, boost_alpha=boost_alpha)
|
||||
core = DictateServiceCore(model=model, idle_seconds=idle_seconds)
|
||||
# Installed services use the strict host config. The environment fallback
|
||||
# keeps direct development invocations and existing local deployments
|
||||
# compatible until install.sh has written config.json.
|
||||
config_path = os.environ.get("PI_DICTATE_CONFIG_PATH")
|
||||
default_config = os.path.expanduser("~/.config/pi-dictate/config.json")
|
||||
if config_path or os.path.isfile(default_config):
|
||||
try:
|
||||
config = load_config(config_path)
|
||||
network_mode = os.environ.get("PI_DICTATE_NETWORK_MODE")
|
||||
expected_network_mode = "offline" if config.backend == "local" else "cloud"
|
||||
if network_mode is not None and network_mode != expected_network_mode:
|
||||
raise ConfigError("configured backend does not match service network mode")
|
||||
backend = backend_from_config(config)
|
||||
except (ConfigError, ValueError) as error:
|
||||
raise SystemExit(f"Invalid pi-dictate configuration: {error}") from error
|
||||
core = DictateServiceCore(backend=backend, idle_seconds=config.idle_seconds)
|
||||
else:
|
||||
model_path = os.environ.get("PI_DICTATE_MODEL_PATH")
|
||||
if not model_path:
|
||||
raise SystemExit("PI_DICTATE_MODEL_PATH must name an explicit local .nemo file")
|
||||
terms_path = os.environ.get("PI_DICTATE_TERMS_PATH")
|
||||
if not terms_path:
|
||||
raise SystemExit("PI_DICTATE_TERMS_PATH must name the host coding dictionary")
|
||||
idle_value = os.environ.get("PI_DICTATE_IDLE_SECONDS", str(DEFAULT_IDLE_SECONDS))
|
||||
boost_value = os.environ.get("PI_DICTATE_BOOST_ALPHA", "1.0")
|
||||
try:
|
||||
idle_seconds = int(idle_value)
|
||||
if str(idle_seconds) != idle_value:
|
||||
raise ValueError
|
||||
boost_alpha = float(boost_value)
|
||||
except ValueError as error:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be an integer; boost alpha must be numeric") from error
|
||||
if not 60 <= idle_seconds <= 86_400:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be between 60 and 86400")
|
||||
if boost_alpha <= 0:
|
||||
raise SystemExit("PI_DICTATE_BOOST_ALPHA must be positive")
|
||||
model = NemoParakeetModel(model_path, terms_path=terms_path, boost_alpha=boost_alpha)
|
||||
core = DictateServiceCore(model=model, idle_seconds=idle_seconds)
|
||||
listener = inherited_listener()
|
||||
serve(listener, core)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user