* feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2) Native ASR engines (faster-whisper / CTranslate2) can segfault on GPU teardown — a process-level crash that kills the whole backend. Running the engine in a child process turns that into a failed job: the sidecar dies, the parent raises a decorated error (engine id + device), and the next request respawns a fresh sidecar. - services/subprocess_asr.py: SubprocessASRBackend reuses SubprocessBackend's wire protocol + lifecycle — including respawn-on-dead-process (_spawn relaunches when the child isn't alive) and GPU-slot acquire/release — adding a 'transcribe' op (the TTS 'generate' surface is stubbed). IsolatedFasterWhisperBackend wraps faster-whisper using the PARENT venv (already a dep — only the process boundary is new); opt-in via OMNIVOICE_ASR_BACKEND=faster-whisper-isolated. - engines/_asr_sidecar/main.py: the faster-whisper runner (stdlib wire protocol; torch/CT2 import lazily so the ready handshake fits the timeout). - engines/_echo/main.py: a 'transcribe' echo op so the round-trip + crash recovery are testable without a real engine. - asr_backend._REGISTRY is now a lazy dict (mirrors the TTS registry) so the isolated backend lists/resolves without importing the subprocess stack unless selected. Tests (echo sidecar, stdlib-only): round-trip, single long-lived sidecar across calls, crash-mid-transcribe → decorated error + backend healthy + next call respawns, registry exposure, generate-not-supported. Spec 7 / parity program Wave 4.2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(asr): deterministic crash test + drift marker for lazy ASR registry (Wave 4.2 CI) CI surfaced two issues: - The echo crash test relied on the crash-AFTER-reply hook, whose reply may still reach the parent (timing-dependent) — and a leaked OMNIVOICE_ECHO_CRASH from a sibling subprocess test poisoned the non-crash tests. Fix: a deterministic OMNIVOICE_ECHO_CRASH_NO_REPLY hook that exits BEFORE replying (guaranteed dead pipe → decorated error), and the asr fixture clears both crash envs so the round-trip/two-call tests can't inherit a leak. - check-docs-drift's _ASR_MARKER didn't match the new lazy registry line (_LazyASRRegistry({); updated the marker + the self-test fixture. Verified the no-reply crash hook by driving the sidecar directly (reply=None, exit 1); drift self-test + real-repo check green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(asr): allowlist the 'segments' op so transcribe replies aren't dropped (Wave 4.2 CI) The parent's PARENT_INBOUND_OPS frozenset gated inbound sidecar frames but never included 'segments' — the ASR transcribe reply op. _recv() dropped the frame as disallowed, tail-recursed, hit EOF, and returned None, so every transcribe surfaced as a bogus 'sidecar crashed mid-transcription'. TTS ('audio') was allowlisted; ASR ('segments') was missed. Add it (and list 'transcribe' in the informational SIDECAR_INBOUND_OPS), update the exact-shape allowlist test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
||
"""Diff the canonical feature inventory against README, docs, and registries.
|
||
|
||
The inventory (``docs/features.yaml``) is the curated truth. This checker
|
||
verifies, without importing any backend module (the engine registries pull
|
||
torch transitively, which the docs-drift CI runner does not have):
|
||
|
||
1. every ``features[]`` name appears verbatim in README.md;
|
||
2. ``tts_engines[].id`` is exactly the set of registry keys parsed from
|
||
``backend/services/tts_backend.py`` (eager ``_REGISTRY`` + lazy
|
||
``_LAZY_REGISTRY``), both directions;
|
||
3. ``asr_engines[].id`` likewise against ``backend/services/asr_backend.py``;
|
||
4. every ``readme:`` string appears in README.md;
|
||
5. every ``doc:`` / ``docs[]`` file exists.
|
||
|
||
Exit 0 = no drift. Exit 1 = drift; findings go to stderr and, with
|
||
``--output``, to a Markdown report consumed by the rolling-issue automation
|
||
in ``.github/workflows/docs-drift.yml``.
|
||
|
||
Companion to ``scripts/validate-install-docs.py`` (the PR-gating half).
|
||
Rolling-issue pattern adapted from Patter (MIT) — see
|
||
docs/competitive-analysis.md, Patter deep dive 4.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
# Markers locating the registry dicts whose keys we extract. Each marker is
|
||
# matched at a line start; the block ends at the first line that is exactly
|
||
# ``}`` or ``})`` (the registries are flat string-keyed dict literals).
|
||
_TTS_MARKERS = ("_LAZY_REGISTRY: dict[str, tuple[str, str]] = {",
|
||
"_REGISTRY: dict[str, type[TTSBackend]] = _LazyRegistry({")
|
||
_ASR_MARKERS = ("_REGISTRY: dict[str, type[ASRBackend]] = _LazyASRRegistry({",)
|
||
|
||
_KEY_RE = re.compile(r'^\s*"([^"]+)"\s*:')
|
||
|
||
|
||
def _registry_ids(source: str, markers: tuple[str, ...], *, path: str) -> set[str]:
|
||
"""Parse string keys out of the dict literal(s) following each marker."""
|
||
ids: set[str] = set()
|
||
lines = source.splitlines()
|
||
for marker in markers:
|
||
try:
|
||
start = next(i for i, ln in enumerate(lines) if ln.strip() == marker.strip())
|
||
except StopIteration:
|
||
raise SystemExit(
|
||
f"check-docs-drift: marker not found in {path}: {marker!r} — "
|
||
"the registry layout changed; update _TTS_MARKERS/_ASR_MARKERS."
|
||
)
|
||
for ln in lines[start + 1:]:
|
||
stripped = ln.strip()
|
||
if stripped in ("}", "})"):
|
||
break
|
||
if stripped.startswith("#"):
|
||
continue
|
||
m = _KEY_RE.match(ln)
|
||
if m:
|
||
ids.add(m.group(1))
|
||
return ids
|
||
|
||
|
||
def _check(root: Path) -> list[str]:
|
||
drifts: list[str] = []
|
||
|
||
inv_path = root / "docs" / "features.yaml"
|
||
if not inv_path.exists():
|
||
return [f"`{inv_path.relative_to(root)}` is missing"]
|
||
inv = yaml.safe_load(inv_path.read_text(encoding="utf-8")) or {}
|
||
|
||
readme = (root / "README.md").read_text(encoding="utf-8")
|
||
|
||
# 1. Features present in README.
|
||
for name in inv.get("features", []):
|
||
if name not in readme:
|
||
drifts.append(f"feature `{name}` is in the inventory but not in README.md")
|
||
|
||
# 2–4. Engine ids vs registries; readme strings; per-engine docs.
|
||
for section, src_rel, markers in (
|
||
("tts_engines", "backend/services/tts_backend.py", _TTS_MARKERS),
|
||
("asr_engines", "backend/services/asr_backend.py", _ASR_MARKERS),
|
||
):
|
||
entries = inv.get(section, [])
|
||
inv_ids = {e["id"] for e in entries}
|
||
code_ids = _registry_ids(
|
||
(root / src_rel).read_text(encoding="utf-8"), markers, path=src_rel
|
||
)
|
||
for missing in sorted(code_ids - inv_ids):
|
||
drifts.append(
|
||
f"engine `{missing}` exists in `{src_rel}` but not in the "
|
||
f"`{section}` inventory — document it (or list it deliberately)"
|
||
)
|
||
for gone in sorted(inv_ids - code_ids):
|
||
drifts.append(
|
||
f"engine `{gone}` is in the `{section}` inventory but no longer "
|
||
f"in `{src_rel}` — remove it from the inventory and docs"
|
||
)
|
||
for entry in entries:
|
||
readme_name = entry.get("readme")
|
||
if readme_name and readme_name not in readme:
|
||
drifts.append(
|
||
f"engine `{entry['id']}`: expected `{readme_name}` in README.md"
|
||
)
|
||
doc = entry.get("doc")
|
||
if doc and not (root / doc).exists():
|
||
drifts.append(f"engine `{entry['id']}`: doc `{doc}` does not exist")
|
||
|
||
# 5. Required docs exist.
|
||
for doc in inv.get("docs", []):
|
||
if not (root / doc).exists():
|
||
drifts.append(f"required doc `{doc}` does not exist")
|
||
|
||
return drifts
|
||
|
||
|
||
def _report(drifts: list[str], checked: int) -> str:
|
||
lines = ["# Docs drift report", ""]
|
||
if drifts:
|
||
lines.append(f"{len(drifts)} mismatch(es) between `docs/features.yaml`, "
|
||
"README.md, docs/, and the engine registries:")
|
||
lines.append("")
|
||
lines += [f"- {d}" for d in drifts]
|
||
lines.append("")
|
||
lines.append("Fix by updating the docs **or** the inventory — whichever is "
|
||
"stale. This issue updates in place and closes automatically "
|
||
"when the nightly check is clean.")
|
||
else:
|
||
lines.append(f"No drift — {checked} inventory entries verified.")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def main(argv: list[str] | None = None, root: Path | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--root", type=Path, default=None,
|
||
help="repo root (default: parent of this script's dir)")
|
||
parser.add_argument("--output", type=Path, default=None,
|
||
help="write a Markdown report to this path")
|
||
args = parser.parse_args([] if argv is None else argv)
|
||
|
||
repo = args.root or root or Path(__file__).resolve().parents[1]
|
||
drifts = _check(repo)
|
||
|
||
inv = yaml.safe_load((repo / "docs" / "features.yaml").read_text(encoding="utf-8")) \
|
||
if (repo / "docs" / "features.yaml").exists() else {}
|
||
checked = sum(len(inv.get(k, [])) for k in ("features", "tts_engines", "asr_engines", "docs"))
|
||
|
||
if args.output:
|
||
args.output.write_text(_report(drifts, checked), encoding="utf-8")
|
||
|
||
if drifts:
|
||
for d in drifts:
|
||
print(f"docs-drift: {d}", file=sys.stderr)
|
||
print(f"check-docs-drift: {len(drifts)} drift(s) across {checked} "
|
||
"inventory entries.", file=sys.stderr)
|
||
return 1
|
||
|
||
print(f"OK — {checked} inventory entries verified against README, docs, "
|
||
"and engine registries.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv[1:]))
|