diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 106a6a30..632203ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,9 @@ jobs: # never been wired into CI, so its cases were a regression test nothing # ran. Cheap (pure bash, stubs pkg-config) and it gates the class. - name: AppImage launcher (AppRun) unit tests - run: bash frontend/src-tauri/appimage/AppRun.test.sh + run: | + bash frontend/src-tauri/appimage/AppRun.test.sh + bash scripts/inject-apprun.test.sh # `backend/tests/` mounts routers on bare FastAPI apps (no heavy main # import chain) with a hermetic data dir from its conftest.py. It no diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85eec6e9..9761f9e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -751,6 +751,17 @@ jobs: "$APPIMAGE" --appimage-extract >/dev/null ROOT="$EXTRACT_DIR/squashfs-root" fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; } + # Regression gate: beforeBundleCommand runs before Tauri creates the + # AppDir. The v0.4.2 artifact therefore silently shipped Tauri's + # stock AppRun and bypassed every WebKit/Mesa compatibility fix. + cmp -s "$ROOT/AppRun" "$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \ + || fail "custom AppRun missing from final AppImage" + [ -s "$ROOT/usr/lib/.bundled-webkitgtk-version" ] \ + || fail "bundled WebKitGTK version marker missing" + cmp -s \ + "$ROOT/usr/lib/.bundled-webkitgtk-version" \ + "$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version" \ + || fail "bundled WebKitGTK version marker is stale or mismatched" # Thin uv-venv installer: verify the AppImage carries the shell binary, # the bundled uv sidecar, and the backend source resources. { [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b168675..7902db5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently. ### Fixed - Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459) +- Linux releases now verify that the AppImage actually contains the compatibility launcher, instead of silently shipping Tauri's stock launcher and opening as a blank window on newer Mesa systems. (#1464) - Patched dependency releases now cover 35 Python and Rust security advisories without weakening VoiceStudio's GPU or offline-runtime compatibility. (#1456, #1472, #1473, #1474, #1475, #1476, #1477) - Curated models now install and repair from reviewed, immutable revisions; custom MOSS remote code requires an explicit safety opt-in. (#1453) - YouTube imports that require a signed-in session can now use an explicitly selected `cookies.txt` export for one import; VoiceStudio never reads browser cookies silently and makes two best-effort attempts to delete its temporary copy. (#1429, #1432) — thanks @dongqing1968-sudo and @phamvandu9595-tech! diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index 11fb6ac9..cab72687 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -273,7 +273,8 @@ def dub_abort(job_id: str): had_procs = bool(_active_procs.get(job_id)) _kill_job_procs(job_id) try: - task_manager.cancel_task(job_id) + 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( diff --git a/backend/api/routers/personas.py b/backend/api/routers/personas.py index 7efdcd3c..650761a9 100644 --- a/backend/api/routers/personas.py +++ b/backend/api/routers/personas.py @@ -241,7 +241,7 @@ async def import_persona(file: UploadFile = File(...)): raise except Exception: cleaned = _cleanup(written) - logger.exception("persona import failed") + 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) diff --git a/backend/api/routers/system.py b/backend/api/routers/system.py index 025b5843..c0ed7f41 100644 --- a/backend/api/routers/system.py +++ b/backend/api/routers/system.py @@ -985,7 +985,9 @@ async def _do_clean_audio(audio, tmp_dir, clean_id): except asyncio.TimeoutError: conversion_fallback = True logger.warning("Final clean-audio conversion timed out; returning the cleaned source format") - if not os.path.exists(final_path): + 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} diff --git a/backend/services/endpoint_race.py b/backend/services/endpoint_race.py index 47f006ff..8f15afc2 100644 --- a/backend/services/endpoint_race.py +++ b/backend/services/endpoint_race.py @@ -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 @@ -450,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": diff --git a/backend/services/network_share.py b/backend/services/network_share.py index 531458d2..028b71e8 100644 --- a/backend/services/network_share.py +++ b/backend/services/network_share.py @@ -122,7 +122,17 @@ async def enable(app) -> ShareState: server.should_exit = True try: await asyncio.wait_for(asyncio.shield(_task), timeout=2) + except asyncio.CancelledError: + _server = server + _state = ShareState(True, port, pin, lan_ipv4_addresses()) + app.state.network_share = _state + raise except Exception as exc: + if _task.done(): + _server = _task = None + _state = ShareState() + app.state.network_share = _state + raise RuntimeError("share listener failed to start") from exc _server = server _state = ShareState(True, port, pin, lan_ipv4_addresses()) app.state.network_share = _state diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index 58c627d9..9f1946de 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -612,8 +612,8 @@ class OmniVoiceBackend(TTSBackend): try: import services.model_manager as mm if mm.model is not None: - mm.model = None mm.free_vram() + mm.model = None except Exception as exc: logger.warning("Shared voice model unload did not complete") raise RuntimeError( diff --git a/docs/adr/apprun-strategy.md b/docs/adr/apprun-strategy.md index 187ee222..7add24c3 100644 --- a/docs/adr/apprun-strategy.md +++ b/docs/adr/apprun-strategy.md @@ -19,13 +19,12 @@ Tauri 2's AppImage bundler auto-generates an `AppRun` shell launcher inside the config key in Tauri 2.x as of this writing. The chosen injection strategy is: > **A custom `AppRun` template sourced from `frontend/src-tauri/appimage/AppRun` -> is copied into the AppImage staging directory by a `beforeBundleCommand` -> (Tauri 2 supports this hook).** +> is installed into Tauri's project-local AppImage tool cache by a +> `beforeBundleCommand`. Tauri then copies that launcher into the AppDir.** -The script that copies it lives at `scripts/inject-apprun.sh` and is invoked -from `package.json` via the existing `bun run build` pipeline, so the -operation is wired into the same `cargo tauri build --bundles appimage` call -the release pipeline already uses. +The script that seeds the cache lives at `scripts/inject-apprun.sh`. The final +release smoke test extracts the AppImage and compares its `AppRun` byte-for-byte +with the source template, so a stock launcher cannot ship silently again. --- @@ -58,25 +57,21 @@ Tauri's `bundle.linux.appimage` accepts `bundleMediaFramework: bool` and `files: HashMap` but **not** an `appRun` / `template` key. Tracking issue: `tauri-apps/tauri#7616` is still open. -### B. `beforeBundleCommand` hook (CHOSEN) +### B. Replace the staged AppDir from `beforeBundleCommand` (REJECTED) -Tauri 2 added `build.beforeBundleCommand` as a sibling to `beforeBuildCommand`. -It runs AFTER `cargo build` but BEFORE the bundler packs the AppDir into an -AppImage. This is exactly the right point to swap the AppRun: -- The AppDir staging directory exists at a predictable path - (`target/${profile}/bundle/appimage/*.AppDir/`) -- The auto-generated `AppRun` is already in place -- We just overwrite it with our version before `appimagetool` runs +`beforeBundleCommand` runs before `tauri-bundler` creates the AppDir. The old +implementation globbed for that future directory, found nothing, exited zero, +and v0.4.2 shipped Tauri's stock launcher. This timing cannot be made reliable. -Tradeoffs: -- ✓ No re-packing the squashfs after the fact (faster, simpler) -- ✓ One-line tauri.conf.json change + small shell script -- ✓ Cross-platform safe: the hook only fires when Linux + AppImage are in the - active target list, so macOS/Windows builds are unaffected -- ✗ The staging path is glob-discovered (the .AppDir name follows - `productName`), so the script handles `productName` changes gracefully +### C. Seed Tauri's local AppImage tool cache (CHOSEN) -### C. Post-bundle re-pack with `appimagetool` +With `bundle.useLocalToolsDir`, Tauri reads its launcher from +`target/.tauri/AppRun-` and copies it into the newly created AppDir. The +hook installs our launcher at that existing extension point before the bundler +runs. `bundle.linux.appimage.files` carries the generated WebKitGTK version +marker into `usr/lib`, where the launcher reads it. + +### D. Post-bundle re-pack with `appimagetool` Status: **rejected.** This would require unpacking the AppImage's squashfs after Tauri produces it, replacing AppRun, re-running `appimagetool --no-appstream`, @@ -87,7 +82,7 @@ release-pipeline dep. Strategy B avoids both. ## Chosen strategy -**Strategy B — `beforeBundleCommand` hook + custom AppRun template.** +**Strategy C — project-local Tauri tool cache + custom AppRun template.** ### Files @@ -95,8 +90,10 @@ release-pipeline dep. Strategy B avoids both. |---|---| | `frontend/src-tauri/appimage/AppRun` | The custom launcher shell script (source of truth, version-controlled) | | `frontend/src-tauri/appimage/AppRun.test.sh` | Shell unit test (W-1) — 4 cases for the WebKit version conditional | -| `scripts/inject-apprun.sh` | Glob the AppDir under `frontend/src-tauri/target/release/bundle/appimage/*.AppDir/` and `cp -f` the AppRun in. Idempotent. | -| `frontend/src-tauri/tauri.conf.json` | `build.beforeBundleCommand` wires the hook into the bundler pipeline | +| `scripts/inject-apprun.sh` | Seed `target/.tauri/AppRun-` and generate the bundled WebKitGTK marker | +| `scripts/inject-apprun.test.sh` | Regression test for cache seeding, executable mode and version stamping | +| `frontend/src-tauri/tauri.conf.json` | Enable the local tool cache and package its generated marker | +| `.github/workflows/release.yml` | Extract the final artifact and reject a stock launcher or missing marker | ### Wire-up @@ -104,14 +101,15 @@ release-pipeline dep. Strategy B avoids both. // frontend/src-tauri/tauri.conf.json { "build": { - "beforeBundleCommand": "bash ../../scripts/inject-apprun.sh" + "beforeBundleCommand": "bash ../scripts/inject-apprun.sh" // ... existing keys } } ``` -The hook is a no-op when `--bundles appimage` is not in the active target -list (the script `glob`s for the AppDir; if none exist, it exits 0). +The hook is a no-op on non-Linux hosts. On Linux, an unknown architecture or +missing WebKitGTK version is a build failure rather than a silently broken +artifact. ### Why `WEBKIT_DISABLE_COMPOSITING_MODE` is conditional, not unconditional @@ -130,7 +128,7 @@ When Tauri 2 ships a first-class `appRun` template key (see open `tauri-apps/tauri#7616`), the migration is: 1. Move `frontend/src-tauri/appimage/AppRun` content into the new config key -2. Delete `scripts/inject-apprun.sh` +2. Delete `scripts/inject-apprun.sh` and its cache-seeding test 3. Remove the `beforeBundleCommand` line that invokes it 4. Keep `AppRun.test.sh` as-is — it still validates the conditional logic diff --git a/frontend/src-tauri/appimage/AppRun b/frontend/src-tauri/appimage/AppRun index c6cb4fed..b288268a 100755 --- a/frontend/src-tauri/appimage/AppRun +++ b/frontend/src-tauri/appimage/AppRun @@ -11,7 +11,7 @@ # known-broken ranges. Setting it unconditionally regresses healthy WebKit # versions (2.48+) where the compositing path works fine. # -# This file is copied into the AppImage staging directory by +# This file is installed into Tauri's local tool cache by # scripts/inject-apprun.sh (wired into Tauri's beforeBundleCommand). See # .planning/decisions/apprun-strategy.md for the decision rationale. @@ -30,13 +30,13 @@ HERE="$(dirname -- "$(readlink -f -- "$0")")" # dev packages installed, so pkg-config answers with their system's healthy # 2.48 while the bundle runs an older lib — skipping a workaround the running # library needs). inject-apprun.sh stamps the bundled version into -# .bundled-webkitgtk-version at build time, where it is knowable by +# usr/lib/.bundled-webkitgtk-version at build time, where it is knowable by # construction; the host pkg-config path survives only as a fallback for # bundles predating the stamp. OMNIVOICE_APPRUN_WK_MARKER exists for the # unit tests to point at a fixture marker. _detect_webkit_workaround() { local wk_version="0.0" - local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/.bundled-webkitgtk-version}" + local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/usr/lib/.bundled-webkitgtk-version}" if [ -r "$marker" ]; then # Empty/unreadable marker content → "0.0" (unknown) → fail-safe workaround, # same philosophy as the missing-pkg-config branch below. @@ -125,7 +125,7 @@ _prefer_system_webkit() { # Only meaningful when we know what we bundled; an unstamped bundle keeps # the old ordering rather than guessing. - local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/.bundled-webkitgtk-version}" + local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/usr/lib/.bundled-webkitgtk-version}" [ -r "$marker" ] || return 1 local bundled bundled="$(cat "$marker" 2>/dev/null | tr -d '[:space:]')" diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 80f108e6..568ef369 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -55,6 +55,7 @@ }, "bundle": { "active": true, + "useLocalToolsDir": true, "targets": [ "dmg", "app", @@ -83,6 +84,13 @@ "binaries/ffmpeg", "binaries/ffprobe" ], + "linux": { + "appimage": { + "files": { + "usr/lib/.bundled-webkitgtk-version": "target/.tauri/bundled-webkitgtk-version" + } + } + }, "macOS": { "minimumSystemVersion": "13.3", "signingIdentity": "-", diff --git a/scripts/inject-apprun.sh b/scripts/inject-apprun.sh index f9aa453a..d8adab95 100755 --- a/scripts/inject-apprun.sh +++ b/scripts/inject-apprun.sh @@ -1,75 +1,50 @@ #!/usr/bin/env bash -# Inject our custom AppRun into Tauri's auto-generated AppImage staging dir. +# Seed Tauri's AppImage tool cache with VoiceStudio's launcher. # -# Wired into tauri.conf.json's `build.beforeBundleCommand`, this script runs -# AFTER `cargo build` but BEFORE `appimagetool` packs the .AppDir. We overwrite -# the default AppRun (which Tauri generates without WEBKIT_DISABLE_COMPOSITING_MODE -# handling) with our conditional launcher. -# -# Issue: #56 (AppImage white-screen on Fedora 44 / Ubuntu 24.04) -# Decision: docs/adr/apprun-strategy.md -# -# Idempotent + safe on non-Linux: if no AppDir staging exists (e.g. macOS -# build, or `--bundles app` only), the script exits 0 cleanly. +# Tauri copies target/.tauri/AppRun- into the AppDir after +# beforeBundleCommand returns. Replacing an AppDir/AppRun here cannot work: +# the AppDir does not exist until the bundler runs. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - APPRUN_SRC="$REPO_ROOT/frontend/src-tauri/appimage/AppRun" +# beforeBundleCommand also runs for macOS and Windows bundles. +case "${OSTYPE:-}" in + linux*) ;; + *) exit 0 ;; +esac + if [ ! -f "$APPRUN_SRC" ]; then echo "inject-apprun: source not found: $APPRUN_SRC" >&2 exit 1 fi -# Tauri's AppImage staging dir: frontend/src-tauri/target/{profile}/bundle/appimage/*.AppDir/ -# The .AppDir name follows productName (e.g. "VoiceStudio.AppDir"). -# Glob across both release and debug profiles in case the caller used --debug. -STAGE_BASE_RELEASE="$REPO_ROOT/frontend/src-tauri/target/release/bundle/appimage" -STAGE_BASE_DEBUG="$REPO_ROOT/frontend/src-tauri/target/debug/bundle/appimage" +TOOLS_DIR="${OMNIVOICE_TAURI_TOOLS_DIR:-$REPO_ROOT/frontend/src-tauri/target/.tauri}" +ARCH="${OMNIVOICE_TARGET_ARCH:-$(uname -m)}" +case "$ARCH" in + x86_64|amd64) ARCH=x86_64 ;; + aarch64|arm64) ARCH=aarch64 ;; + armv7l|armhf) ARCH=armhf ;; + *) + echo "inject-apprun: unsupported Linux architecture: $ARCH" >&2 + exit 1 + ;; +esac -found=0 -for stage_base in "$STAGE_BASE_RELEASE" "$STAGE_BASE_DEBUG"; do - if [ ! -d "$stage_base" ]; then - continue - fi - # Use a glob loop instead of `find` to avoid surprises with names containing spaces. - shopt -s nullglob - for appdir in "$stage_base"/*.AppDir; do - if [ -d "$appdir" ]; then - echo "inject-apprun: replacing AppRun in $appdir" - cp -f "$APPRUN_SRC" "$appdir/AppRun" - chmod 755 "$appdir/AppRun" - # Stamp the bundled WebKitGTK version (#961 follow-up). The AppImage - # bundles THIS build host's libwebkit2gtk, so the host's pkg-config - # answer here is the version the shipped bundle will actually run — - # knowable by construction at bundle time, unknowable reliably at - # runtime (a user's pkg-config reports their SYSTEM's version, which - # LD_LIBRARY_PATH overrides with the bundled copy). AppRun's workaround - # auto-detection reads this marker first and only falls back to host - # pkg-config when the marker is absent (bundles predating the stamp). - wk_bundled="$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \ - || pkg-config --modversion webkit2gtk-4.0 2>/dev/null \ - || echo "")" - if [ -n "$wk_bundled" ]; then - printf '%s\n' "$wk_bundled" > "$appdir/.bundled-webkitgtk-version" - echo "inject-apprun: stamped bundled WebKitGTK version: $wk_bundled" - else - echo "inject-apprun: WARNING — could not read the bundled WebKitGTK version (pkg-config missing?); AppRun will use its runtime fallback" >&2 - fi - found=1 - fi - done - shopt -u nullglob -done +mkdir -p "$TOOLS_DIR" +install -m 755 "$APPRUN_SRC" "$TOOLS_DIR/AppRun-$ARCH" -if [ $found -eq 0 ]; then - # Not necessarily an error — beforeBundleCommand runs unconditionally even - # when the active target list does not include appimage. Stay quiet so - # macOS/Windows builds do not see noisy stderr. - echo "inject-apprun: no AppDir staging found (skipping — not an AppImage build)" +# Tauri's appimage.files copies this into usr/lib. AppRun reads the marker +# there to compare the bundled WebKitGTK with the host copy it may prefer. +WK_VERSION="${OMNIVOICE_WEBKIT_VERSION:-$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \ + || pkg-config --modversion webkit2gtk-4.0 2>/dev/null || true)}" +if [ -z "$WK_VERSION" ]; then + echo "inject-apprun: bundled WebKitGTK version is unavailable" >&2 + exit 1 fi +printf '%s\n' "$WK_VERSION" > "$TOOLS_DIR/bundled-webkitgtk-version" -exit 0 +echo "inject-apprun: seeded AppRun-$ARCH (WebKitGTK $WK_VERSION)" diff --git a/scripts/inject-apprun.test.sh b/scripts/inject-apprun.test.sh new file mode 100755 index 00000000..19c3e85f --- /dev/null +++ b/scripts/inject-apprun.test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +OMNIVOICE_TAURI_TOOLS_DIR="$TMP/tools" \ +OMNIVOICE_TARGET_ARCH=amd64 \ +OMNIVOICE_WEBKIT_VERSION=2.48.7 \ + bash "$REPO_ROOT/scripts/inject-apprun.sh" + +cmp -s \ + "$REPO_ROOT/frontend/src-tauri/appimage/AppRun" \ + "$TMP/tools/AppRun-x86_64" +[ -x "$TMP/tools/AppRun-x86_64" ] +[ "$(cat "$TMP/tools/bundled-webkitgtk-version")" = "2.48.7" ] + +mkdir -p "$TMP/squashfs-root/usr/lib" +cp "$TMP/tools/bundled-webkitgtk-version" \ + "$TMP/squashfs-root/usr/lib/.bundled-webkitgtk-version" +cmp -s \ + "$TMP/squashfs-root/usr/lib/.bundled-webkitgtk-version" \ + "$TMP/tools/bundled-webkitgtk-version" +printf '%s\n' '2.48.6' > "$TMP/squashfs-root/usr/lib/.bundled-webkitgtk-version" +if cmp -s \ + "$TMP/squashfs-root/usr/lib/.bundled-webkitgtk-version" \ + "$TMP/tools/bundled-webkitgtk-version"; then + echo "FAIL: stale packaged WebKitGTK marker was accepted" >&2 + exit 1 +fi + +echo "PASS: Tauri AppImage tool cache seeded" diff --git a/tests/test_degraded_api_observability.py b/tests/test_degraded_api_observability.py index bfa1f7a5..6c808b3b 100644 --- a/tests/test_degraded_api_observability.py +++ b/tests/test_degraded_api_observability.py @@ -1,11 +1,11 @@ """Optional API data may degrade, but the failure must remain observable.""" from contextlib import contextmanager - -from api.routers import openai_compat, system -from core import db, run_sentinel +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") @@ -22,6 +22,8 @@ def test_voice_catalog_db_failure_keeps_shape_and_warns(monkeypatch, caplog): 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", @@ -32,7 +34,7 @@ def test_notification_probe_failures_keep_shape_and_warn(monkeypatch, caplog): "_crashed_last_session", lambda: (_ for _ in ()).throw(OSError("log unavailable")), ) - with caplog.at_level("WARNING", logger="omnivoice.system"): + with caplog.at_level("WARNING"): result = system.system_notifications() assert set(result) == {"notifications", "count"} diff --git a/tests/test_dictation_router.py b/tests/test_dictation_router.py index 917dfc3b..c9b355c6 100644 --- a/tests/test_dictation_router.py +++ b/tests/test_dictation_router.py @@ -3,7 +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 services +import importlib import pytest @@ -82,6 +82,7 @@ def test_set_prefs_accepts_repo_id_and_normalizes(client): 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"} diff --git a/tests/test_truthful_degraded_state.py b/tests/test_truthful_degraded_state.py index 28a54b47..8c11dbfb 100644 --- a/tests/test_truthful_degraded_state.py +++ b/tests/test_truthful_degraded_state.py @@ -215,10 +215,14 @@ def test_shared_voice_unload_failure_is_stable(monkeypatch): 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_network_start_failure_retains_listener_for_disable(monkeypatch): +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")) @@ -234,4 +238,31 @@ async def test_network_start_failure_retains_listener_for_disable(monkeypatch): 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 True + assert network_share.get_state().enabled is False + assert network_share._task is None + + +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"