Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
debpalash
2026-08-10 13:20:38 +00:00
25 changed files with 669 additions and 112 deletions
+1
View File
@@ -42,6 +42,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Fixed
- Filenames and other outside data can no longer forge extra lines or terminal commands in backend and frontend diagnostic logs. (#1457)
- Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459)
- Backend failures keep raw tracebacks, local paths and credentials in the local log instead of returning them in API responses. (#1454)
- GPT-SoVITS connections now stay on loopback or explicitly trusted networks and cannot escape through redirects or DNS rebinding. (#1463)
- Engine discovery no longer exposes probe exceptions, local paths or credentials in API responses and logs. (#1460)
+4 -4
View File
@@ -507,7 +507,8 @@ async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
try:
await websocket.send_json({"type": "status", "stage": stage})
except Exception:
pass
logger.warning("Sherpa load status could not be delivered; stopping stream setup")
return False
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
@@ -522,7 +523,8 @@ async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
try:
await websocket.send_json({"type": "status", "stage": "ready"})
except Exception:
pass
logger.warning("Sherpa ready status could not be delivered; stopping stream setup")
return False
return True
@@ -1001,5 +1003,3 @@ def _chunks_to_wav(chunks: list[bytes]) -> str | None:
# WhisperX) can decode WebM/Opus containers natively.
logger.debug("Falling back to raw WebM input for ASR")
return tmp_in.name
+21 -8
View File
@@ -101,13 +101,13 @@ class DictationPrefsUpdate(BaseModel):
def set_dictation_prefs(req: DictationPrefsUpdate):
"""Persist any subset of the dictation prefs. Validates ``mode`` and
``model_id`` so a bad value can't wedge the capture engine."""
canonical = None
if req.mode is not None:
if req.mode not in _VALID_MODES:
raise HTTPException(
status_code=400,
detail=f"mode must be one of {_VALID_MODES}",
)
prefs.set_(PREF_MODE, req.mode)
if req.model_id is not None:
if not sd.is_sherpa_model(req.model_id):
raise HTTPException(
@@ -116,6 +116,26 @@ def set_dictation_prefs(req: DictationPrefsUpdate):
)
# Normalise to the canonical dictation id (accept repo_id too).
canonical = sd.get_spec(req.model_id).id
# Reset before persisting: if the capture service is unavailable, the
# request fails without claiming that settings which are not active were
# saved. A reset is safe even when a later preference write fails; the old
# persisted selection is simply loaded again on next capture.
try:
from services import asr_backend
asr_backend._capture_backend = None
asr_backend._capture_backend_key = None
except Exception as exc:
logger.warning("Dictation capture backend could not be reset")
raise HTTPException(
status_code=503,
detail="Dictation settings could not be applied. Retry after the capture service is ready.",
) from exc
if req.mode is not None:
prefs.set_(PREF_MODE, req.mode)
if canonical is not None:
prefs.set_(PREF_MODEL_ID, canonical)
# Explicitly choosing a model clears any auto-demotion: the user is in
# charge, and a sherpa upgrade may well have fixed the decoder that
@@ -124,11 +144,4 @@ def set_dictation_prefs(req: DictationPrefsUpdate):
sd.clear_demotion(canonical)
if req.enabled is not None:
prefs.set_(PREF_ENABLED, bool(req.enabled))
# Rebuild the cached capture singleton so the change takes effect at once.
try:
from services import asr_backend
asr_backend._capture_backend = None
asr_backend._capture_backend_key = None
except Exception:
pass
return _read_prefs()
+9 -4
View File
@@ -273,13 +273,18 @@ def dub_abort(job_id: str):
with _active_procs_lock:
had_procs = bool(_active_procs.get(job_id))
_kill_job_procs(job_id)
try:
if task_manager.cancel_task(job_id) is False:
raise RuntimeError("task cancellation was declined")
except Exception as exc:
logger.warning("Dub task cancellation failed")
raise HTTPException(
status_code=503,
detail="The dub could not be fully aborted. Retry the abort operation.",
) from exc
job = _dub_jobs.get(job_id)
if job is not None:
job["aborted"] = True
try:
task_manager.cancel_task(job_id)
except Exception:
pass
return {"aborted": True, "had_active_procs": had_procs}
+5 -2
View File
@@ -117,8 +117,11 @@ def _sanitize_audio(audio_out):
"sanitizing to silence to keep the WAV decodable (#629)."
)
return torch.nan_to_num(audio_out, nan=0.0, posinf=0.0, neginf=0.0)
except Exception:
pass
except Exception as exc:
logger.warning("Generated audio validation failed")
raise RuntimeError(
"Generated audio could not be validated. Retry the generation."
) from exc
return audio_out
+1 -1
View File
@@ -675,7 +675,7 @@ def list_voices():
"language": row["language"],
})
except Exception:
pass
logger.warning("Voice profiles could not be loaded; returning built-in aliases only")
return {"voices": voices, "engines": backends}
+12 -6
View File
@@ -237,12 +237,15 @@ async def import_persona(file: UploadFile = File(...)):
_insert(profile_id)
except HTTPException:
_cleanup(written)
if not _cleanup(written):
raise HTTPException(status_code=500, detail="Import failed, and temporary files could not be removed. Close any app using them and retry cleanup.")
raise
except Exception:
_cleanup(written)
logger.exception("persona import failed")
raise HTTPException(status_code=500, detail="Import failed; no files were kept.")
cleaned = _cleanup(written)
logger.warning("Persona import failed")
detail = ("Import failed; no files were kept." if cleaned else
"Import failed, and temporary files could not be removed. Close any app using them and retry cleanup.")
raise HTTPException(status_code=500, detail=detail)
event_bus.emit("profiles", {"action": "created", "id": profile_id})
logger.info("Imported persona %s as %s (verified=%s)", log_safe(persona.get("name")), log_safe(profile_id), verified)
@@ -261,13 +264,16 @@ async def import_persona(file: UploadFile = File(...)):
}
def _cleanup(paths: list[str]) -> None:
def _cleanup(paths: list[str]) -> bool:
complete = True
for p in paths:
try:
if p and os.path.exists(p):
os.remove(p)
except OSError:
pass
complete = False
logger.warning("Persona import temporary-file cleanup did not complete")
return complete
def _rename_for_new_id(written: list[str], new_id: str) -> list[str]:
+15 -3
View File
@@ -223,11 +223,23 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
_create_symlink(blob_path, pointer, new_blob=True)
# refs/main → commit so scan_cache_dir maps the revision correctly.
ref_path = os.path.join(refs_dir, "main")
ref_tmp = ref_path + ".tmp"
try:
with open(os.path.join(refs_dir, "main"), "w") as f:
with open(ref_tmp, "w") as f:
f.write(commit)
except OSError:
pass
os.replace(ref_tmp, ref_path)
except OSError as exc:
logger.warning("Downloaded model revision could not be finalized")
try:
os.remove(ref_tmp)
except FileNotFoundError:
pass # Idempotent cleanup: the failed write may not create it.
except OSError:
logger.warning("Downloaded model revision temporary-file cleanup did not complete")
raise RuntimeError(
"Downloaded model revision could not be finalized. Retry the install."
) from exc
return snap_dir
+14 -2
View File
@@ -191,7 +191,8 @@ def _hf_endpoint_host() -> tuple[str, int]:
from core.failure import configured_hf_mirror
mirror = configured_hf_mirror()
except Exception:
mirror = ""
logger.warning("Configured Hugging Face endpoint could not be read")
return "", 0
if mirror:
try:
from urllib.parse import urlsplit
@@ -199,7 +200,10 @@ def _hf_endpoint_host() -> tuple[str, int]:
if u.hostname:
return u.hostname, u.port or (80 if u.scheme == "http" else 443)
except Exception:
pass
logger.warning("Configured Hugging Face endpoint could not be parsed")
return "", 0
logger.warning("Configured Hugging Face endpoint has no host")
return "", 0
return "huggingface.co", 443
@@ -272,6 +276,14 @@ def _network_check() -> dict:
# Manual mode (explicit endpoint) — probe exactly what the user chose.
net_host, net_port = _hf_endpoint_host()
if not net_host:
return {
"id": "network", "label": "Network (configured endpoint)",
"status": "warn",
"detail": "The configured Hugging Face endpoint could not be validated.",
"fix": "Review the endpoint in Settings → Models, then re-check.",
"mirror_reachable": False,
}
net_ok = _probe_network(net_host, net_port)
mirror_reachable = False
if not net_ok and net_host == "huggingface.co":
+30 -13
View File
@@ -400,13 +400,18 @@ async def stream_logs(
if not path or not os.path.exists(path):
raise HTTPException(status_code=404, detail=f"Log file not found for source={source}")
try:
initial_position = os.path.getsize(path)
except OSError as exc:
logger.warning("Log stream could not determine its starting position")
raise HTTPException(
status_code=503,
detail="The log stream could not be started. Retry after checking file permissions.",
) from exc
async def _generate():
"""Yield SSE events whenever new lines appear in the log file."""
last_pos = 0
try:
last_pos = os.path.getsize(path)
except Exception:
pass
last_pos = initial_position
while True:
await asyncio.sleep(interval)
try:
@@ -461,8 +466,12 @@ async def clear_system_logs():
for key in ("crash_log_acked", "crash_log_acked_size"):
try:
prefs_delete(key)
except Exception:
pass
except Exception as exc:
logger.warning("Cleared logs but could not reset crash acknowledgement state")
raise HTTPException(
status_code=500,
detail="Logs were cleared, but notification state could not be reset. Retry the clear operation.",
) from exc
return {"cleared": cleared_any}
@@ -719,7 +728,7 @@ def system_notifications():
},
})
except Exception:
pass
logger.warning("Previous-run crash record could not be checked")
# 5. A previous session logged a crash the user never saw.
# crash_log grew past the last acknowledged size AND predates this
@@ -742,7 +751,7 @@ def system_notifications():
},
})
except Exception:
pass
logger.warning("Previous-session crash log could not be checked")
return {"notifications": notes, "count": len(notes)}
@@ -981,18 +990,26 @@ async def _do_clean_audio(audio, tmp_dir, clean_id):
clean_filename = f"mic_{clean_id}.wav"
final_path = os.path.join(OUTPUTS_DIR, clean_filename)
conversion_fallback = False
try:
await run_ffmpeg(
rc, _, _ = await run_ffmpeg(
[ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path],
timeout=120.0,
)
conversion_fallback = rc != 0
except asyncio.TimeoutError:
pass
if not os.path.exists(final_path):
conversion_fallback = True
logger.warning("Final clean-audio conversion timed out; returning the cleaned source format")
if conversion_fallback:
shutil.copy2(clean_path, final_path)
elif not os.path.exists(final_path):
shutil.copy2(clean_path, final_path)
headers = {"X-Clean-Filename": clean_filename}
if conversion_fallback:
headers["X-Clean-Conversion"] = "fallback"
return FileResponse(final_path, media_type="audio/wav", filename=clean_filename,
headers={"X-Clean-Filename": clean_filename})
headers=headers)
@router.get("/system/asr-backends")
+19 -7
View File
@@ -25,6 +25,7 @@ the 4-step mirror contract documented there.
from __future__ import annotations
import json
import logging
import os
import threading
import time
@@ -33,6 +34,8 @@ from collections import OrderedDict
from core.config import DATA_DIR
from core.scrub import scrub_text
logger = logging.getLogger("omnivoice.error_journal")
JOURNAL_PATH = os.path.join(DATA_DIR, "error_journal.jsonl")
_MAX_ENTRIES = 50
@@ -137,17 +140,20 @@ def _fingerprint(error_class: str, exc: BaseException) -> str:
return hashlib.sha1(raw.encode("utf-8", "replace"), usedforsecurity=False).hexdigest()[:16]
def _persist_locked() -> None:
def _persist_locked() -> bool:
"""Rewrite the JSONL mirror from the in-memory ring. Caller holds _lock.
Never raises losing persistence must not break the exception handler."""
Never raises losing persistence must not break the exception handler.
Returns whether the durable mirror was updated."""
try:
tmp = JOURNAL_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for entry in _entries.values():
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp, JOURNAL_PATH)
return True
except Exception:
pass
logger.warning("Error journal persistence failed; entries remain available in memory")
return False
def _hydrate() -> None:
@@ -168,7 +174,7 @@ def _hydrate() -> None:
except FileNotFoundError:
pass
except Exception:
pass
logger.warning("Error journal could not be loaded; starting with an empty in-memory journal")
_hydrate()
@@ -231,10 +237,16 @@ def recent(limit: int = 20) -> list[dict]:
return list(reversed(items))[: max(1, min(limit, _MAX_ENTRIES))]
def clear() -> None:
def clear() -> bool:
"""Clear the journal, retaining memory state if durable deletion fails."""
with _lock:
_entries.clear()
try:
os.remove(JOURNAL_PATH)
except FileNotFoundError:
# Idempotent clear: an already-absent durable mirror is success.
logger.debug("Error journal mirror already absent during clear")
except OSError:
pass
logger.warning("Error journal could not be cleared; keeping entries available for retry")
return False
_entries.clear()
return True
+7 -4
View File
@@ -236,19 +236,22 @@ def touch_activity(kind: str, detail: str | None = None) -> None:
logger.debug("run-sentinel activity touch failed (non-fatal)", exc_info=True)
def clear_sentinel() -> None:
def clear_sentinel() -> bool:
"""Clean shutdown — remove the sentinel so the next startup knows this
run ended on purpose. Only removes a sentinel this run wrote."""
with _lock:
if not _state["owns"]:
return
return True
try:
os.remove(SENTINEL_PATH)
except FileNotFoundError:
pass
# Idempotent success: there is no stale marker to misread.
logger.debug("run sentinel already absent during clear")
except Exception:
logger.debug("run-sentinel clear failed (non-fatal)", exc_info=True)
logger.warning("run-sentinel clear failed; retaining ownership for retry")
return False
_state["owns"] = False
return True
# ── Unclean-shutdown detection + crash records ─────────────────────────────
+8 -6
View File
@@ -831,15 +831,17 @@ async def lifespan(app: FastAPI):
await close_http_client()
except Exception:
pass
logger.info("Shutdown: done.")
# Last thing on a clean shutdown: retire the run sentinel so the next
# startup doesn't misread this exit as a crash (#1164). After "Shutdown:
# done." on purpose — if anything above dies, the sentinel survives and
# the death still gets reported.
# startup doesn't misread this exit as a crash (#1164). If clearing fails,
# retain the sentinel and report a degraded shutdown truthfully.
try:
run_sentinel.clear_sentinel()
sentinel_cleared = run_sentinel.clear_sentinel()
except Exception:
pass
sentinel_cleared = False
if sentinel_cleared:
logger.info("Shutdown: done.")
else:
logger.warning("Shutdown completed, but the run sentinel could not be cleared")
from core.version import APP_VERSION # single source of truth (pyproject metadata)
+3 -2
View File
@@ -111,8 +111,9 @@ def create_mcp_server():
# sub-mounted. Harmless for the standalone CLI run() path.
try:
mcp.settings.streamable_http_path = "/"
except Exception:
pass
except Exception as exc:
logger.error("MCP transport path configuration failed")
raise RuntimeError("MCP transport could not be configured.") from exc
# Extend the MCP SDK's DNS-rebinding allowlist so agents on non-localhost
# hosts (Docker's host.containers.internal, a LAN IP, a reverse proxy) can
+6 -3
View File
@@ -831,14 +831,17 @@ def _cleanup_partial_download(job_dir: str) -> None:
A partial download left on disk would otherwise be picked up as a "finished"
file by the post-download codec probe, or collide with the next attempt's
output. Best-effort never raises on the failure path.
output. Raises a stable error instead of retrying against unsafe stale data.
"""
import glob
for stale in glob.glob(os.path.join(job_dir, "original.*")):
try:
os.remove(stale)
except OSError:
pass
except OSError as exc:
logger.warning("Partial video download cleanup failed")
raise RuntimeError(
"Could not prepare the video download retry. Close any app using its temporary files and retry."
) from exc
def _delete_cookie_export(cookie_file: str | None) -> bool:
+13 -4
View File
@@ -265,7 +265,10 @@ def env_opt_out() -> bool:
return (os.environ.get(MODE_ENV) or "").strip().lower() in _OPT_OUT_VALUES
def explicit_endpoint() -> str:
_PREF_READ_FAILED = object()
def explicit_endpoint():
"""The endpoint the user explicitly configured, or "".
Same resolution the download paths use: ``HF_ENDPOINT`` env (what
@@ -282,7 +285,8 @@ def explicit_endpoint() -> str:
return str(prefs.get("hf_endpoint", "") or "").strip().rstrip("/")
except Exception:
return ""
logger.warning("Endpoint preference could not be read; using manual mode")
return _PREF_READ_FAILED
def mode() -> str:
@@ -291,7 +295,8 @@ def mode() -> str:
Settings (including explicitly choosing the official endpoint)."""
if env_opt_out():
return "manual"
if explicit_endpoint():
endpoint = explicit_endpoint()
if endpoint is _PREF_READ_FAILED or endpoint:
return "manual"
try:
from core import prefs
@@ -299,7 +304,9 @@ def mode() -> str:
if str(prefs.get(_MODE_PREF, "") or "").strip().lower() == "manual":
return "manual"
except Exception:
pass
# A failed preference read must not opt the user into network racing.
logger.warning("Endpoint mode preference could not be read; using manual mode")
return "manual"
return "auto"
@@ -448,6 +455,8 @@ def effective_endpoint() -> Optional[str]:
per-download hot path. Never raises."""
try:
ep = explicit_endpoint()
if ep is _PREF_READ_FAILED:
return None
if ep:
return ep
if mode() != "auto":
+12 -4
View File
@@ -12,12 +12,15 @@ the flush dropdown) depends on ``{models, count}`` and
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import services.model_manager as mm
from services.model_manager import get_best_device
logger = logging.getLogger("omnivoice.model_lifecycle")
def _tts_vram_mb() -> float:
"""Best-effort allocated VRAM for the in-process model. Accurate on CUDA,
@@ -71,6 +74,7 @@ def list_loaded() -> dict:
"count": n}`` with per-model id/name/checkpoint/device/vram_mb/unloadable
(+ optional ``note``)."""
models: list[dict] = []
degraded_sources: list[str] = []
active_tts = _active_tts_id()
# 1. In-process TTS model (VoiceStudio)
@@ -131,7 +135,8 @@ def list_loaded() -> dict:
**_tts_attribution(s["id"], active_tts),
})
except Exception:
pass
logger.warning("Loaded-model inventory unavailable for subprocess sidecars")
degraded_sources.append("sidecars")
# 5. In-process engine instances that hold a model (mlx-audio, cosyvoice,
# voxcpm2, kittentts, …). These live in the generate path's instance
@@ -161,7 +166,8 @@ def list_loaded() -> dict:
**_tts_attribution(eid, active_tts),
})
except Exception:
pass
logger.warning("Loaded-model inventory unavailable for in-process engines")
degraded_sources.append("engines")
# 6. The warm capture/dictation ASR singleton — resident until idle-released
# (#1101 class). Held separately from the co-loaded WhisperX ASR above.
@@ -180,7 +186,8 @@ def list_loaded() -> dict:
"note": "released after the idle timeout",
})
except Exception:
pass
logger.warning("Loaded-model inventory unavailable for dictation")
degraded_sources.append("dictation")
# System memory snapshot — free/total RAM (and VRAM on a dedicated GPU) plus
# a low-memory advisory, so the panel can show pressure instead of leaving
@@ -196,7 +203,8 @@ def list_loaded() -> dict:
except Exception:
pass
return {"models": models, "count": len(models), "system": system}
return {"models": models, "count": len(models), "system": system,
"degraded_sources": degraded_sources}
async def unload(model_id: str) -> dict:
+55 -27
View File
@@ -6,6 +6,7 @@ are untouched (no restart). Disabling stops it, closing the 0.0.0.0 socket.
Loopback-only by default: nothing binds 0.0.0.0 until enable() is called.
"""
import asyncio
import logging
import os
import secrets
import socket
@@ -16,6 +17,7 @@ import psutil
import uvicorn
_DEFAULT_BACKEND_PORT = 3900 # must match backend/main.py uvicorn.run(port=...)
logger = logging.getLogger("omnivoice.network_share")
def backend_port() -> int:
@@ -61,9 +63,14 @@ class ShareState:
lan_addresses: list = field(default_factory=list)
_state = ShareState()
_server: Optional["uvicorn.Server"] = None
_task: Optional["asyncio.Task"] = None
@dataclass
class _ShareRuntime:
state: ShareState = field(default_factory=ShareState)
server: Optional["uvicorn.Server"] = None
task: Optional["asyncio.Task"] = None
_runtime = _ShareRuntime()
def lan_ipv4_addresses() -> list:
@@ -96,19 +103,18 @@ def _find_free_port(base: int, tries: int = 20) -> int:
def get_state() -> ShareState:
return _state
return _runtime.state
async def enable(app) -> ShareState:
global _server, _task, _state
if _state.enabled:
return _state
if _runtime.state.enabled:
return _runtime.state
port = _find_free_port(share_port_base())
pin = _gen_pin()
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="warning")
server = uvicorn.Server(config)
server.install_signal_handlers = lambda: None # never hijack signals in-process
_task = asyncio.create_task(server.serve())
_runtime.task = asyncio.create_task(server.serve())
for _ in range(100): # ~5s for the socket to bind
if getattr(server, "started", False):
break
@@ -119,27 +125,49 @@ async def enable(app) -> ShareState:
# with a listener that isn't actually up (spec §7).
server.should_exit = True
try:
await asyncio.wait_for(_task, timeout=2)
except Exception:
pass
_task = None
await asyncio.wait_for(asyncio.shield(_runtime.task), timeout=2)
except asyncio.CancelledError:
if _runtime.task.done():
_runtime.server = _runtime.task = None
_runtime.state = ShareState()
else:
_runtime.server = server
_runtime.state = ShareState(True, port, pin, lan_ipv4_addresses())
app.state.network_share = _runtime.state
raise
except Exception as exc:
if _runtime.task.done():
_runtime.server = _runtime.task = None
_runtime.state = ShareState()
app.state.network_share = _runtime.state
raise RuntimeError("share listener failed to start") from exc
_runtime.server = server
_runtime.state = ShareState(True, port, pin, lan_ipv4_addresses())
app.state.network_share = _runtime.state
logger.warning("Failed LAN listener startup could not be cleaned up")
raise RuntimeError(
"LAN share listener could not be stopped. Retry Disable before enabling again."
) from exc
_runtime.server = _runtime.task = None
raise RuntimeError("share listener failed to start")
_server = server
_state = ShareState(True, port, pin, lan_ipv4_addresses())
app.state.network_share = _state
return _state
_runtime.server = server
_runtime.state = ShareState(True, port, pin, lan_ipv4_addresses())
app.state.network_share = _runtime.state
return _runtime.state
async def disable(app) -> ShareState:
global _server, _task, _state
if _server is not None:
_server.should_exit = True
if _task is not None:
if _runtime.server is not None:
_runtime.server.should_exit = True
if _runtime.task is not None:
try:
await asyncio.wait_for(_task, timeout=5)
except Exception:
pass
_server = _task = None
_state = ShareState()
app.state.network_share = _state
return _state
await asyncio.wait_for(asyncio.shield(_runtime.task), timeout=5)
except Exception as exc:
logger.warning("LAN share listener did not stop; retaining enabled state")
raise RuntimeError(
"LAN sharing could not be disabled. Retry after active connections close."
) from exc
_runtime.server = _runtime.task = None
_runtime.state = ShareState()
app.state.network_share = _runtime.state
return _runtime.state
+7 -4
View File
@@ -114,8 +114,11 @@ def serve_disable() -> dict:
cli = _cli()
if not cli:
return {"ok": True}
try:
subprocess.run([cli, "serve", "reset"], capture_output=True, text=True, timeout=20)
except Exception:
pass
result = _run([cli, "serve", "reset"])
if not result["ok"]:
logger.warning("Tailscale sharing could not be disabled")
return {
"ok": False,
"error": "Tailscale sharing could not be disabled. Check that Tailscale is running and retry.",
}
return {"ok": True}
+6 -3
View File
@@ -612,10 +612,13 @@ class OmniVoiceBackend(TTSBackend):
try:
import services.model_manager as mm
if mm.model is not None:
mm.model = None
mm.free_vram()
except Exception:
pass
mm.model = None
except Exception as exc:
logger.warning("Shared voice model unload did not complete")
raise RuntimeError(
"The shared voice model could not be unloaded. Retry after the current generation finishes."
) from exc
# ── VoxCPM2 adapter (optional, scaffolded) ──────────────────────────────────
+43
View File
@@ -0,0 +1,43 @@
"""Optional API data may degrade, but the failure must remain observable."""
from contextlib import contextmanager
import importlib
def test_voice_catalog_db_failure_keeps_shape_and_warns(monkeypatch, caplog):
openai_compat = importlib.import_module("api.routers.openai_compat")
db = importlib.import_module("core.db")
@contextmanager
def broken_db():
raise OSError("database unavailable")
yield
monkeypatch.setattr(db, "db_conn", broken_db)
monkeypatch.setattr("services.tts_backend.list_backends", lambda: [])
with caplog.at_level("WARNING", logger="omnivoice.openai"):
result = openai_compat.list_voices()
assert set(result) == {"voices", "engines"}
assert result["voices"]
assert "built-in aliases only" in caplog.text
def test_notification_probe_failures_keep_shape_and_warn(monkeypatch, caplog):
system = importlib.import_module("api.routers.system")
run_sentinel = importlib.import_module("core.run_sentinel")
monkeypatch.setattr(
run_sentinel,
"newest_record",
lambda: (_ for _ in ()).throw(OSError("record unavailable")),
)
monkeypatch.setattr(
system,
"_crashed_last_session",
lambda: (_ for _ in ()).throw(OSError("log unavailable")),
)
with caplog.at_level("WARNING"):
result = system.system_notifications()
assert set(result) == {"notifications", "count"}
assert result["count"] == len(result["notifications"])
assert "Previous-run crash record could not be checked" in caplog.text
assert "Previous-session crash log could not be checked" in caplog.text
+21
View File
@@ -3,6 +3,7 @@ Tests for the dictation router (GET /dictation/models, GET/POST /dictation/prefs
the exact contract the frontend dictation UI binds to.
"""
import os
import importlib
import pytest
@@ -91,3 +92,23 @@ def test_set_prefs_accepts_repo_id_and_normalizes(client):
assert r.status_code == 200
# Stored as the canonical dictation id, not the repo_id.
assert r.json()["model_id"] == "sherpa-whisper-tiny"
def test_reset_failure_does_not_persist_new_preferences(monkeypatch):
services = importlib.import_module("services")
from api.routers import dictation as dr
store = {dr.PREF_MODE: "toggle"}
monkeypatch.setattr(dr.prefs, "get", lambda key, default=None: store.get(key, default))
monkeypatch.setattr(dr.prefs, "set_", lambda key, value: store.__setitem__(key, value))
class _BrokenBackend:
def __setattr__(self, _name, _value):
raise RuntimeError("capture service unavailable")
monkeypatch.setattr(services, "asr_backend", _BrokenBackend())
with pytest.raises(Exception) as caught:
dr.set_dictation_prefs(dr.DictationPrefsUpdate(mode="hold"))
assert getattr(caught.value, "status_code", None) == 503
assert store == {dr.PREF_MODE: "toggle"}
+37
View File
@@ -91,3 +91,40 @@ def test_record_never_raises(monkeypatch):
entry = record(ValueError("still works"))
assert entry["error_class"] == "UNKNOWN"
assert entry["count"] == 1
def test_persistence_failure_is_observable_without_losing_memory(monkeypatch, caplog):
monkeypatch.setattr(error_journal, "JOURNAL_PATH", "/nonexistent/dir/x.jsonl")
with caplog.at_level("WARNING", logger="omnivoice.error_journal"):
entry = record(ValueError("still visible"))
assert entry in recent()
assert "persistence failed" in caplog.text
def test_hydrate_failure_is_observable(monkeypatch, caplog, tmp_path):
journal_dir = tmp_path / "journal-dir"
journal_dir.mkdir()
monkeypatch.setattr(error_journal, "JOURNAL_PATH", str(journal_dir))
with caplog.at_level("WARNING", logger="omnivoice.error_journal"):
error_journal._hydrate()
assert "could not be loaded" in caplog.text
def test_clear_failure_keeps_entries_retryable(monkeypatch, caplog):
entry = record(ValueError("keep me"))
monkeypatch.setattr(
error_journal.os,
"remove",
lambda _path: (_ for _ in ()).throw(PermissionError("locked")),
)
with caplog.at_level("WARNING", logger="omnivoice.error_journal"):
assert error_journal.clear() is False
assert entry in recent()
assert "keeping entries available for retry" in caplog.text
def test_clear_is_idempotent_when_durable_mirror_is_already_absent(tmp_path, monkeypatch):
monkeypatch.setattr(error_journal, "JOURNAL_PATH", str(tmp_path / "absent.jsonl"))
error_journal._entries["entry"] = {"fingerprint": "entry"}
assert error_journal.clear() is True
assert recent() == []
+2 -5
View File
@@ -87,11 +87,8 @@ def test_pin_only_remote_discovery_never_returns_share_pin(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
monkeypatch.setattr(
live_network_share,
"_state",
live_network_share.ShareState(True, 3901, "123456", ["192.168.1.10"]),
)
monkeypatch.setattr(live_network_share._runtime, "state",
live_network_share.ShareState(True, 3901, "123456", ["192.168.1.10"]))
# Keep the consumption middleware inert: this endpoint is testing the
# intentional admin read-only exception itself, before a PIN is supplied.
monkeypatch.setattr(app.state, "network_share", None, raising=False)
+318
View File
@@ -0,0 +1,318 @@
"""Failure paths must not claim state transitions that did not complete."""
import asyncio
import importlib
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
def test_partial_download_cleanup_blocks_unsafe_retry(monkeypatch, tmp_path):
pipeline = importlib.import_module("services.dub_pipeline")
stale = tmp_path / "original.partial"
stale.write_bytes(b"partial")
monkeypatch.setattr("glob.glob", lambda _pattern: [str(stale)])
monkeypatch.setattr(
pipeline.os,
"remove",
lambda _path: (_ for _ in ()).throw(PermissionError("/secret/video")),
)
with pytest.raises(RuntimeError) as caught:
pipeline._cleanup_partial_download(str(tmp_path))
assert "prepare the video download retry" in str(caught.value)
assert "/secret/video" not in str(caught.value)
def test_mcp_transport_path_failure_stops_server_creation(monkeypatch):
mcp_server = importlib.import_module("mcp_server")
class RejectPath:
transport_security = SimpleNamespace(allowed_hosts=[])
def __setattr__(self, name, value):
if name == "streamable_http_path":
raise RuntimeError("/secret/sdk/path")
object.__setattr__(self, name, value)
class FakeFastMCP:
def __init__(self, *_args, **_kwargs):
self.settings = RejectPath()
monkeypatch.setattr(mcp_server, "_ensure_mcp", lambda: FakeFastMCP)
with pytest.raises(RuntimeError) as caught:
mcp_server.create_mcp_server()
assert str(caught.value) == "MCP transport could not be configured."
def test_endpoint_preference_failure_is_fail_closed_to_manual(monkeypatch):
endpoint_race = importlib.import_module("services.endpoint_race")
prefs = importlib.import_module("core.prefs")
monkeypatch.delenv("HF_ENDPOINT", raising=False)
monkeypatch.delenv("OMNIVOICE_HF_ENDPOINT_MODE", raising=False)
monkeypatch.setattr(endpoint_race, "explicit_endpoint", lambda: "")
monkeypatch.setattr(prefs, "get", lambda *_args: (_ for _ in ()).throw(OSError("secret")))
assert endpoint_race.mode() == "manual"
@pytest.mark.asyncio
async def test_sherpa_ready_delivery_failure_does_not_report_loaded(monkeypatch):
capture_ws = importlib.import_module("api.routers.capture_ws")
sherpa = importlib.import_module("services.sherpa_dictation")
monkeypatch.setattr(sherpa, "is_installed", lambda _spec: True)
class Socket:
calls = 0
async def send_json(self, _payload):
self.calls += 1
if self.calls == 2:
raise ConnectionError("secret socket")
backend = SimpleNamespace(ensure_loaded=lambda: None)
assert await capture_ws._sherpa_load_with_status(Socket(), backend, SimpleNamespace(id="test")) is False
def test_audio_validation_failure_does_not_return_unchecked_audio(monkeypatch):
generation = importlib.import_module("api.routers.generation")
torch = importlib.import_module("torch")
audio = torch.tensor([float("nan")])
monkeypatch.setattr(torch, "isfinite", lambda _audio: (_ for _ in ()).throw(RuntimeError("secret")))
with pytest.raises(RuntimeError) as caught:
generation._sanitize_audio(audio)
assert str(caught.value) == "Generated audio could not be validated. Retry the generation."
@pytest.mark.asyncio
async def test_log_stream_start_failure_returns_stable_error(monkeypatch, tmp_path):
system = importlib.import_module("api.routers.system")
log = tmp_path / "private.log"
log.write_text("old secret\n", encoding="utf-8")
monkeypatch.setattr(system, "LOG_PATH", str(log))
monkeypatch.setattr(system.os.path, "getsize", lambda _path: (_ for _ in ()).throw(OSError(str(log))))
with pytest.raises(HTTPException) as caught:
await system.stream_logs(source="backend", interval=1.0)
assert caught.value.status_code == 503
assert str(log) not in caught.value.detail
@pytest.mark.asyncio
async def test_log_clear_does_not_hide_notification_reset_failure(monkeypatch, tmp_path):
system = importlib.import_module("api.routers.system")
log = tmp_path / "backend.log"
log.write_text("data", encoding="utf-8")
monkeypatch.setattr(system, "LOG_PATH", str(log))
monkeypatch.setattr(system, "CRASH_LOG_PATH", str(tmp_path / "missing.log"))
monkeypatch.setattr(
system,
"prefs_delete",
lambda _key: (_ for _ in ()).throw(OSError("/secret/prefs")),
)
with pytest.raises(HTTPException) as caught:
await system.clear_system_logs()
assert caught.value.status_code == 500
assert "/secret/prefs" not in caught.value.detail
def test_tailscale_disable_reports_reset_failure_without_raw_output(monkeypatch):
tailscale = importlib.import_module("services.tailscale")
monkeypatch.setattr(tailscale, "_cli", lambda: "/usr/bin/tailscale")
monkeypatch.setattr(tailscale, "_run", lambda _args: {"ok": False, "error": "/secret/stderr"})
result = tailscale.serve_disable()
assert result["ok"] is False
assert "/secret/stderr" not in result["error"]
@pytest.mark.asyncio
async def test_network_disable_retains_enabled_state_when_listener_does_not_stop(monkeypatch):
network_share = importlib.import_module("services.network_share")
loop = asyncio.get_running_loop()
task = loop.create_future()
task.set_exception(PermissionError("secret listener"))
server = SimpleNamespace(should_exit=False)
state = network_share.ShareState(True, 3901, "123456", ["192.0.2.1"])
app = SimpleNamespace(state=SimpleNamespace(network_share=state))
monkeypatch.setattr(network_share._runtime, "server", server)
monkeypatch.setattr(network_share._runtime, "task", task)
monkeypatch.setattr(network_share._runtime, "state", state)
with pytest.raises(RuntimeError) as caught:
await network_share.disable(app)
assert str(caught.value) == "LAN sharing could not be disabled. Retry after active connections close."
assert network_share.get_state() is state
assert app.state.network_share is state
def test_dub_abort_failure_keeps_job_retryable(monkeypatch):
dub_core = importlib.import_module("api.routers.dub_core")
job = {"id": "job-1"}
monkeypatch.setitem(dub_core._dub_jobs, "job-1", job)
monkeypatch.setattr(dub_core, "_kill_job_procs", lambda _job_id: None)
monkeypatch.setattr(
dub_core.task_manager,
"cancel_task",
lambda _job_id: (_ for _ in ()).throw(RuntimeError("secret task")),
)
with pytest.raises(HTTPException) as caught:
dub_core.dub_abort("job-1")
assert caught.value.status_code == 503
assert "aborted" not in job
assert "secret task" not in caught.value.detail
def test_run_sentinel_clear_failure_retains_ownership(monkeypatch):
sentinel = importlib.import_module("core.run_sentinel")
monkeypatch.setitem(sentinel._state, "owns", True)
monkeypatch.setattr(
sentinel.os,
"remove",
lambda _path: (_ for _ in ()).throw(PermissionError("secret sentinel")),
)
assert sentinel.clear_sentinel() is False
assert sentinel._state["owns"] is True
def test_loaded_model_inventory_reports_degraded_source(monkeypatch, caplog):
lifecycle = importlib.import_module("services.model_lifecycle")
sidecars = importlib.import_module("services.subprocess_backend")
monkeypatch.setattr(sidecars, "list_live_sidecars", lambda: (_ for _ in ()).throw(RuntimeError("/secret/model")))
result = lifecycle.list_loaded()
assert "sidecars" in result["degraded_sources"]
assert "/secret/model" not in caplog.text
def test_configured_endpoint_failure_never_probes_official_host(monkeypatch):
wizard = importlib.import_module("api.routers.setup.wizard")
failure = importlib.import_module("core.failure")
endpoint_race = importlib.import_module("services.endpoint_race")
monkeypatch.setattr(endpoint_race, "mode", lambda: "manual")
monkeypatch.setattr(failure, "configured_hf_mirror", lambda: (_ for _ in ()).throw(RuntimeError("/secret/config")))
monkeypatch.setattr(wizard, "_probe_network", lambda *_a, **_k: pytest.fail("must not probe a fallback host"))
result = wizard._network_check()
assert result["status"] == "warn"
assert "secret" not in result["detail"]
def test_persona_cleanup_reports_failure_without_path(monkeypatch, caplog):
personas = importlib.import_module("api.routers.personas")
monkeypatch.setattr(personas.os.path, "exists", lambda _p: True)
monkeypatch.setattr(personas.os, "remove", lambda _p: (_ for _ in ()).throw(PermissionError("/secret/persona")))
assert personas._cleanup(["/secret/persona"]) is False
assert "/secret/persona" not in caplog.text
def test_shared_voice_unload_failure_is_stable(monkeypatch):
tts = importlib.import_module("services.tts_backend")
manager = importlib.import_module("services.model_manager")
backend = object.__new__(tts.OmniVoiceBackend)
backend._model = object()
monkeypatch.setattr(tts, "clear_clone_prompt_cache", lambda: None)
monkeypatch.setattr(manager, "model", object())
monkeypatch.setattr(manager, "free_vram", lambda: (_ for _ in ()).throw(RuntimeError("/secret/gpu")))
with pytest.raises(RuntimeError) as caught:
backend.unload()
assert str(caught.value) == "The shared voice model could not be unloaded. Retry after the current generation finishes."
assert manager.model is not None
monkeypatch.setattr(manager, "free_vram", lambda: None)
backend.unload()
assert manager.model is None
@pytest.mark.asyncio
async def test_terminal_network_start_failure_resets_state(monkeypatch):
network_share = importlib.import_module("services.network_share")
task = asyncio.get_running_loop().create_future()
task.set_exception(RuntimeError("/secret/listener"))
server = SimpleNamespace(started=False, should_exit=False, serve=lambda: None)
monkeypatch.setattr(network_share, "_find_free_port", lambda _base: 3901)
monkeypatch.setattr(network_share, "_gen_pin", lambda: "123456")
monkeypatch.setattr(network_share.uvicorn, "Server", lambda _config: server)
monkeypatch.setattr(network_share.asyncio, "create_task", lambda _coro: task)
async def no_sleep(_seconds):
return None
monkeypatch.setattr(network_share.asyncio, "sleep", no_sleep)
app = SimpleNamespace(state=SimpleNamespace())
with pytest.raises(RuntimeError) as caught:
await network_share.enable(app)
assert "secret" not in str(caught.value)
assert network_share.get_state().enabled is False
assert network_share._runtime.task is None
@pytest.mark.asyncio
async def test_live_network_start_cleanup_can_be_retried_by_disable(monkeypatch):
network_share = importlib.import_module("services.network_share")
task = asyncio.get_running_loop().create_future()
server = SimpleNamespace(started=False, should_exit=False, serve=lambda: None)
monkeypatch.setattr(network_share, "_find_free_port", lambda _base: 3901)
monkeypatch.setattr(network_share, "_gen_pin", lambda: "123456")
monkeypatch.setattr(network_share, "lan_ipv4_addresses", lambda: [])
monkeypatch.setattr(network_share.uvicorn, "Server", lambda _config: server)
monkeypatch.setattr(network_share.asyncio, "create_task", lambda _coro: task)
async def no_sleep(_seconds):
return None
monkeypatch.setattr(network_share.asyncio, "sleep", no_sleep)
calls = 0
async def retryable_wait(_awaitable, timeout):
nonlocal calls
calls += 1
if calls == 1:
raise asyncio.TimeoutError
return None
monkeypatch.setattr(network_share.asyncio, "wait_for", retryable_wait)
app = SimpleNamespace(state=SimpleNamespace())
with pytest.raises(RuntimeError):
await network_share.enable(app)
assert network_share.get_state().enabled is True
await network_share.disable(app)
assert network_share.get_state().enabled is False
@pytest.mark.asyncio
async def test_cancelled_terminal_network_start_resets_state(monkeypatch):
network_share = importlib.import_module("services.network_share")
task = asyncio.get_running_loop().create_future()
task.cancel()
server = SimpleNamespace(started=False, should_exit=False, serve=lambda: None)
monkeypatch.setattr(network_share, "_find_free_port", lambda _base: 3901)
monkeypatch.setattr(network_share.uvicorn, "Server", lambda _config: server)
monkeypatch.setattr(network_share.asyncio, "create_task", lambda _coro: task)
async def no_sleep(_seconds):
return None
monkeypatch.setattr(network_share.asyncio, "sleep", no_sleep)
app = SimpleNamespace(state=SimpleNamespace())
with pytest.raises(asyncio.CancelledError):
await network_share.enable(app)
assert network_share.get_state().enabled is False
assert network_share._runtime.server is None
assert network_share._runtime.task is None
assert app.state.network_share.enabled is False
def test_dub_abort_false_result_stays_retryable(monkeypatch):
dub_core = importlib.import_module("api.routers.dub_core")
job = {"id": "job-false"}
monkeypatch.setitem(dub_core._dub_jobs, "job-false", job)
monkeypatch.setattr(dub_core, "_kill_job_procs", lambda _job_id: None)
monkeypatch.setattr(dub_core.task_manager, "cancel_task", lambda _job_id: False)
with pytest.raises(HTTPException) as caught:
dub_core.dub_abort("job-false")
assert caught.value.status_code == 503
assert "aborted" not in job
def test_endpoint_pref_read_failure_keeps_manual_mode(monkeypatch):
endpoint_race = importlib.import_module("services.endpoint_race")
prefs = importlib.import_module("core.prefs")
calls = iter([OSError("/secret/pref"), "auto"])
def read(_key, _default=""):
value = next(calls)
if isinstance(value, Exception):
raise value
return value
monkeypatch.delenv("HF_ENDPOINT", raising=False)
monkeypatch.setattr(prefs, "get", read)
assert endpoint_race.mode() == "manual"