Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) ──────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user