fix: preserve startup and media failure evidence (#1722)
* fix: preserve startup and media failure evidence * fix(dictation): bind readiness to installed revision * test(dictation): align cache resolution contract * fix: keep retry failures actionable
This commit is contained in:
@@ -22,6 +22,10 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
|
||||
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
|
||||
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
|
||||
|
||||
## [0.5.1] — 2026-08-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
@@ -557,6 +557,7 @@ def classify(reason: str) -> str:
|
||||
or "unable to download video" in low
|
||||
or "remote end closed" in low
|
||||
or "timed out" in low
|
||||
or "the page needs to be reloaded" in low
|
||||
):
|
||||
return "VIDEO_DOWNLOAD_NETWORK"
|
||||
# #1227: Windows Smart App Control / WDAC / AppLocker refused to load a
|
||||
|
||||
@@ -281,41 +281,66 @@ def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
|
||||
Restricts the fetch to the exact int8 assets we pin via ``allow_patterns``
|
||||
so we never pull the bundled fp32 weights or test wavs.
|
||||
"""
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from huggingface_hub import snapshot_download
|
||||
from services.hf_revisions import installed_revision, revision_for
|
||||
from services.hf_revisions import revision_for
|
||||
|
||||
wanted = list(spec.files.values())
|
||||
# Probe the revision an existing installation actually resolved. Older
|
||||
# releases followed ``main`` and may therefore have a different snapshot;
|
||||
# retaining it preserves offline upgrades. Any network fetch still uses
|
||||
# the reviewed immutable pin.
|
||||
installed = installed_revision(spec.repo_id, hf_constants.HF_HUB_CACHE)
|
||||
try:
|
||||
return snapshot_download(
|
||||
repo_id=spec.repo_id,
|
||||
revision=installed,
|
||||
local_files_only=True,
|
||||
allow_patterns=wanted,
|
||||
)
|
||||
except Exception:
|
||||
if not download:
|
||||
raise
|
||||
installed = _installed_snapshot(spec)
|
||||
if installed:
|
||||
return installed
|
||||
if not download:
|
||||
raise FileNotFoundError(f"No complete cached snapshot for {spec.repo_id}")
|
||||
logger.info("sherpa dictation: downloading %s on first use", spec.repo_id)
|
||||
return snapshot_download(
|
||||
repo_id=spec.repo_id,
|
||||
revision=revision_for(spec.repo_id),
|
||||
allow_patterns=wanted,
|
||||
cache_dir=_live_hub_cache_dir(),
|
||||
)
|
||||
|
||||
|
||||
def _live_hub_cache_dir() -> str:
|
||||
"""The effective hub root, evaluated after Settings restores the env."""
|
||||
direct = os.environ.get("HF_HUB_CACHE") or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
if direct:
|
||||
return os.path.expanduser(direct)
|
||||
home = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
|
||||
return os.path.join(os.path.expanduser(home), "hub")
|
||||
|
||||
|
||||
def _installed_snapshot(spec: SherpaModelSpec) -> str | None:
|
||||
"""Complete snapshot for the recorded revision in the live cache."""
|
||||
from services.hf_revisions import installed_revision
|
||||
|
||||
cache_dir = _live_hub_cache_dir()
|
||||
revision = installed_revision(spec.repo_id, cache_dir)
|
||||
snapshot = os.path.join(
|
||||
cache_dir,
|
||||
"models--" + spec.repo_id.replace("/", "--"),
|
||||
"snapshots",
|
||||
revision,
|
||||
)
|
||||
if all(os.path.isfile(os.path.join(snapshot, filename))
|
||||
for filename in spec.files.values()):
|
||||
return snapshot
|
||||
return None
|
||||
|
||||
|
||||
def is_installed(spec: SherpaModelSpec) -> bool:
|
||||
"""True if every pinned asset is already present in the HF cache."""
|
||||
try:
|
||||
d = _resolve_model_dir(spec, download=False)
|
||||
except Exception:
|
||||
return False
|
||||
return all(os.path.isfile(os.path.join(d, f)) for f in spec.files.values())
|
||||
"""True if the recorded cached snapshot contains every pinned asset.
|
||||
|
||||
Do not use ``snapshot_download(local_files_only=True)`` for this probe.
|
||||
``huggingface_hub.constants.HF_HUB_CACHE`` is fixed when that module is
|
||||
first imported, while VoiceStudio can restore its cache directory later
|
||||
from the durable user settings. Resolve the live root and the recorded
|
||||
revision ourselves so readiness and loading cannot disagree after a cache
|
||||
move, desktop relaunch, or stale snapshot (#1707).
|
||||
"""
|
||||
return _installed_snapshot(spec) is not None
|
||||
|
||||
|
||||
# ── Recognizers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -219,6 +219,37 @@ pub fn get_bootstrap_logs(state: tauri::State<'_, BootstrapState>) -> Vec<LogPay
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn buffered_log_tail(logs: &[LogPayload], stage: &str, max_lines: usize) -> String {
|
||||
let mut lines = logs
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|entry| entry.stage == stage && !entry.line.trim().is_empty())
|
||||
.take(max_lines)
|
||||
.map(|entry| entry.line.trim().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
lines.reverse();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn command_failure_message(
|
||||
prefix: &str,
|
||||
result: &io::Result<std::process::ExitStatus>,
|
||||
output_tail: &str,
|
||||
) -> String {
|
||||
let outcome = match result {
|
||||
Ok(status) => status
|
||||
.code()
|
||||
.map(|code| format!("exit code {code}"))
|
||||
.unwrap_or_else(|| status.to_string()),
|
||||
Err(error) => format!("command error: {error}"),
|
||||
};
|
||||
if output_tail.is_empty() {
|
||||
format!("{prefix}: {outcome} — no command output was captured")
|
||||
} else {
|
||||
format!("{prefix}: {outcome}\n\nLast output:\n{output_tail}")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
|
||||
respawn_backend(app, state.stage.clone(), state.logs.clone());
|
||||
@@ -2359,7 +2390,18 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir);
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
fail(progress, &format!("Repair uv sync failed: {:?}", repair_status));
|
||||
let output_tail = app
|
||||
.try_state::<BootstrapState>()
|
||||
.and_then(|state| {
|
||||
state.logs.lock().ok().map(|logs| {
|
||||
buffered_log_tail(&logs, "installing_deps", 12)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
fail(
|
||||
progress,
|
||||
&command_failure_message("Repair uv sync failed", &repair_status, &output_tail),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -3295,6 +3337,38 @@ mod tests {
|
||||
assert!(!marker.is_file());
|
||||
let _ = fs::remove_dir_all(&venv_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_command_message_carries_the_newest_relevant_output() {
|
||||
let logs = vec![
|
||||
LogPayload {
|
||||
stage: "downloading_uv".into(),
|
||||
line: "unrelated".into(),
|
||||
},
|
||||
LogPayload {
|
||||
stage: "installing_deps".into(),
|
||||
line: "resolver context".into(),
|
||||
},
|
||||
LogPayload {
|
||||
stage: "installing_deps".into(),
|
||||
line: "actual dependency conflict".into(),
|
||||
},
|
||||
];
|
||||
let tail = buffered_log_tail(&logs, "installing_deps", 1);
|
||||
let failure = command_failure_message(
|
||||
"Repair uv sync failed",
|
||||
&Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"access denied",
|
||||
)),
|
||||
&tail,
|
||||
);
|
||||
|
||||
assert!(failure.contains("command error: access denied"));
|
||||
assert!(failure.contains("Last output:\nactual dependency conflict"));
|
||||
assert!(!failure.contains("resolver context"));
|
||||
assert!(!failure.contains("unrelated"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -41,6 +41,14 @@ def test_broken_pipe_is_transient():
|
||||
assert dp._is_transient_download_error(ConnectionResetError()) is True
|
||||
|
||||
|
||||
def test_youtube_reload_request_is_transient():
|
||||
reported = RuntimeError(
|
||||
"ERROR: [youtube] qQjZNdURxzg: The page needs to be reloaded."
|
||||
)
|
||||
assert failure.classify(str(reported)) == "VIDEO_DOWNLOAD_NETWORK"
|
||||
assert dp._is_transient_download_error(reported) is True
|
||||
|
||||
|
||||
def test_unsupported_url_is_not_transient():
|
||||
# Must classify as UNSUPPORTED (more specific) and therefore NOT retry —
|
||||
# otherwise we'd waste 3 attempts on a link that can never download.
|
||||
@@ -137,6 +145,20 @@ def test_gives_up_after_bounded_retries(_patched_ytdlp):
|
||||
assert evt["hint"]
|
||||
|
||||
|
||||
def test_reload_request_exhaustion_keeps_network_guidance(_patched_ytdlp):
|
||||
job_dir = str(_patched_ytdlp)
|
||||
message = "ERROR: [youtube] qQjZNdURxzg: The page needs to be reloaded."
|
||||
_FakeYDL.behaviors = [RuntimeError(message)] * 10
|
||||
|
||||
with pytest.raises(RuntimeError) as ei:
|
||||
dp.yt_download_sync("https://youtube.com/watch?v=qQjZNdURxzg", job_dir)
|
||||
|
||||
assert len(_FakeYDL.calls) == dp._YT_DOWNLOAD_RETRIES + 1
|
||||
evt = failure.build_failure(ei.value, stage="download", include_diagnostic=False)
|
||||
assert evt["docs_topic"] == "VIDEO_DOWNLOAD_NETWORK"
|
||||
assert evt["hint"]
|
||||
|
||||
|
||||
def test_does_not_retry_unsupported_url(_patched_ytdlp):
|
||||
job_dir = str(_patched_ytdlp)
|
||||
# A non-downloadable link must fail fast (no wasted retries).
|
||||
|
||||
@@ -203,40 +203,42 @@ def test_sherpa_available_degrades_native_loader_failures(monkeypatch, native_er
|
||||
def test_model_resolution_pins_offline_probe_and_download(monkeypatch, tmp_path):
|
||||
from services import hf_revisions, sherpa_dictation as sd
|
||||
import huggingface_hub
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
spec = sd.get_spec("sherpa-whisper-tiny")
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||||
calls = []
|
||||
|
||||
def fake_snapshot(**kwargs):
|
||||
calls.append(kwargs)
|
||||
if kwargs.get("local_files_only"):
|
||||
raise FileNotFoundError("not cached")
|
||||
return "/cache/pinned"
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot)
|
||||
assert sd._resolve_model_dir(spec) == "/cache/pinned"
|
||||
assert len(calls) == 2
|
||||
assert all(call["revision"] == hf_revisions.revision_for(spec.repo_id) for call in calls)
|
||||
assert calls == [{
|
||||
"repo_id": spec.repo_id,
|
||||
"revision": hf_revisions.revision_for(spec.repo_id),
|
||||
"allow_patterns": list(spec.files.values()),
|
||||
"cache_dir": str(tmp_path),
|
||||
}]
|
||||
|
||||
|
||||
def test_model_resolution_probes_preserved_legacy_snapshot(monkeypatch, tmp_path):
|
||||
from services import hf_revisions, sherpa_dictation as sd
|
||||
import huggingface_hub
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
spec = sd.get_spec("sherpa-whisper-tiny")
|
||||
legacy_revision = "e" * 40
|
||||
ref = (
|
||||
tmp_path
|
||||
/ "models--csukuangfj--sherpa-onnx-whisper-tiny"
|
||||
/ "refs"
|
||||
/ "main"
|
||||
)
|
||||
repo = tmp_path / "models--csukuangfj--sherpa-onnx-whisper-tiny"
|
||||
ref = repo / "refs" / "main"
|
||||
ref.parent.mkdir(parents=True)
|
||||
ref.write_text(legacy_revision + "\n", encoding="ascii")
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
snapshot = repo / "snapshots" / legacy_revision
|
||||
snapshot.mkdir(parents=True)
|
||||
for filename in spec.files.values():
|
||||
target = snapshot / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"model")
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||||
calls = []
|
||||
|
||||
def fake_snapshot(**kwargs):
|
||||
@@ -244,14 +246,9 @@ def test_model_resolution_probes_preserved_legacy_snapshot(monkeypatch, tmp_path
|
||||
return "/cache/legacy"
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot)
|
||||
assert sd._resolve_model_dir(spec) == "/cache/legacy"
|
||||
assert calls == [{
|
||||
"repo_id": spec.repo_id,
|
||||
"revision": legacy_revision,
|
||||
"local_files_only": True,
|
||||
"allow_patterns": list(spec.files.values()),
|
||||
}]
|
||||
assert calls[0]["revision"] != hf_revisions.revision_for(spec.repo_id)
|
||||
assert sd._resolve_model_dir(spec) == str(snapshot)
|
||||
assert calls == []
|
||||
assert legacy_revision != hf_revisions.revision_for(spec.repo_id)
|
||||
|
||||
|
||||
# ── The 4 recognizer kinds construct + transcribe ───────────────────────────
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Sherpa installed-state must follow the catalogue's live cache roots (#1707)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services import sherpa_dictation
|
||||
from services.hf_revisions import revision_for
|
||||
|
||||
|
||||
def test_installed_probe_uses_live_hf_cache_root(tmp_path, monkeypatch):
|
||||
spec = sherpa_dictation.get_spec("sherpa-whisper-tiny")
|
||||
assert spec is not None
|
||||
snapshot = (
|
||||
tmp_path
|
||||
/ ("models--" + spec.repo_id.replace("/", "--"))
|
||||
/ "snapshots"
|
||||
/ revision_for(spec.repo_id)
|
||||
)
|
||||
snapshot.mkdir(parents=True)
|
||||
for filename in spec.files.values():
|
||||
target = snapshot / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"model")
|
||||
|
||||
# Simulate huggingface_hub having been imported before Settings restored a
|
||||
# different cache. The old implementation asked snapshot_download(),
|
||||
# which could keep consulting its import-time constant instead of this
|
||||
# live value.
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
sherpa_dictation,
|
||||
"_resolve_model_dir",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError(
|
||||
"installed-state must not call snapshot_download"
|
||||
)),
|
||||
)
|
||||
|
||||
assert sherpa_dictation.is_installed(spec) is True
|
||||
|
||||
|
||||
def test_installed_probe_requires_all_pinned_assets(tmp_path, monkeypatch):
|
||||
spec = sherpa_dictation.get_spec("sherpa-whisper-tiny")
|
||||
assert spec is not None
|
||||
snapshot = (
|
||||
tmp_path
|
||||
/ ("models--" + spec.repo_id.replace("/", "--"))
|
||||
/ "snapshots"
|
||||
/ revision_for(spec.repo_id)
|
||||
)
|
||||
snapshot.mkdir(parents=True)
|
||||
first = next(iter(spec.files.values()))
|
||||
target = snapshot / first
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"partial")
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||||
|
||||
assert sherpa_dictation.is_installed(spec) is False
|
||||
|
||||
|
||||
def test_complete_stale_snapshot_does_not_mask_missing_recorded_revision(tmp_path, monkeypatch):
|
||||
spec = sherpa_dictation.get_spec("sherpa-whisper-tiny")
|
||||
assert spec is not None
|
||||
repo = tmp_path / ("models--" + spec.repo_id.replace("/", "--"))
|
||||
stale = repo / "snapshots" / ("a" * 40)
|
||||
stale.mkdir(parents=True)
|
||||
for filename in spec.files.values():
|
||||
target = stale / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"stale model")
|
||||
(repo / "voicestudio-revision").write_text("b" * 40, encoding="ascii")
|
||||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||||
|
||||
assert sherpa_dictation.is_installed(spec) is False
|
||||
Reference in New Issue
Block a user