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:
@@ -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!
|
||||
|
||||
|
||||
@@ -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 2–3s."""
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(®ion, 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user