Files
VoiceStudio/backend/runtime_adapter/digest.py
T
velixio dcd8683f3a feat(runtime-adapter): implement the GPU-node runtime gRPC server
RuntimeAdapterService over a private Unix-domain socket (default
/run/voicestudio/runtime.sock, VOICE_STUDIO_RUNTIME_SOCKET override; no
HTTP, no TCP, no database, no outbound network):

- Health/GetCapabilities read one RuntimeContext, so runtime/adapter
  versions are identical across both calls by construction. Devices come
  from torch (CUDA per-GPU / MPS / CPU with system RAM as capacity);
  models come from the tts_backend engine registry + hf_revisions pinned
  revisions, digest-pinned via a cached sha256 snapshot digest. READY is
  explicit: probe passed, snapshot complete, digest computed — a
  loading/installed/failed model is reported truthfully, never READY.
- Execute streams started -> bounded progress -> exactly one terminal
  event, validates attempt identity, approved model digest, typed bounded
  parameters, and LOCAL absolute-path handles (URL-shaped handles are
  invalid input, never fetched), runs the engine on a worker thread,
  enforces the request deadline, and writes the output WAV atomically
  with a size/sha256/duration manifest plus raw measurements.
- Stable RTA_* failure codes map onto RuntimeFailureClass: input,
  model-load, inference, GPU-resource, local-storage, canceled, crash.
- Cancel is idempotent by attempt id (ACCEPTED / ALREADY_TERMINAL /
  NOT_FOUND) against a bounded attempt registry.
- python -m backend.runtime_adapter serves; --selfcheck starts a temp
  socket and runs a port of internal/gateway/preflight.go's checks
  against itself (verified passing on this host: 1 device, 2 ready
  digest-pinned models).
2026-08-13 13:51:03 +05:30

113 lines
4.0 KiB
Python

"""Stable digests for locally installed model snapshots.
``model_digest`` in the wire contract pins the exact bytes a READY model will
execute with. Hugging Face snapshots are symlink farms into ``blobs/``, so the
digest is computed over the *resolved* file contents: SHA-256 of the sorted
sequence ``<posix relpath>\\n<file sha256>\\n``. That is stable across hosts,
cache locations, and symlink layout, and changes whenever any weight byte or
the file set changes.
Hashing multi-GB weights on every ``GetCapabilities`` call would be absurd, so
the result is cached in a JSON sidecar keyed by a cheap fingerprint of the
file list (relpath, size, mtime_ns). Any file change invalidates the cache and
forces a full re-hash.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
DIGEST_PREFIX = "sha256:"
_CHUNK = 1024 * 1024
def file_sha256(path: str | os.PathLike[str]) -> str:
hasher = hashlib.sha256()
with open(path, "rb") as fh:
while True:
chunk = fh.read(_CHUNK)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
def _manifest(root: Path) -> list[tuple[str, int, int]]:
"""Sorted (relpath, size, mtime_ns) for every regular file under root.
Follows symlinks (HF snapshot layout); a dangling symlink raises
``FileNotFoundError`` — callers treat that as an incomplete install.
"""
entries: list[tuple[str, int, int]] = []
for current, dirs, files in os.walk(root, followlinks=True):
dirs.sort()
for name in sorted(files):
path = Path(current) / name
stat = path.stat() # resolves symlinks; raises if dangling
rel = path.relative_to(root).as_posix()
entries.append((rel, stat.st_size, stat.st_mtime_ns))
entries.sort()
return entries
def _fingerprint(entries: list[tuple[str, int, int]]) -> str:
return hashlib.sha256(
json.dumps(entries, separators=(",", ":")).encode("utf-8")
).hexdigest()
def snapshot_digest(root: str | os.PathLike[str], cache_path: str | os.PathLike[str] | None = None) -> str:
"""``sha256:<hex>`` digest of the snapshot at ``root``.
Raises ``FileNotFoundError`` for a missing/empty snapshot or dangling
symlink and ``OSError`` for unreadable files — callers classify those as
not-READY rather than fabricating a digest.
"""
root = Path(root)
entries = _manifest(root)
if not entries:
raise FileNotFoundError(f"empty model snapshot: {root}")
fingerprint = _fingerprint(entries)
if cache_path is not None:
cached = _read_cache(cache_path)
if cached is not None and cached.get("fingerprint") == fingerprint:
digest = cached.get("digest", "")
if isinstance(digest, str) and digest.startswith(DIGEST_PREFIX):
return digest
hasher = hashlib.sha256()
for rel, _size, _mtime in entries:
hasher.update(rel.encode("utf-8"))
hasher.update(b"\n")
hasher.update(file_sha256(root / rel).encode("ascii"))
hasher.update(b"\n")
digest = DIGEST_PREFIX + hasher.hexdigest()
if cache_path is not None:
_write_cache(cache_path, fingerprint, digest)
return digest
def _read_cache(cache_path: str | os.PathLike[str]) -> dict | None:
try:
with open(cache_path, encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
def _write_cache(cache_path: str | os.PathLike[str], fingerprint: str, digest: str) -> None:
cache_path = Path(cache_path)
payload = json.dumps({"fingerprint": fingerprint, "digest": digest})
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
temporary = cache_path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(payload, encoding="utf-8")
os.replace(temporary, cache_path)
except OSError:
pass # cache is an optimization; the digest itself is already computed