Merge pull request #1962 from debpalash/land/queue2

Land the reviewed PR queue, part two: log panel, bootstrap mirror, setup diagnostics
This commit is contained in:
Palash Debnath
2026-09-09 13:58:18 -07:00
committed by GitHub
18 changed files with 1308 additions and 82 deletions
+11
View File
@@ -9,6 +9,10 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- A GPU that is merely short on free memory is no longer told to reinstall its drivers (#1812) — thanks @michaelhuamanflores!
- An error thrown by a browser extension is filtered on Safari and the macOS app too, not only on Chromium (#1901) — thanks @Chang-Jin-Lee!
- Choosing the China mirror no longer re-races the network on every dependency step, which cost seconds per step on blocked connections (#1892) — thanks @yuezheng2006!
- The backend log panel reports a log it cannot read instead of quietly showing less (#1847) — thanks @Chang-Jin-Lee!
- The floating dictation bubble adds pause, resume, stop, close, and a multiline preview (#1952)
- Transcriptions checks model readiness and offers an inline download and shortcut hints (#1952)
- Transcriptions' missing-model prompt lists every dictation model by accuracy vs latency, languages and size, so you install the one that fits — or switch to one already on disk (#1952)
@@ -17,6 +21,8 @@ the frozen-backend fallback mirror it for their toolchains.
- A generation failure that the app cannot classify now names the backend error class, so two unrelated faults stop arriving as the same untriageable report (#1800)
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
- Colab transcription and dubbing now include an explicit ASR model setup step (#1922) — thanks @nidhi-singh02!
- Apple Silicon now shows one canonical OmniVoice choice in the engine picker while retaining its automatic crash-isolated sidecar runtime (#1913)
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
- Model downloads survive a flaky connection instead of restarting from zero (#1940)
@@ -70,6 +76,10 @@ the frozen-backend fallback mirror it for their toolchains.
- `bun desktop-prod` and `bun desktop-fresh` find Rust and uv from a terminal opened before they were installed, as `bun desktop` already did; a missing Rust toolchain fails up front with the install steps (#1952)
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1955)
- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
- The Backend log tab keeps showing history across a log rollover, instead of going nearly empty until new lines arrive (#1920)
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1920)
- An error thrown by a browser extension no longer offers to file itself as a VoiceStudio bug (#1901)
- Clearing the desktop logs no longer wipes the backend's stderr, which is the only record a native crash leaves behind and is meant to survive a respawn (#1510)
- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk!
- Interrupted audiobook renders can resume cached chapters after tab navigation, and their chapter cache is available from the recovery card (#1911) — thanks @psiberfunk!
@@ -132,6 +142,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
- The bootstrap splash no longer shows fabricated first-run install steps on a warm start or repair sync — a step now renders done only once it was actually observed (#1894)
- A deliberate, clean quit killed by the desktop shell's short shutdown grace no longer gets reported as a crash on next launch — the run sentinel now clears before the slower shutdown steps instead of after (#1895)
- Model Catalogue engine rows stack into one column on narrow shells instead of clipping actions off-screen (#1891)
- Simplified Chinese locale completed: all 486 missing keys translated and the parity ratchet tightened to zero (#1877) — thanks @yearth!
+8 -3
View File
@@ -19,6 +19,7 @@ import sys
from fastapi import APIRouter
from api.schemas import SetupStatusResponse, PreflightResponse
from core.device_caps import KERNEL_RISK_MARKER
# MIN_FREE_GB + disk_free_bytes are single-sourced in ``.models`` (the lowest
# module in the setup import graph) so the wizard gate, the /models header, and
# the per-install disk guard can't drift apart.
@@ -174,8 +175,8 @@ def _detect_gpu() -> dict:
return info
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 2.0) -> bool:
"""Tiny TCP connect test."""
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 8.0) -> bool:
"""Tiny TCP connect test. 8s default — high-latency / China paths often exceed 23s."""
import socket
try:
with socket.create_connection((host, port), timeout=timeout):
@@ -498,10 +499,14 @@ def preflight():
_why = gpu_routing.get("routing_reason")
if _rs == "accelerated" and not _why:
r_status, r_detail, r_fix = "pass", f"{_eng}{_dev} (accelerated)", None
elif _rs == "accelerated": # driver/arch caveat
elif _rs == "accelerated" and KERNEL_RISK_MARKER in (_why or ""):
r_status, r_detail, r_fix = "warn", f"{_eng}{_dev}: {_why}", (
"GPU selected but may fail at kernel launch — update drivers / "
"reinstall torch for this GPU architecture.")
elif _rs == "accelerated": # low-VRAM caveat — not a driver/arch issue
r_status, r_detail, r_fix = "warn", f"{_eng}{_dev}: {_why}", (
"Unload other models before generating, keep the text short, "
"or pick a lighter engine.")
elif _rs == "cpu_fallback":
r_status, r_detail, r_fix = "warn", (
f"{_eng} runs on CPU here: {_why or 'no GPU path for this host'}"), (
+184 -37
View File
@@ -297,6 +297,142 @@ def _tail_file(path: str, tail: int):
return all_lines[-tail:], len(all_lines)
# Must track main.py's _WindowsSafeRotatingFileHandler(backupCount=3). The
# handler rolls omnivoice.log at 2 MB into .1/.2/.3, so up to 6 MB of history
# lives in files this module used to ignore entirely.
_LOG_BACKUP_COUNT = 3
def _rotated_log_paths(base: str) -> list[str]:
"""Existing `<base>.1 … .N`, newest first."""
return [p for p in (f"{base}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)) if os.path.exists(p)]
def _tail_rolling(base: str, tail: int):
"""Tail `base`, reaching into its rotated siblings when it runs short.
A rollover leaves omnivoice.log nearly empty, and the Backend tab then
showed a handful of lines — or none — while the failure the user was asked
to copy sat in omnivoice.log.1. Reading the current file first keeps the
common case at one file read; the backups are only touched when they are
the only place the requested lines can come from.
Returns (lines oldest-first, total lines across the files read, paths read
oldest-first). The total counts only the files it had to open — it stops as
soon as `tail` is satisfied, so it is "how much is behind these lines",
not the size of the whole rotation set.
"""
chunks: list[list[str]] = []
paths: list[str] = []
total = 0
remaining = tail
candidates = [p for p in [base, *_rotated_log_paths(base)] if os.path.exists(p)]
for path in candidates:
if remaining <= 0:
break
try:
lines, count = _tail_file(path, remaining)
except FileNotFoundError:
# A rollover can rename a candidate between the existence check
# above and this open, and the handler holds no lock we can take
# from a route. Skip the vanished file rather than 500 the whole
# panel over one member of the set — the previous single-file
# version failed the request outright in the same situation.
#
# A roll landing mid-walk can also shift which chunk a file holds,
# so a tail taken at that instant may repeat or miss a block. The
# panel re-polls every 5s and the next read is clean; buying strict
# consistency here would mean reaching into logging's internals.
continue
except PermissionError as exc:
# Windows only, and only the sharing violation: the handler still
# holds the file it is rolling. Any other permission failure is a
# real misconfiguration and must not be hidden.
if os.name == "nt" and getattr(exc, "winerror", None) == 32:
continue
raise
if count == 0:
continue
chunks.append(lines)
paths.append(path)
total += count
remaining -= len(lines)
# Files were visited newest-first; the reader wants oldest-first.
out: list[str] = []
for chunk in reversed(chunks):
out.extend(chunk)
return out, total, list(reversed(paths))
def _tauri_plugin_log_candidates():
"""The `tauri-plugin-log` files — the shell's own log, and the only thing
the Tauri tab actually displays.
Split out from :func:`_tauri_log_candidates` so Clear can touch these and
leave the backend stdout/stderr redirect alone. See
:func:`clear_tauri_logs`.
"""
home = os.path.expanduser("~")
bid = "com.debpalash.omnivoice-studio"
if sys.platform == "darwin":
return [
os.path.join(home, "Library/Logs", bid, "tauri.log"),
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
]
if sys.platform.startswith("linux"):
data_dir = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local/share")
return [
os.path.join(data_dir, bid, "logs", "tauri.log"),
os.path.join(home, ".config", bid, "logs", "tauri.log"),
]
if sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", home)
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
return [
os.path.join(localappdata, bid, "logs", "tauri.log"),
os.path.join(appdata, bid, "logs", "tauri.log"),
]
return []
def _backend_redirect_log_candidates():
"""`backend.log` / `backend_err.log` — the spawned backend's stdout and
stderr, written by `src-tauri/src/backend.rs::backend_log_path()`.
Deliberately NOT cleared by the Tauri tab's Clear button.
`open_err_log_for_run()` opens `backend_err.log` **append-only** so "a
respawn must not destroy the previous run's evidence" (#1510), rotates it
to `.1` rather than truncating, and its spawn diagnostics are described
there as "retained in backend_err.log across runs and lands verbatim in bug
reports". A native death (a Windows access violation, a SIGSEGV) writes
nothing to the Python log by construction, so this file is the only record
of it.
`OMNIVOICE_LOG_DIR` is honoured first, in the same precedence
`backend_log_path()` uses. The backend is a child of the shell, so an
ambient override reaches both — and a resolver that ignored it would look
in the per-OS default while the writer wrote somewhere else, which is the
divergence class this file already has one of (see #1782).
"""
override = (os.environ.get("OMNIVOICE_LOG_DIR") or "").strip()
if override:
return [
os.path.join(override, "backend.log"),
os.path.join(override, "backend_err.log"),
]
home = os.path.expanduser("~")
if sys.platform == "darwin":
base = os.path.join(home, "Library/Logs/OmniVoice")
elif sys.platform.startswith("linux"):
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
base = os.path.join(state_dir, "OmniVoice")
elif sys.platform.startswith("win"):
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
base = os.path.join(localappdata, "OmniVoice", "Logs")
else:
return []
return [os.path.join(base, "backend.log"), os.path.join(base, "backend_err.log")]
def _tauri_log_candidates():
"""Likely paths for Tauri-side logs, most useful first.
@@ -308,40 +444,15 @@ def _tauri_log_candidates():
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
- backend.rs::backend_log_path() redirects the spawned backend's
stdout/stderr to `backend.log` / `backend_err.log` under
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/VoiceStudio` falling
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
back to `~/.local/state/OmniVoice` (Linux), and
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
startup banners and hard-crash tracebacks land — keep all three OS
shapes listed or sidecar crashes become invisible off-macOS.
"""
home = os.path.expanduser("~")
bid = "com.debpalash.omnivoice-studio"
if sys.platform == "darwin":
return [
os.path.join(home, "Library/Logs", bid, "tauri.log"),
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
os.path.join(home, "Library/Logs/OmniVoice/backend.log"),
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
]
if sys.platform.startswith("linux"):
data_dir = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local/share")
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
return [
os.path.join(data_dir, bid, "logs", "tauri.log"),
os.path.join(home, ".config", bid, "logs", "tauri.log"),
os.path.join(state_dir, "OmniVoice", "backend.log"),
os.path.join(state_dir, "OmniVoice", "backend_err.log"),
]
if sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", home)
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
return [
os.path.join(localappdata, bid, "logs", "tauri.log"),
os.path.join(appdata, bid, "logs", "tauri.log"),
os.path.join(localappdata, "OmniVoice", "Logs", "backend.log"),
os.path.join(localappdata, "OmniVoice", "Logs", "backend_err.log"),
]
return []
# Composed from the two halves so the read path keeps seeing every file
# while Clear can be narrowed to the shell's own log.
return _tauri_plugin_log_candidates() + _backend_redirect_log_candidates()
@router.get("/system/logs")
@@ -356,12 +467,24 @@ async def system_logs(tail: int = 200):
except Exception:
tail = 200
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
if not os.path.exists(path):
if os.path.exists(LOG_PATH) or _rotated_log_paths(LOG_PATH):
base = LOG_PATH
else:
base = CRASH_LOG_PATH
if not os.path.exists(base) and not _rotated_log_paths(base):
return {"lines": [], "path": LOG_PATH, "exists": False}
path = base
try:
lines, total = await asyncio.to_thread(_tail_file, path, tail)
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
lines, total, paths = await asyncio.to_thread(_tail_rolling, base, tail)
return {
"lines": lines,
"path": path,
"exists": True,
"total_lines": total,
# Which files the tail actually came from, oldest first. A bug
# report can then say whether it crossed a rollover.
"paths": paths,
}
except Exception as e:
raise HTTPException(
status_code=500,
@@ -467,9 +590,23 @@ def _read_from_pos(path: str, pos: int) -> list[str]:
@router.post("/system/logs/clear")
async def clear_system_logs():
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads).
Includes the rotated siblings. Truncating only omnivoice.log left up to
6 MB in .1/.2/.3, so Clear freed almost nothing and — now that the tail
reaches into those files — would have looked like it did nothing at all.
"""
cleared_any = False
for p in (LOG_PATH, CRASH_LOG_PATH):
# The full fixed name set rather than a snapshot of what exists: enumerating
# first leaves a window where a rollover creates a backup after the scan and
# its history survives a Clear that reported success. Names the handler can
# ever write are known up front, so there is nothing to enumerate.
targets = [
LOG_PATH,
*(f"{LOG_PATH}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)),
CRASH_LOG_PATH,
]
for p in targets:
if os.path.exists(p):
try:
await asyncio.to_thread(_truncate_file, p)
@@ -502,10 +639,20 @@ def _truncate_file(path: str):
@router.post("/system/logs/tauri/clear")
async def clear_tauri_logs():
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
"""Truncate the shell's own log files. OS-level rotation may recreate them.
The backend stdout/stderr redirect is deliberately excluded. This button
lives on a tab that shows `tauri.log`, and truncating `backend_err.log`
from it destroyed evidence the user was never shown — the one record of a
native death, which writes nothing to the Python log. `backend.rs`'s
`open_err_log_for_run()` opens that file append-only precisely so "a
respawn must not destroy the previous run's evidence" (#1510) and rotates
it to `.1` instead of truncating, so it manages its own size and does not
need clearing from here.
"""
cleared = []
failed = 0
for p in _tauri_log_candidates():
for p in _tauri_plugin_log_candidates():
if os.path.exists(p):
try:
await asyncio.to_thread(_truncate_file, p)
+7 -1
View File
@@ -28,6 +28,7 @@ import shutil
import sys
from core.config import DATA_DIR
from core.device_caps import KERNEL_RISK_MARKER
from core.scrub import scrub_text
from core.version import APP_VERSION
@@ -230,11 +231,16 @@ def _check_gpu_routing() -> dict:
host = v.get("host_family", "cpu")
if status == "accelerated":
if reason: # driver/arch caveat — accelerated but at risk
if reason and KERNEL_RISK_MARKER in reason: # driver/arch caveat — at risk
return _check("gpu_routing", "GPU routing", WARN,
f"{engine} -> {dev}: {reason}",
"The GPU is selected but may fail at kernel launch — "
"update drivers / reinstall torch for this GPU arch.")
if reason: # low-VRAM caveat — not a driver/arch issue
return _check("gpu_routing", "GPU routing", WARN,
f"{engine} -> {dev}: {reason}",
"Unload other models before generating, keep the text "
"short, or pick a lighter engine.")
return _check("gpu_routing", "GPU routing", OK, f"{engine} -> {dev} (accelerated)")
if status == "cpu_fallback":
return _check("gpu_routing", "GPU routing", WARN,
+30 -7
View File
@@ -1080,6 +1080,33 @@ async def lifespan(app: FastAPI):
app.state.startup_task = asyncio.create_task(_deferred_startup(app))
yield
# ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
# Retire the run sentinel FIRST, before any bounded wait below (#1895):
# once uvicorn has begun graceful shutdown the exit is deliberate by
# definition, so the sentinel has already done its job. This is one
# os.remove, against a ~50s worst-case tail of bounded waits plus model
# unload / free_vram() / gc.collect() below. Measured on macOS: a normal
# shutdown takes 5.25s end to end, while the desktop shell allows 2s
# (bootstrap.rs terminate_process_tree) before SIGKILL — so the old
# placement at the very end was killed every time on any run that had
# reached a working state. Doing the deadline-sensitive step first makes
# correctness independent of how much of that tail runs, instead of
# depending on the shell-side deadline being long enough to cover it.
#
# SCOPE, explicitly: this only helps platforms where lifespan teardown
# actually BEGINS. On Windows it does not — tools.rs terminates the job
# object with no graceful phase at all, so this line is never reached and
# a deliberate quit is still misreported as a crash there. That needs the
# shell to signal deliberate intent before the hard kill, which is a
# separate Rust-side change and is tracked separately; nothing here
# should be read as fixing Windows.
#
# sentinel_cleared feeds the truthful "Shutdown: done."/degraded log at
# the end of this function; nothing below re-clears the sentinel, so a
# later failure can't mask this result.
try:
sentinel_cleared = run_sentinel.clear_sentinel()
except Exception:
sentinel_cleared = False
# May run after a startup that never finished (SIGTERM mid-Phase-A/B), so
# every handle is read from app.state with a None default and every
# deferred-phase name is guarded.
@@ -1206,13 +1233,9 @@ async def lifespan(app: FastAPI):
await close_http_client()
except Exception:
pass
# Last thing on a clean shutdown: retire the run sentinel so the next
# startup doesn't misread this exit as a crash (#1164). If clearing fails,
# retain the sentinel and report a degraded shutdown truthfully.
try:
sentinel_cleared = run_sentinel.clear_sentinel()
except Exception:
sentinel_cleared = False
# Sentinel was already retired at the TOP of this block (#1895) — report
# truthfully using that result rather than clearing (or re-checking) it
# again here, so a failure in the steps above can't mask it as "done."
if sentinel_cleared:
logger.info("Shutdown: done.")
else:
+1 -1
View File
@@ -65,7 +65,7 @@ _MODE_PREF = "hf_endpoint_mode" # "auto" | "manual"; absent → default
_DECISION_PREF = "hf_endpoint_auto" # cached decision dict (see race())
DECISION_MAX_AGE_S = 7 * 24 * 3600.0 # re-race a decision older than 7 days
PROBE_TIMEOUT_S = 3.0 # short: a probe is not a download
PROBE_TIMEOUT_S = 8.0 # high-latency / China paths often need >3s
MIRROR_SPEEDUP_FACTOR = 3.0 # mirror must be ≥3× faster to win
# Small, stable, long-lived public file for the optional ranged-GET
+87
View File
@@ -113,6 +113,93 @@ def test_unclean_shutdown_yields_crash_record(sentinel_env, monkeypatch):
assert acked is False, "a fresh crash record must be unacknowledged"
def test_lifespan_clears_sentinel_even_if_later_shutdown_raises(monkeypatch, tmp_path):
"""THE #1895 regression: before this fix, ``clear_sentinel()`` was the
LAST statement of ``main.py``'s lifespan shutdown, behind ~50s of bounded
waits plus model unload / ``free_vram()`` / ``gc.collect()`` / httpx
close. The desktop shell's quit path grants only a 2s grace before
SIGKILL (``frontend/src-tauri/src/bootstrap.rs``
``terminate_process_tree``), and Windows grants no graceful phase at all
(``tools.rs``) — nowhere near enough, so a deliberate, clean quit
routinely got killed before reaching that last line, leaving the
sentinel behind for the NEXT startup to misreport as "did not shut down
cleanly... likely crashed".
Simulates that class of interruption without an actual SIGKILL: a later
shutdown step (``model_loads_begin_shutdown()``, called unguarded well
after the sentinel clear) raises, so nothing past it in the shutdown
body ever runs — for this purpose, the same effect as being killed
mid-teardown.
Fail-before/pass-after: with ``clear_sentinel()`` moved to the TOP of
the shutdown block (immediately after ``yield``), the sentinel is
already gone by the time this raise happens, so the next startup must
not fabricate a crash record.
"""
import asyncio
from fastapi import FastAPI
# Fresh `main`/`core`/`api`/`services` import, mirroring
# tests/test_model_load_shutdown.py's `_reimported_backend_modules`: a
# sibling suite may have purged these names from sys.modules, leaving a
# collection-time alias stale. Purging and re-importing here makes this
# test self-consistent in isolation, not dependent on suite order.
purge_names = ("main", "core", "api", "services")
purge_prefixes = ("core.", "api.", "services.")
saved = {
name: mod for name, mod in sys.modules.items()
if name in purge_names or name.startswith(purge_prefixes)
}
def _purge():
for name in [
n for n in sys.modules
if n in purge_names or n.startswith(purge_prefixes)
]:
sys.modules.pop(name, None)
_purge()
try:
import main as main_mod
from core import run_sentinel as fresh_run_sentinel
monkeypatch.setattr(
fresh_run_sentinel, "SENTINEL_PATH", str(tmp_path / "run_sentinel.json")
)
monkeypatch.setattr(
fresh_run_sentinel, "CRASH_RECORD_PATH", str(tmp_path / "last_run_crash.json")
)
monkeypatch.setattr(
fresh_run_sentinel, "LOG_PATH", str(tmp_path / "omnivoice.log")
)
fresh_run_sentinel._reset_for_tests()
def _boom():
raise RuntimeError("simulated kill: interrupted after the early clear")
monkeypatch.setattr(main_mod, "model_loads_begin_shutdown", _boom)
async def scenario():
app = FastAPI()
async with main_mod.lifespan(app):
pass
with pytest.raises(RuntimeError, match="simulated kill"):
asyncio.run(scenario())
assert not os.path.exists(fresh_run_sentinel.SENTINEL_PATH), (
"sentinel must already be cleared even though a later shutdown "
"step raised before ever reaching the old clear-sentinel line"
)
assert fresh_run_sentinel.detect_unclean_shutdown() is None, (
"a deliberate quit interrupted after the early clear must never "
"be reported as a crash on the next startup"
)
finally:
_purge()
sys.modules.update(saved)
def test_live_pid_means_second_instance_not_a_crash(sentinel_env):
"""A sentinel owned by a LIVE process is a concurrent second instance
sharing DATA_DIR — never a crash, and we must not take over or delete
+6 -1
View File
@@ -436,7 +436,12 @@ pub fn backend_log_path() -> PathBuf {
// harness gives every scenario its own tempdir through this.
if let Ok(dir) = std::env::var("OMNIVOICE_LOG_DIR") {
if !dir.trim().is_empty() {
let log_dir = PathBuf::from(dir);
// Trim here too, not only in the emptiness test above. The Python
// reader strips this variable before joining (see
// api/routers/system.py::_backend_redirect_log_candidates), so a
// padded value had the writer and the reader looking at different
// directories — the exact divergence #1925 exists to close.
let log_dir = PathBuf::from(dir.trim());
let _ = fs::create_dir_all(&log_dir);
return log_dir.join("backend.log");
}
+74 -24
View File
@@ -1909,16 +1909,54 @@ fn apply_uv_http_env(cmd: &mut Command) {
.env("UV_HTTP_RETRIES", "5");
}
/// Default Aliyun PyPI simple index for the `china` region preset.
const CHINA_PYPI_INDEX: &str = "https://mirrors.aliyun.com/pypi/simple/";
/// Resolve the PyPI simple-index URL for `uv` / `uv pip` subprocesses.
/// Explicit setup-screen override wins; otherwise the `china` region preset
/// points at Aliyun. Other regions leave the index unset (uv's default PyPI).
fn resolve_pypi_index_url(region: &str, override_url: Option<&str>) -> Option<String> {
if let Some(url) = override_url.map(str::trim).filter(|u| !u.is_empty()) {
return Some(url.to_string());
}
if region == "china" {
return Some(CHINA_PYPI_INDEX.to_string());
}
None
}
/// Apply `UV_INDEX_URL` when a custom or region-preset PyPI mirror is active.
/// Must run for *every* `uv` path that may fetch packages — including the
/// repair sync. Omitting it there left China-region installs hitting
/// `pypi.org` for build backends (e.g. hatchling) and failing with
/// `tls handshake eof` while the UI already showed the China (mirror) region.
fn apply_pypi_index_env<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cmd: &mut Command) {
let cfg = crate::config::load_config(app);
let region = get_effective_region(app);
// Clear any ambient value first. Without this a uv call inherits the
// parent process's UV_INDEX_URL whenever resolve_pypi_index_url returns
// None, so a stale mirror set in the developer's shell silently outranks
// the region the user actually chose.
cmd.env_remove("UV_INDEX_URL");
if let Some(url) = resolve_pypi_index_url(&region, cfg.mirrors.pypi_index.as_deref()) {
cmd.env("UV_INDEX_URL", url);
}
}
/// The one env applicator every `uv` invocation must go through: HTTP
/// resilience (above) + volume co-location. The latter pins UV_CACHE_DIR /
/// UV_PYTHON_INSTALL_DIR under the env root when the install is rooted on a
/// different volume than uv's default cache (D:-drive installs / portable
/// mode) — otherwise every wheel is downloaded+unpacked on the system drive
/// and then cross-volume *copied* into the venv, silently requiring the full
/// install size on C: and ENOSPC-ing installs the user deliberately pointed
/// at another drive. See `setup::uv_env_overrides_for` for the exact rules.
/// resilience (above) + volume co-location + PyPI mirror. The latter pins
/// UV_CACHE_DIR / UV_PYTHON_INSTALL_DIR under the env root when the install
/// is rooted on a different volume than uv's default cache (D:-drive
/// installs / portable mode) — otherwise every wheel is downloaded+unpacked
/// on the system drive and then cross-volume *copied* into the venv,
/// silently requiring the full install size on C: and ENOSPC-ing installs
/// the user deliberately pointed at another drive. See
/// `setup::uv_env_overrides_for` for the exact rules. PyPI index goes here
/// so first-run, drift, repair, and targeted `uv pip` repairs all honor
/// the China / custom mirror — not only the happy-path sync.
fn apply_uv_env<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cmd: &mut Command) {
apply_uv_http_env(cmd);
apply_pypi_index_env(app, cmd);
for (k, v) in crate::setup::uv_env_overrides(app) {
cmd.env(k, v);
}
@@ -2775,13 +2813,8 @@ creating the new environment at an ASCII-safe path instead (#1783)"
Ok(uv_path) => {
let mut drift_cmd = Command::new(&uv_path);
scrub_python_env(&mut drift_cmd); // #144
// apply_uv_env sets UV_INDEX_URL for china / custom mirrors
apply_uv_env(app, &mut drift_cmd);
let user_cfg = crate::config::load_config(app);
if let Some(pypi) = user_cfg.mirrors.pypi_index.as_deref() {
drift_cmd.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
drift_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
drift_cmd
.args(DRIFT_SYNC_ARGS)
.current_dir(&project_dir);
@@ -3132,12 +3165,7 @@ the existing venv; newly added dependencies may be missing (#307)",
.args(["sync", "--no-dev", "--verbose"])
.current_dir(&project_dir);
}
// PyPI index precedence: explicit setup-screen mirror > region preset.
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
sync_cmd.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
sync_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
// UV_INDEX_URL (china / custom) applied via apply_uv_env above.
let mut sync_ok = matches!(run_streaming(app, "installing_deps", &mut sync_cmd), Ok(ref s) if s.success());
// #569: the big cu128 torch wheel (~2.5 GB) is the most common first-run
@@ -3160,13 +3188,9 @@ the existing venv; newly added dependencies may be missing (#307)",
emit_log(app, "installing_deps", "Retrying the install with the wheels you provided locally…");
let mut retry = Command::new(&uv_path);
scrub_python_env(&mut retry);
// apply_uv_env sets UV_INDEX_URL for china / custom mirrors
apply_uv_env(app, &mut retry);
retry.env("UV_FIND_LINKS", &wheels_dir);
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
retry.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
retry.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
retry.args(["sync", "--no-dev", "--verbose"]).current_dir(&project_dir);
sync_ok = matches!(run_streaming(app, "installing_deps", &mut retry), Ok(ref s) if s.success());
}
@@ -3455,6 +3479,32 @@ mod tests {
assert_eq!(envs.get("UV_HTTP_RETRIES").map(String::as_str), Some("5"));
}
#[test]
fn resolve_pypi_index_url_honors_override_then_china_preset() {
// Repair / first-run / drift all share this resolver via apply_uv_env.
// China must not fall through to pypi.org (tls handshake eof on
// hatchling when the UI already shows the China (mirror) region).
assert_eq!(
resolve_pypi_index_url("china", None).as_deref(),
Some(CHINA_PYPI_INDEX)
);
assert_eq!(
resolve_pypi_index_url("global", None),
None,
"non-china regions keep uv's default PyPI"
);
assert_eq!(
resolve_pypi_index_url("china", Some("https://example.com/simple/")).as_deref(),
Some("https://example.com/simple/"),
"explicit override wins over the china preset"
);
assert_eq!(
resolve_pypi_index_url("china", Some(" ")),
Some(CHINA_PYPI_INDEX.to_string()),
"blank override falls back to the china preset"
);
}
#[test]
fn crash_loop_policy_is_three_deaths_in_ten_minutes() {
// #941 escalation guard: ≥3 crashes inside 10 min must stop the
+20
View File
@@ -247,7 +247,27 @@ fn pick_region(direct: Option<std::time::Duration>, mirror: Option<std::time::Du
/// and we stay direct ("global", no proxy hop); on a throttled/blocked network
/// the mirror answers first (or GitHub times out) and we switch ("restricted").
/// Both probes run in parallel, so the check costs one timeout, not two.
/// Cached result of the auto-detection probe, for the life of the process.
///
/// The probe costs up to one 4-second timeout and makes two outbound requests.
/// That was acceptable while it ran once during bootstrap, but the PyPI mirror
/// applicator now runs for EVERY `uv` invocation (#1892), and with the default
/// `region = "auto"` each of those re-raced the network. On a blocked or
/// offline network that is a repeated 4-second stall per uv call, and it
/// multiplies the outbound calls a local-first app makes without being asked.
///
/// The answer cannot meaningfully change mid-session — it describes which way
/// out of the machine is faster — so racing it again is pure cost. A user who
/// changes networks restarts the app or picks the region explicitly, both of
/// which bypass this path.
static AUTO_REGION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Race github.com against the ghproxy mirror once, then reuse the verdict.
pub fn auto_detect_region() -> String {
AUTO_REGION.get_or_init(auto_detect_region_uncached).clone()
}
fn auto_detect_region_uncached() -> String {
log::info!("Auto-detecting region (racing github.com vs ghproxy mirror)...");
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
// Probe the SAME resource through both paths so the latencies compare fairly.
+60 -4
View File
@@ -25,11 +25,65 @@ const IGNORE_PATTERNS = [
/Script error\.?$/i, // opaque cross-origin errors carry no info
];
function shouldShow(message, error) {
// A browser extension injected into the page throws in the page's own error
// channel, so `window.onerror` cannot tell it apart from ours by message alone
// — and the message-based list above cannot help, because an extension's
// TypeError reads exactly like one of ours. #1901 is the result: a report filed
// against VoiceStudio whose stack is entirely
// `chrome-extension://…/executors/200.js`, with no frame of ours in it.
//
// `Script error.` above already covers the opaque cross-origin case. An
// extension's script is not opaque, so it arrives with a full stack and slips
// straight through to the "Report this bug" action.
const EXTENSION_URL = /\b(?:chrome|moz|safari-web|safari|ms-browser)-extension:\/\//i;
// Whether a stack line is a FRAME rather than the header. V8 writes
// "TypeError: <message>" first and then " at fn (url:1:2)"; JSC and
// SpiderMonkey write "fn@url:1:2" with no header at all. Both frame shapes are
// recognised, and anything else is the header — which matters because a message
// can contain a URL of its own, in either direction: an extension error reading
// "Failed to fetch https://example.com" would otherwise report the message's
// URL as its origin and escape the filter, and one of OUR errors quoting a
// `chrome-extension://` URL would otherwise be suppressed as an extension's.
// The JSC alternative is anchored: a frame is `fn@url`, and a function name has
// no spaces, so the `@` must be reachable from the line start through
// non-whitespace only. Unanchored, a V8 HEADER whose message happens to read
// `... user@chrome-extension://...` matched as a JSC frame and its message URL
// was taken as the throw site -- the same false positive one layer down.
// JSC additionally labels three frame kinds with a SPACE in them —
// `global code@url`, `eval code@url` and `module code@url`. A bare `\S*`
// before the `@` therefore skips exactly the top-level frame that an injected
// extension script throws from, so originUrl() found no frame, returned '',
// and the extension's error still offered "Report this bug" — #1901 unfixed
// on WKWebView, which is the macOS desktop build and Safari. The three labels
// are enumerated rather than allowing spaces generally, because a general
// space would re-open the V8-header false positive the anchoring exists for.
const FRAME_LINE = /^\s*at\s|^\s*(?:(?:global|eval|module) code|[^\s@]*)@[a-z-]+:\/\//i;
const FRAME_URL = /[a-z-]+:\/\/[^\s)]+/i;
/** The URL the error came FROM, or '' when the origin cannot be established. */
function originUrl(error, filename) {
// An ErrorEvent names the script directly, which beats parsing a stack.
// Only `unhandledrejection` has to fall back to the stack.
if (typeof filename === 'string' && filename) return filename;
const stack = typeof error?.stack === 'string' ? error.stack : '';
const frame = stack.split('\n').find((line) => FRAME_LINE.test(line));
if (!frame) return '';
// The FIRST frame only, and '' when it names no URL (a native or anonymous
// throw site). Walking deeper to find one would attribute an error to a
// frame that did not throw it — and since the only thing this decides is
// whether to offer a report, an unknown origin has to mean "offer it".
// Silencing one of our own bugs is worse than leaving noise in.
const match = frame.match(FRAME_URL);
return match ? match[0] : '';
}
function shouldShow(message, error, filename) {
// Some browser streams (including WaveSurfer's BodyStreamBuffer) describe
// normal cancellation without the word "AbortError" in the message. The
// structured DOMException name is the reliable cancellation contract.
if (error?.name === 'AbortError') return false;
if (EXTENSION_URL.test(originUrl(error, filename))) return false;
if (!message || IGNORE_PATTERNS.some((p) => p.test(message))) return false;
const key = String(message).slice(0, 200);
const now = Date.now();
@@ -38,8 +92,8 @@ function shouldShow(message, error) {
return true;
}
function surface(message, error) {
if (!shouldShow(message, error)) return;
function surface(message, error, filename) {
if (!shouldShow(message, error, filename)) return;
const err = error instanceof Error ? error : new Error(String(error ?? message));
toastErrorWithReport(
i18next.t('errors.unexpected', { message: String(message).slice(0, 140) }),
@@ -53,7 +107,9 @@ export function installGlobalErrorHandlers() {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('error', (e) => {
surface(e?.error?.message || e.message, e.error);
// `e.filename` is the script the throw came from — more reliable than
// parsing a stack, and present even when the error object is not.
surface(e?.error?.message || e.message, e.error, e?.filename);
});
window.addEventListener('unhandledrejection', (e) => {
const r = e?.reason;
+217 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { toastErrorWithReport } = vi.hoisted(() => ({
toastErrorWithReport: vi.fn(),
@@ -14,6 +14,29 @@ function dispatchUnhandledRejection(reason) {
window.dispatchEvent(event);
}
function dispatchError({ message, filename, error }) {
const event = new Event('error');
Object.defineProperty(event, 'message', { value: message });
Object.defineProperty(event, 'filename', { value: filename });
Object.defineProperty(event, 'error', { value: error });
window.dispatchEvent(event);
}
/** An Error whose stack is entirely extension frames, as in #1901. */
function extensionError(message) {
const err = new Error(message);
err.stack = [
`TypeError: ${message}`,
' at Y (chrome-extension://eppiocemhmnlbhjplcgkofciiegomcon/executors/200.js:1:761)',
' at E (chrome-extension://eppiocemhmnlbhjplcgkofciiegomcon/executors/200.js:1:1442)',
].join('\n');
return err;
}
beforeEach(() => {
toastErrorWithReport.mockClear();
});
describe('global unhandled rejection reporting', () => {
it('ignores a named AbortError while still surfacing a real rejection', () => {
installGlobalErrorHandlers();
@@ -28,3 +51,196 @@ describe('global unhandled rejection reporting', () => {
expect(toastErrorWithReport.mock.calls[0][1]).toBe(failure);
});
});
/**
* A browser extension throws into the page's own error channel, so the toast
* offered "Report this bug" for code that is not ours. #1901 is one such
* report: the stack is entirely
* `chrome-extension://eppiocemhmnlbhjplcgkofciiegomcon/executors/200.js` with
* no VoiceStudio frame in it, and the maintainer had no way to tell from the
* message ("Cannot read properties of undefined") that it was not a real bug.
*
* The message-based IGNORE_PATTERNS cannot help here an extension's TypeError
* reads exactly like one of ours and the existing `Script error.` entry only
* covers the opaque cross-origin case. An extension's script is not opaque, so
* it arrives with a full stack and goes straight through.
*
* consoleBuffer still records these into Settings Logs Frontend; what is
* suppressed is the offer to file them against this project.
*/
describe('errors thrown by a browser extension', () => {
it('does not offer to report an error event from an extension script', () => {
installGlobalErrorHandlers();
dispatchError({
message: "Cannot read properties of undefined (reading 'M_ID')",
filename: 'chrome-extension://eppiocemhmnlbhjplcgkofciiegomcon/executors/200.js',
error: extensionError("Cannot read properties of undefined (reading 'M_ID')"),
});
expect(toastErrorWithReport).not.toHaveBeenCalled();
});
it('does not offer to report a rejection whose throw site is an extension', () => {
installGlobalErrorHandlers();
// No `filename` on an unhandledrejection, so the throw site has to come
// off the stack.
dispatchUnhandledRejection(extensionError('extension promise blew up'));
expect(toastErrorWithReport).not.toHaveBeenCalled();
});
it('reads the origin off the first FRAME, not off a URL in the message', () => {
// Greptile: an extension error whose message carries a URL. Scanning the
// whole stack matched the message's URL first, reported that as the origin,
// and let the extension through the filter — the bug this PR exists to fix,
// surviving inside the fix.
installGlobalErrorHandlers();
const err = new Error('Failed to fetch https://example.com/api');
err.stack = [
'TypeError: Failed to fetch https://example.com/api',
' at Y (chrome-extension://someid/executors/200.js:1:761)',
].join('\n');
dispatchUnhandledRejection(err);
expect(toastErrorWithReport).not.toHaveBeenCalled();
});
it('still reports our error when the MESSAGE quotes an extension URL', () => {
// CodeRabbit, the same defect from the other side and the worse half: one
// of our own failures that happens to name a chrome-extension:// URL in
// its text was suppressed as if an extension had thrown it.
installGlobalErrorHandlers();
const ours = new Error('blocked request to chrome-extension://someid/x.js');
ours.stack = [
'Error: blocked request to chrome-extension://someid/x.js',
' at loadAsset (http://tauri.localhost/assets/main-app.js:9:1)',
].join('\n');
dispatchUnhandledRejection(ours);
expect(toastErrorWithReport).toHaveBeenCalledOnce();
expect(toastErrorWithReport.mock.calls[0][1]).toBe(ours);
});
it('does not read a V8 header that happens to contain an @ URL as a frame', () => {
// Greptile: the JSC frame alternative was unanchored, so a header reading
// `... user@chrome-extension://...` matched as a frame and its message URL
// became the origin. Same false positive as the message-URL case, one layer
// down, and the earlier test missed it because its message had no `@`.
installGlobalErrorHandlers();
const ours = new Error('blocked request to user@chrome-extension://someid/x.js');
ours.stack = [
'Error: blocked request to user@chrome-extension://someid/x.js',
' at loadAsset (http://tauri.localhost/assets/main-app.js:9:1)',
].join('\n');
dispatchUnhandledRejection(ours);
expect(toastErrorWithReport).toHaveBeenCalledOnce();
expect(toastErrorWithReport.mock.calls[0][1]).toBe(ours);
});
it('still treats a real JSC frame as a frame', () => {
// The anchor must not cost the Safari/Firefox stack shape, which has no
// header line at all and puts the `@` right after the function name.
installGlobalErrorHandlers();
const err = new Error('jsc shaped stack');
err.stack = 'Y@chrome-extension://someid/200.js:1:761';
dispatchUnhandledRejection(err);
expect(toastErrorWithReport).not.toHaveBeenCalled();
});
it('reports rather than guesses when the throw site names no URL', () => {
// A native or anonymous first frame leaves the origin unknown. Walking
// deeper to find a URL would attribute the error to a frame that did not
// throw it, so an unknown origin means "offer the report" — leaving noise
// in is cheaper than silencing one of ours.
installGlobalErrorHandlers();
const err = new Error('sort comparator exploded');
err.stack = [
'TypeError: sort comparator exploded',
' at Array.sort (<anonymous>)',
' at Y (chrome-extension://someid/inject.js:1:1)',
].join('\n');
dispatchUnhandledRejection(err);
expect(toastErrorWithReport).toHaveBeenCalledOnce();
});
it('still reports our own error when an extension frame sits below it', () => {
// The reason this filters on the THROW SITE and not on "any frame mentions
// an extension": an extension that patches a built-in leaves its frame in
// the middle of a stack whose fault is genuinely ours. Dropping those would
// silence real bugs, which is worse than the noise it saves.
installGlobalErrorHandlers();
const ours = new Error('dub export failed');
ours.stack = [
'Error: dub export failed',
' at exportDub (http://tauri.localhost/assets/main-app.js:9:1)',
' at patched (chrome-extension://someid/inject.js:1:1)',
].join('\n');
dispatchError({ message: ours.message, filename: undefined, error: ours });
expect(toastErrorWithReport).toHaveBeenCalledOnce();
expect(toastErrorWithReport.mock.calls[0][1]).toBe(ours);
});
// WebKit labels its top-level frames "global code@…", "eval code@…" and
// "module code@…" — the only frame labels that contain a space. The frame
// matcher's anti-header anchoring rejected them, so on WKWebView (the macOS
// desktop shell) and Safari an extension's error found no matching frame,
// fell through with an unknown origin, and still offered the report — the
// exact #1901 behaviour, unfixed on the browser engine that needs it most.
it.each(['global code', 'eval code', 'module code'])(
'suppresses an extension error thrown from a WebKit "%s" frame',
(label) => {
installGlobalErrorHandlers();
// A distinct message per label: shouldShow() throttles by message text,
// so reusing one would let the second and third cases pass on the
// throttle rather than on the frame match they exist to prove.
const err = new Error(`extension threw from ${label}`);
err.stack = [
`${label}@chrome-extension://someid/executors/200.js:1:9`,
'promiseReactionJob@[native code]',
].join('\n');
dispatchUnhandledRejection(err);
expect(toastErrorWithReport).not.toHaveBeenCalled();
},
);
it('still reports our own error thrown from a WebKit global frame', () => {
// The label must not become a blanket mute: a top-level throw of OURS
// carries the same shape and has to keep reaching the user.
installGlobalErrorHandlers();
const ours = new Error('webkit top-level render crash');
ours.stack = ['global code@http://tauri.localhost/assets/main-app.js:9:1'].join('\n');
dispatchUnhandledRejection(ours);
expect(toastErrorWithReport).toHaveBeenCalledOnce();
});
it('does not treat a V8 header mentioning an extension URL as a frame', () => {
// Regression guard for the anchoring this change had to preserve: the
// message text below contains "code@chrome-extension://", and reading it
// as a frame would attribute one of our errors to an extension and mute it.
installGlobalErrorHandlers();
const ours = new Error('failed to load code@chrome-extension://someid/x.js');
ours.stack = [
'TypeError: failed to load code@chrome-extension://someid/x.js',
' at loadThing (http://tauri.localhost/assets/main-app.js:4:1)',
].join('\n');
dispatchUnhandledRejection(ours);
expect(toastErrorWithReport).toHaveBeenCalledOnce();
});
});
+57 -3
View File
@@ -642,13 +642,61 @@
"play(path, f\"\\nVoice of profile {NARRATOR_ID} (gen_time={h.get('X-Gen-Time')}s):\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 12a. Install the transcription model (required for cells 13 and 18)\n",
"\n",
"Running the next cell explicitly downloads **Systran/faster-whisper-large-v3** from Hugging Face (roughly 3 GB) for the notebook's default transcription backend. Skip it if you only want the TTS examples. Rerunning checks Hugging Face for the snapshot and reuses cached files, downloading only missing files.\n",
"\n",
"The notebook and the backend launched in cell 5 share the same Hugging Face cache. If you change cache settings, restart the backend with those settings before continuing. If you select a different ASR engine/model in the app, install that model through Model Catalogue instead; this cell prepares the default large-v3 workflow.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── 12a. Install the transcription model ────────────────────────────────\n",
"from huggingface_hub import snapshot_download\n",
"\n",
"ASR_MODEL_READY = False\n",
"# The CTranslate2 (faster-whisper) build is what the backend prefers, and it\n",
"# needs the cuDNN 8 libraries installed by the setup step above. If that step\n",
"# reported a failure, the backend falls back to its PyTorch Whisper default\n",
"# instead and will download THAT model on first use — several more GB. Check\n",
"# the setup output before spending the download below.\n",
"ASR_REPO_ID = \"Systran/faster-whisper-large-v3\"\n",
"print(f\"Preparing {ASR_REPO_ID} (roughly 3 GB if not cached)...\")\n",
"# Reuses cached files and downloads any missing files from an interrupted run.\n",
"# Do not pass local_dir: the backend looks in the shared Hugging Face cache.\n",
"asr_path = snapshot_download(repo_id=ASR_REPO_ID)\n",
"ASR_MODEL_READY = True\n",
"print(\"ASR model cached at:\", asr_path)\n",
"\n",
"# Confirm the backend will actually use what we just fetched. When cuDNN 8 is\n",
"# missing this says so HERE, while the cell that spent the bandwidth is still\n",
"# on screen, rather than letting cell 13 quietly pull a second large model.\n",
"try:\n",
" import ctranslate2 # noqa: F401\n",
"except Exception as _exc:\n",
" print(\"\")\n",
" print(\"WARNING: CTranslate2 is not importable here, so the backend will\")\n",
" print(\" fall back to its PyTorch Whisper default and download\")\n",
" print(\" that model separately - several more GB.\")\n",
" print(f\" Reason: {_exc}\")\n",
" print(\" Re-run the cuDNN 8 setup cell above before continuing.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 13. Transcription (speech-to-text)\n",
"\n",
"The round trip: the WAV that TTS produced in cell 9 goes back through `POST /transcribe`, and the recognized text should match the original sentence. Expected runtime: the **first** transcription downloads an ASR model (roughly 1-3 GB, a few minutes); afterwards it's seconds.\n"
"The round trip: the WAV that TTS produced in cell 9 goes back through `POST /transcribe`, and the recognized text should match the original sentence. Run **cell 12a** first to install the ASR model. The backend intentionally rejects transcription when its model is missing; it does not automatically download it. The first transcription loads the installed model into memory.\n"
]
},
{
@@ -658,13 +706,16 @@
"outputs": [],
"source": [
"# ── 13. Transcription: TTS -> ASR round trip ────────────────────────────────\n",
"if not globals().get(\"ASR_MODEL_READY\", False):\n",
" raise SystemExit(\"Run cell 12a (ASR model download) before transcription or dubbing.\")\n",
"\n",
"try:\n",
" ensure_wav\n",
"except NameError:\n",
" raise SystemExit(\"Run cell 8 (feature-tour helpers) first.\")\n",
"\n",
"wav = ensure_wav(\"tts_en.wav\", EN_TEXT)\n",
"print(\"Transcribing... (first run downloads an ASR model — a few minutes)\")\n",
"print(\"Transcribing... (first run loads the installed ASR model)\")\n",
"with open(wav, \"rb\") as f:\n",
" r = requests.post(f\"{BASE}/transcribe\",\n",
" files={\"audio\": (\"tts_en.wav\", f, \"audio/wav\")},\n",
@@ -915,7 +966,7 @@
"\n",
"The flagship pipeline, kept honest and miniature: a 6-second synthetic clip (color frame + the cell-9 English narration) is dubbed into Spanish — upload → prep (audio extract + Demucs vocal separation) → transcribe → translate → voice-cloned TTS → mux — all through the same job API the app uses.\n",
"\n",
"**Run this cell only if you have 5-15 minutes**: the first run downloads the Demucs separation model and (if cell 13 didn't run) an ASR model. Translation here uses the free Google web endpoint via `deep-translator` (installed in-cell); for a fully offline dub the backend also supports `provider=\"nllb\"` (a ~2.5 GB one-time model download).\n"
"**Run this cell only if you have 5-15 minutes**: run **cell 12a** first to install the ASR model (cell 13 itself is optional). The first dubbing run also downloads the Demucs separation model. Translation here uses the free Google web endpoint via `deep-translator` (installed in-cell); for a fully offline dub the backend also supports `provider=\"nllb\"` (a ~2.5 GB one-time model download).\n"
]
},
{
@@ -925,6 +976,9 @@
"outputs": [],
"source": [
"# ── 18. Video dubbing (mini): English clip -> Spanish dub ───────────────────\n",
"if not globals().get(\"ASR_MODEL_READY\", False):\n",
" raise SystemExit(\"Run cell 12a (ASR model download) before transcription or dubbing.\")\n",
"\n",
"import subprocess\n",
"import sys\n",
"import time\n",
@@ -0,0 +1,156 @@
"""The Backend log tab must survive a log rollover.
`main.py` attaches a `RotatingFileHandler(maxBytes=2MB, backupCount=3)`, so
`omnivoice.log` is rolled into `.1/.2/.3` and starts again from empty. The tail
endpoint read only the current file, so for the minutes after a rollover the
panel showed a handful of lines or none while up to 6 MB of history, the
failure included, sat in `omnivoice.log.1`.
That matters more than a cosmetic gap: `.github/ISSUE_TEMPLATE` and the engine
guides both ask a reporter to paste "the backend log" from that panel, and
#1782 is a thread where three rounds went into an empty Logs panel. A rollover
is not the only way to get one, but it is one this endpoint can rule out.
Measured before the fix, with 3 lines in the current file and 500 in each of
two backups: `tail=200` returned 3 lines and reported `total_lines: 3`.
Clear is in the same file because the two are coupled once the tail reaches
into the backups, a Clear that truncates only `omnivoice.log` looks like it did
nothing.
"""
from __future__ import annotations
import asyncio
import os
import pytest
@pytest.fixture
def system_mod():
from api.routers import system
return system
@pytest.fixture
def rolling(tmp_path, system_mod, monkeypatch):
"""A rolled-over log set: 3 fresh lines, 500 + 500 in the backups."""
base = tmp_path / "omnivoice.log"
base.write_text("".join(f"fresh {i}\n" for i in range(3)), encoding="utf-8")
(tmp_path / "omnivoice.log.1").write_text(
"".join(f"older {i}\n" for i in range(500)), encoding="utf-8"
)
(tmp_path / "omnivoice.log.2").write_text(
"".join(f"oldest {i}\n" for i in range(500)), encoding="utf-8"
)
monkeypatch.setattr(system_mod, "LOG_PATH", str(base))
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
return tmp_path
def test_tail_reaches_across_a_rollover(system_mod, rolling):
res = asyncio.run(system_mod.system_logs(tail=200))
assert len(res["lines"]) == 200
# Oldest first, and the newest line is still the newest line on disk.
assert res["lines"][0].strip() == "older 303"
assert res["lines"][-1].strip() == "fresh 2"
# It crossed exactly one boundary and stopped there.
assert [os.path.basename(p) for p in res["paths"]] == ["omnivoice.log.1", "omnivoice.log"]
def test_the_common_case_still_reads_one_file(system_mod, tmp_path, monkeypatch):
"""No rollover in play → the backups are not opened at all.
The panel polls every 5s, so reaching into 6 MB of backups on every call
would be a poor trade for a case that only matters right after a roll.
"""
base = tmp_path / "omnivoice.log"
base.write_text("".join(f"line {i}\n" for i in range(500)), encoding="utf-8")
(tmp_path / "omnivoice.log.1").write_text("stale\n" * 500, encoding="utf-8")
monkeypatch.setattr(system_mod, "LOG_PATH", str(base))
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
res = asyncio.run(system_mod.system_logs(tail=200))
assert [os.path.basename(p) for p in res["paths"]] == ["omnivoice.log"]
assert res["lines"][0].strip() == "line 300"
assert "stale" not in "".join(res["lines"])
def test_a_backup_alone_is_still_a_log(system_mod, tmp_path, monkeypatch):
"""The window between the roll and the first new line is not "no log"."""
base = tmp_path / "omnivoice.log"
(tmp_path / "omnivoice.log.1").write_text("only in the backup\n", encoding="utf-8")
monkeypatch.setattr(system_mod, "LOG_PATH", str(base))
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
assert not base.exists()
res = asyncio.run(system_mod.system_logs(tail=200))
assert res["exists"] is True
assert [line.strip() for line in res["lines"]] == ["only in the backup"]
def test_no_log_at_all_still_reports_absent(system_mod, tmp_path, monkeypatch):
monkeypatch.setattr(system_mod, "LOG_PATH", str(tmp_path / "omnivoice.log"))
monkeypatch.setattr(system_mod, "CRASH_LOG_PATH", str(tmp_path / "crash_log.txt"))
res = asyncio.run(system_mod.system_logs(tail=200))
assert res == {"lines": [], "path": str(tmp_path / "omnivoice.log"), "exists": False}
def test_a_file_that_vanishes_mid_walk_does_not_fail_the_panel(
system_mod, rolling, monkeypatch
):
"""A rollover can rename a candidate between the scan and the open.
The handler exposes no lock a route can take, so the walk skips the file
instead of failing the request. The single-file version 500'd the whole
panel in the same situation, so this is strictly better than before.
"""
real_tail = system_mod._tail_file
def flaky(path, tail):
if path.endswith("omnivoice.log.1"):
raise FileNotFoundError(path)
return real_tail(path, tail)
monkeypatch.setattr(system_mod, "_tail_file", flaky)
res = asyncio.run(system_mod.system_logs(tail=200))
# .1 is gone, so the walk falls through to .2 and still fills the request.
assert res["exists"] is True
assert len(res["lines"]) == 200
assert [os.path.basename(p) for p in res["paths"]] == ["omnivoice.log.2", "omnivoice.log"]
def test_clear_covers_a_backup_created_after_the_scan(system_mod, rolling, monkeypatch):
"""Clear works off the fixed name set, not a snapshot of what exists.
Enumerating first left a window where a rollover created a backup after the
scan and its history survived a Clear that reported success.
"""
monkeypatch.setattr(system_mod, "prefs_delete", lambda _key: None, raising=False)
# Stand in for the race: a scan that ran before the rollover would have
# reported no backups at all, and the version that trusted it truncated
# only the current file while .1 and .2 kept their history.
monkeypatch.setattr(system_mod, "_rotated_log_paths", lambda _base: [])
asyncio.run(system_mod.clear_system_logs())
for name in ("omnivoice.log", "omnivoice.log.1", "omnivoice.log.2"):
assert (rolling / name).stat().st_size == 0, f"{name} survived a Clear that trusted a stale scan"
def test_clear_empties_the_backups_too(system_mod, rolling, monkeypatch):
monkeypatch.setattr(system_mod, "prefs_delete", lambda _key: None, raising=False)
res = asyncio.run(system_mod.clear_system_logs())
assert res == {"cleared": True}
for name in ("omnivoice.log", "omnivoice.log.1", "omnivoice.log.2"):
assert (rolling / name).stat().st_size == 0, f"{name} survived Clear"
@@ -0,0 +1,219 @@
"""Clearing the Tauri tab must not destroy the backend's stderr.
`/system/logs/tauri/clear` iterated every candidate in `_tauri_log_candidates()`
and truncated each one including `backend_err.log`, the spawned backend's
stderr. Three things make that a data-loss bug rather than a tidy-up:
- The tab that owns the button does not show it. On desktop the Frontend/Tauri
panel goes through the Rust `read_log_tail` command, whose `tauri_log_path()`
resolves `tauri.log` and nothing else, so the user truncates a file they were
never shown.
- `src-tauri/src/backend.rs::open_err_log_for_run()` opens it **append-only**
so "a respawn must not destroy the previous run's evidence" (#1510) and
rotates it to `.1` rather than truncating. It manages its own size; clearing
it from here only undoes that.
- A native death a Windows access violation (`0xC0000005`), a SIGSEGV writes
nothing to the Python log by construction, so this file is the only record
that it happened. #1777 and #1782 are both threads where the maintainer had
to ask a reporter for this file by hand.
Clear is therefore narrowed to the shell's own log. The READ path is unchanged,
and the last test pins that: `_tauri_log_candidates()` still lists all four
files in the same order on all three platforms, because the refactor that split
the list is where a silent regression would hide.
"""
from __future__ import annotations
import asyncio
import os
import pytest
@pytest.fixture
def system_mod():
from api.routers import system
return system
@pytest.fixture
def shell_logs(tmp_path, system_mod, monkeypatch):
"""A plugin log and both backend redirects, all non-empty."""
plugin = tmp_path / "tauri.log"
backend_out = tmp_path / "backend.log"
backend_err = tmp_path / "backend_err.log"
for f in (plugin, backend_out, backend_err):
f.write_text(f"{f.name} content\n", encoding="utf-8")
# Patch the composite as well as the two halves, and with raising=False, so
# the behaviour assertions run identically against the pre-split code —
# otherwise a fail-before check only proves the new helpers do not exist
# yet, which is not the same as proving the bug.
monkeypatch.setattr(
system_mod,
"_tauri_log_candidates",
lambda: [str(plugin), str(backend_out), str(backend_err)],
)
monkeypatch.setattr(
system_mod, "_tauri_plugin_log_candidates", lambda: [str(plugin)], raising=False
)
monkeypatch.setattr(
system_mod,
"_backend_redirect_log_candidates",
lambda: [str(backend_out), str(backend_err)],
raising=False,
)
return plugin, backend_out, backend_err
def test_clear_keeps_the_backend_stderr(system_mod, shell_logs):
plugin, backend_out, backend_err = shell_logs
res = asyncio.run(system_mod.clear_tauri_logs())
assert res["cleared"] == [str(plugin)]
assert plugin.stat().st_size == 0
# The evidence a native crash leaves behind survives the button.
assert backend_err.read_text(encoding="utf-8") == "backend_err.log content\n"
assert backend_out.read_text(encoding="utf-8") == "backend.log content\n"
def test_clear_reports_nothing_when_there_is_no_plugin_log(system_mod, tmp_path, monkeypatch):
absent = [str(tmp_path / "absent.log")]
monkeypatch.setattr(system_mod, "_tauri_log_candidates", lambda: absent)
monkeypatch.setattr(
system_mod, "_tauri_plugin_log_candidates", lambda: absent, raising=False
)
monkeypatch.setattr(
system_mod, "_backend_redirect_log_candidates", lambda: [], raising=False
)
assert asyncio.run(system_mod.clear_tauri_logs()) == {"cleared": [], "failed": 0}
@pytest.mark.parametrize(
"platform,expected",
[
(
"darwin",
[
"Library/Logs/{bid}/tauri.log",
"Library/Logs/{bid}/VoiceStudio.log",
"Library/Logs/OmniVoice/backend.log",
"Library/Logs/OmniVoice/backend_err.log",
],
),
(
"linux",
[
".local/share/{bid}/logs/tauri.log",
".config/{bid}/logs/tauri.log",
".local/state/OmniVoice/backend.log",
".local/state/OmniVoice/backend_err.log",
],
),
(
"win32",
[
"AppData/Local/{bid}/logs/tauri.log",
"AppData/Roaming/{bid}/logs/tauri.log",
"AppData/Local/OmniVoice/Logs/backend.log",
"AppData/Local/OmniVoice/Logs/backend_err.log",
],
),
],
)
def test_the_read_candidate_list_is_unchanged(system_mod, monkeypatch, platform, expected):
"""Splitting the list must not change what `/system/logs/tauri` can reach.
Same files, same order, on every platform the composite is where a
refactor slip would silently hide a log from the read path.
"""
bid = "com.debpalash.omnivoice-studio"
home = "/home/tester"
monkeypatch.setattr(system_mod.sys, "platform", platform)
monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", home))
for var in ("XDG_DATA_HOME", "XDG_STATE_HOME", "APPDATA", "LOCALAPPDATA", "OMNIVOICE_LOG_DIR"):
monkeypatch.delenv(var, raising=False)
if platform == "win32":
monkeypatch.setenv("APPDATA", f"{home}/AppData/Roaming")
monkeypatch.setenv("LOCALAPPDATA", f"{home}/AppData/Local")
got = [p.replace("\\", "/") for p in system_mod._tauri_log_candidates()]
assert got == [f"{home}/{suffix.format(bid=bid)}" for suffix in expected]
def test_the_backend_redirect_follows_the_writer_s_override(system_mod, monkeypatch, tmp_path):
"""`OMNIVOICE_LOG_DIR` moves the files, so the resolver has to follow it.
`backend.rs::backend_log_path()` checks that variable before any per-OS
default, and the backend is a child of the shell, so an ambient override
reaches both processes. A resolver that ignored it would look in the
per-OS default while the writer wrote somewhere else the same divergence
that makes the desktop Logs panel read the wrong file in #1782.
"""
monkeypatch.setenv("OMNIVOICE_LOG_DIR", str(tmp_path))
assert system_mod._backend_redirect_log_candidates() == [
os.path.join(str(tmp_path), "backend.log"),
os.path.join(str(tmp_path), "backend_err.log"),
]
def test_a_blank_override_falls_back_to_the_default(system_mod, monkeypatch):
"""Matches the writer's `!dir.trim().is_empty()` guard."""
monkeypatch.setenv("OMNIVOICE_LOG_DIR", " ")
paths = system_mod._backend_redirect_log_candidates()
assert paths, "a blank override must not empty the candidate list"
assert all("OmniVoice" in p for p in paths)
def test_a_padded_override_resolves_the_same_place_the_writer_wrote(
system_mod, monkeypatch, tmp_path
):
"""A padded `OMNIVOICE_LOG_DIR` must not split the reader from the writer.
The Python side strips the variable before joining. `backend.rs` trimmed it
only for the emptiness guard and then did `PathBuf::from(dir)` on the RAW
value, so ` /tmp/logs ` had the shell writing to a directory whose name
carried the spaces while this resolver looked in the trimmed one. That is
the same reader/writer divergence #1782 is about, reintroduced through the
override that exists to control it.
"""
monkeypatch.setenv("OMNIVOICE_LOG_DIR", f" {tmp_path} ")
assert system_mod._backend_redirect_log_candidates() == [
os.path.join(str(tmp_path), "backend.log"),
os.path.join(str(tmp_path), "backend_err.log"),
]
def test_a_vanished_rotated_file_is_skipped_but_a_real_error_is_not(
system_mod, monkeypatch, tmp_path
):
"""The rotation walk may skip a file that rolled away — nothing else.
`except OSError: continue` also swallowed PermissionError and genuine I/O
failures, so a log the panel could not read rendered as an empty or partly
empty panel with no explanation. Only the race the guard exists for is
silent now.
"""
base = tmp_path / "omnivoice.log"
base.write_text("line one\n", encoding="utf-8")
def _vanished(path, remaining):
raise FileNotFoundError(path)
monkeypatch.setattr(system_mod, "_tail_file", _vanished)
lines, total, paths = system_mod._tail_rolling(str(base), 10)
assert (lines, total, paths) == ([], 0, [])
def _refused(path, remaining):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(system_mod, "_tail_file", _refused)
with pytest.raises(PermissionError):
system_mod._tail_rolling(str(base), 10)
+74
View File
@@ -0,0 +1,74 @@
"""Execute notebook prerequisite ordering with mocked downloads (#1922)."""
import json
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
NOTEBOOK = Path(__file__).resolve().parents[1] / "notebooks/OmniVoice_Studio_Colab.ipynb"
class ColabASRSetupTests(unittest.TestCase):
def setup_source(self):
cells = json.loads(NOTEBOOK.read_text())["cells"]
for cell in cells:
source = "".join(cell["source"])
if cell["cell_type"] == "code" and "12a. Install the transcription model" in source:
return source
self.fail("Notebook needs an explicit ASR setup cell before transcription")
def test_setup_downloads_exact_repo_and_can_be_rerun(self):
calls = []
def download(repo_id):
self.assertEqual(repo_id, "Systran/faster-whisper-large-v3")
calls.append(repo_id)
return "/fake/hub/snapshot"
module = types.ModuleType("huggingface_hub")
module.snapshot_download = download
scope = {}
with patch.dict(sys.modules, {"huggingface_hub": module}):
exec(self.setup_source(), scope)
self.assertTrue(scope["ASR_MODEL_READY"])
self.assertEqual(len(calls), 1)
calls.clear()
exec(self.setup_source(), scope)
self.assertEqual(len(calls), 1)
def test_failed_download_clears_previous_ready_state(self):
module = types.ModuleType("huggingface_hub")
def download(*args, **kwargs):
raise OSError("download interrupted")
module.snapshot_download = download
scope = {"ASR_MODEL_READY": True}
with patch.dict(sys.modules, {"huggingface_hub": module}):
with self.assertRaises(OSError):
exec(self.setup_source(), scope)
self.assertFalse(scope["ASR_MODEL_READY"])
def test_transcription_and_dubbing_gate_before_any_work(self):
cells = json.loads(NOTEBOOK.read_text())["cells"]
for number in (13, 18):
source = next("".join(c["source"]) for c in cells
if c["cell_type"] == "code"
and f" {number}. " in "".join(c["source"]).splitlines()[0])
with self.subTest(cell=number):
with self.assertRaisesRegex(SystemExit, "12a"):
exec(source, {})
def test_setup_precedes_transcription_in_run_all(self):
code = ["".join(c["source"]) for c in json.loads(NOTEBOOK.read_text())["cells"]
if c["cell_type"] == "code"]
setup = next(i for i, s in enumerate(code)
if "12a. Install the transcription model" in s)
transcribe = next(i for i, s in enumerate(code)
if " 13. " in s.splitlines()[0])
self.assertLess(setup, transcribe)
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -114,6 +114,14 @@ async def test_tauri_log_clear_reports_truncate_failure(monkeypatch, tmp_path, a
log = tmp_path / "webview.log"
log.write_text("data", encoding="utf-8")
monkeypatch.setattr(system, "_tauri_log_candidates", lambda: [str(log)])
# Clear now goes through the plugin-log half only, so patching the
# composite alone no longer reaches it. Patching both keeps this honest
# against the pre-split code too; without it the real resolver is
# consulted and the result depends on whether the machine running the
# test happens to have a shell log on disk.
monkeypatch.setattr(
system, "_tauri_plugin_log_candidates", lambda: [str(log)], raising=False
)
monkeypatch.setattr(
system,
"_truncate_file",
+89
View File
@@ -0,0 +1,89 @@
"""#1812 — an accelerated GPU with a caveat must name the RIGHT remedy.
`_check_gpu_routing` and the setup wizard's preflight both treated "accelerated
but with a routing_reason" as one case and printed the driver/architecture
remedy: "may fail at kernel launch — update drivers / reinstall torch for this
GPU architecture." A perfectly healthy CUDA card that merely has less free VRAM
than the engine wants also carries a reason, so a low-VRAM caveat told the user
to reinstall torch. That sends someone to rebuild a working toolchain over what
is really "close some other models first".
The two are now split on KERNEL_RISK_MARKER, which is the only marker that
means the kernels themselves may not launch.
"""
import pytest
from core import diagnose
from core.diagnose import WARN, OK
from core.device_caps import KERNEL_RISK_MARKER
def _verdict(monkeypatch, reason, status="accelerated"):
from services import tts_backend
monkeypatch.setattr(
tts_backend,
"gpu_routing_verdict",
lambda: {
"routing_status": status,
"engine": "omnivoice",
"effective_device": "cuda",
"routing_reason": reason,
"host_family": "cuda",
},
)
def test_kernel_risk_keeps_the_driver_remedy(monkeypatch):
_verdict(monkeypatch, f"sm_120 is not in the build's archs — {KERNEL_RISK_MARKER}")
check = diagnose._check_gpu_routing()
assert check["status"] == WARN
assert "update drivers" in check["hint"]
def test_low_vram_does_not_tell_the_user_to_reinstall_torch(monkeypatch):
_verdict(monkeypatch, "6.0 GB free is below the 8.0 GB this engine wants")
check = diagnose._check_gpu_routing()
assert check["status"] == WARN
assert "reinstall torch" not in check["hint"]
assert "update drivers" not in check["hint"]
assert "lighter engine" in check["hint"]
def test_a_clean_accelerated_host_stays_ok(monkeypatch):
_verdict(monkeypatch, None)
check = diagnose._check_gpu_routing()
assert check["status"] == OK
@pytest.mark.parametrize(
"reason,expect_driver_remedy",
[
(f"arch mismatch — {KERNEL_RISK_MARKER}", True),
("low free VRAM for this engine", False),
],
)
def test_preflight_routes_the_same_way_as_the_diagnostic(
monkeypatch, reason, expect_driver_remedy
):
# The wizard's preflight carries its own copy of this branch; the two must
# not drift, or the same host gets different advice in Settings than in
# first-run setup.
from api.routers.setup import wizard
from services import tts_backend
monkeypatch.setattr(
tts_backend,
"gpu_routing_verdict",
lambda: {
"routing_status": "accelerated",
"engine": "omnivoice",
"effective_device": "cuda",
"routing_reason": reason,
"host_family": "cuda",
},
)
rows = wizard.preflight()["checks"]
row = next(r for r in rows if r["id"] == "gpu_routing")
assert row["status"] == "warn"
assert ("reinstall torch" in (row["fix"] or "")) is expect_driver_remedy