+ )}
{error && (
Date: Thu, 17 Sep 2026 16:50:16 +0530
Subject: [PATCH 02/11] fix(setup): send the HF token when installing a gated
model
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Installing a gated model failed the fast download path with "401
Unauthorized" even with a valid token and the licence accepted, then fell
back to snapshot_download and logged a 401 that reads like the token or
the licence grant is at fault when it is neither (#2163).
`token_resolver.resolve()` returns a ResolvedToken record, not the bearer
string. Two call sites handed that record straight to consumers typed
`token: str | None`, and both fail silently rather than loudly:
- `_segmented_snapshot` passes it to HfApi, get_hf_file_metadata and our
own segmented_download. huggingface_hub's build_hf_headers ignores a
non-str token and falls back to its own ambient discovery, so a token
held only in VoiceStudio's Settings produces NO Authorization header
and every gated file 401s. segmented_download instead interpolates it
into `f"Bearer {token}"`, sending a malformed header that also inlines
the raw secret into the request.
- `_step_fetch_weights` passes it to snapshot_download, so gated engine
weights 401 the same way.
Every other resolve() caller already unwraps `.token`; these two were the
outliers. Both now unwrap once, at the seam.
The existing weights tests all stubbed resolve() to return None, so no
test ever exercised a resolved token — which is why this went unnoticed.
The new tests drive a real ResolvedToken through both seams and assert a
`str` reaches every consumer, plus an integration test that installs the
pyannote diarisation pipeline end to end: a weightless config_only repo
validates, both dependency repos are fetched, and every call carries the
bearer string.
Two catalogue invariants keep the rest of #2163 from returning by edit:
dependency repos must be revision-pinned (revision_for raises otherwise,
so an unpinned one ships an always-failing install), and a config_only
entry must declare config_required_files (without them the completeness
check can never pass and the error lists no files at all — the shape the
report hit on 0.5.2).
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 1 +
backend/api/routers/setup/download.py | 11 +-
backend/services/sidecar_install.py | 7 +-
tests/test_gated_install_token_2163.py | 134 ++++++++++++++++++++++
tests/test_hf_revisions.py | 13 ++-
tests/test_models_catalog.py | 30 +++++
tests/test_pyannote_install_2163.py | 151 +++++++++++++++++++++++++
tests/test_sidecar_install.py | 58 ++++++++++
8 files changed, 400 insertions(+), 5 deletions(-)
create mode 100644 tests/test_gated_install_token_2163.py
create mode 100644 tests/test_pyannote_install_2163.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77e2dd83..27d2b4d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039)
- PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041)
- MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040)
+- Installing a gated model now sends your Hugging Face token on the fast download path too, so pyannote diarisation and gated engine weights stop failing with "401 Unauthorized" when the token is saved in Settings (#2173, #2163)
### CI
diff --git a/backend/api/routers/setup/download.py b/backend/api/routers/setup/download.py
index 74ad664d..32f81d20 100644
--- a/backend/api/routers/setup/download.py
+++ b/backend/api/routers/setup/download.py
@@ -230,7 +230,16 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
from services.segmented_download import segmented_download
from services.token_resolver import resolve as _resolve_token
- token = _resolve_token()
+ # `resolve()` returns a ResolvedToken record, not the bearer string, and
+ # every consumer below is typed `token: str | None`. Handing over the
+ # record fails silently rather than loudly (#2163): huggingface_hub's
+ # build_hf_headers ignores a non-str token and falls back to its own
+ # ambient discovery, so a token held only in VoiceStudio's settings sends
+ # NO Authorization header at all and every gated file 401s; our own
+ # segmented_download interpolates it into `f"Bearer {token}"` and sends a
+ # malformed header carrying the raw secret. Unwrap once, here.
+ _resolved = _resolve_token()
+ token = _resolved.token if _resolved else None
api = HfApi(endpoint=endpoint, token=token)
info = api.repo_info(repo_id, repo_type="model", revision=revision)
commit = info.sha
diff --git a/backend/services/sidecar_install.py b/backend/services/sidecar_install.py
index efd3e655..2e6f26c5 100644
--- a/backend/services/sidecar_install.py
+++ b/backend/services/sidecar_install.py
@@ -1543,10 +1543,15 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
# other model download in the app — see setup/download.py): the
# source checkout is unpinned upstream `main` anyway, and hf_hub
# checksum-verifies each artifact. Hence the B615 waiver below.
+ # Unwrap to the bearer string: snapshot_download takes `token: str |
+ # None`, and a ResolvedToken record is ignored in favour of ambient
+ # discovery, so gated engine weights 401 for a user whose token lives
+ # in VoiceStudio's settings rather than HF's own cache (#2163).
+ _resolved = resolve_token()
kwargs: dict = {
"repo_id": spec.weights_repo_id,
"local_dir": str(wdir),
- "token": resolve_token(),
+ "token": _resolved.token if _resolved else None,
}
if spec.weights_revision:
kwargs["revision"] = spec.weights_revision
diff --git a/tests/test_gated_install_token_2163.py b/tests/test_gated_install_token_2163.py
new file mode 100644
index 00000000..8a062500
--- /dev/null
+++ b/tests/test_gated_install_token_2163.py
@@ -0,0 +1,134 @@
+"""#2163: a gated model install must actually send the HF bearer token.
+
+``token_resolver.resolve()`` returns a ``ResolvedToken`` *record*. Every
+huggingface_hub entry point — and our own ``segmented_download`` — takes
+``token: str | None``. The segmented accelerator handed the record straight
+through, and neither consumer complains:
+
+* ``build_hf_headers`` ignores a non-``str`` token and falls back to
+ huggingface_hub's own ambient discovery, so a token held only in
+ VoiceStudio's Settings produces **no** ``Authorization`` header at all and
+ every gated file 401s;
+* ``segmented_download`` interpolates it into ``f"Bearer {token}"``, sending a
+ malformed header that also inlines the raw secret into the request.
+
+Either way the accelerator 401s on the first file of a gated repo, is disabled
+for the rest of the install, and logs a 401 that reads like the user's token or
+license grant is at fault when it is neither.
+"""
+import importlib
+import os
+from types import SimpleNamespace
+
+os.environ.setdefault("OMNIVOICE_MODEL", "test")
+os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
+
+import pytest
+
+from services.token_resolver import ResolvedToken
+
+
+@pytest.fixture
+def download():
+ return importlib.import_module("api.routers.setup.download")
+
+
+GATED_REPO = "pyannote/speaker-diarization-3.1"
+REVISION = "c" * 40
+
+
+def _drive_segmented(download, monkeypatch, tmp_path, resolved):
+ """Run ``_segmented_snapshot`` with every network seam mocked.
+
+ Returns the token value each of the three consumers actually received.
+ """
+ import huggingface_hub
+ from huggingface_hub import file_download as hf_file_download
+ from services import segmented_download as sd_mod
+ from services import token_resolver
+
+ monkeypatch.setattr(token_resolver, "resolve", lambda *a, **k: resolved)
+ monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_CACHE", str(tmp_path))
+
+ seen: dict = {}
+
+ class _FakeApi:
+ def __init__(self, *, endpoint=None, token=None):
+ seen["hf_api"] = token
+
+ def repo_info(self, repo_id, repo_type=None, revision=None):
+ # Gated repos serve metadata unauthenticated and gate the file
+ # bytes — which is why the 401 in #2163 lands on the first
+ # resolve() call rather than here.
+ return SimpleNamespace(
+ sha=revision,
+ siblings=[SimpleNamespace(rfilename="config.yaml")],
+ )
+
+ monkeypatch.setattr(huggingface_hub, "HfApi", _FakeApi)
+
+ def _fake_metadata(url, token=None, **_kw):
+ seen["file_metadata"] = token
+ return SimpleNamespace(etag='"deadbeef"', location=url, size=10)
+
+ monkeypatch.setattr(hf_file_download, "get_hf_file_metadata", _fake_metadata)
+
+ async def _fake_segmented(url, blob_path, *, token=None, **_kw):
+ seen["segmented"] = token
+ with open(blob_path, "wb") as fh:
+ fh.write(b"config: ok")
+ return blob_path
+
+ monkeypatch.setattr(sd_mod, "segmented_download", _fake_segmented)
+
+ download._segmented_snapshot(GATED_REPO, endpoint=None, revision=REVISION)
+ return seen
+
+
+def test_segmented_install_sends_the_bearer_string_to_every_consumer(
+ download, monkeypatch, tmp_path
+):
+ seen = _drive_segmented(
+ download,
+ monkeypatch,
+ tmp_path,
+ ResolvedToken(token="hf_gatedsecret", source="app", username="tester"),
+ )
+
+ assert seen == {
+ "hf_api": "hf_gatedsecret",
+ "file_metadata": "hf_gatedsecret",
+ "segmented": "hf_gatedsecret",
+ }
+ # The record itself must never cross the seam — that is the whole bug.
+ for consumer, value in seen.items():
+ assert isinstance(value, str), f"{consumer} received {type(value).__name__}"
+
+
+def test_segmented_install_sends_no_token_when_none_resolves(
+ download, monkeypatch, tmp_path
+):
+ # No token anywhere: every consumer must get a real None so huggingface_hub
+ # treats the repo as anonymous, never the string "None".
+ seen = _drive_segmented(download, monkeypatch, tmp_path, None)
+ assert seen == {"hf_api": None, "file_metadata": None, "segmented": None}
+
+
+def test_a_token_record_would_build_a_broken_authorization_header():
+ """Why the unwrap matters, pinned at our own auth seam.
+
+ ``_auth_headers`` is the function that turns the token into the header the
+ segmented downloader sends. Given the bearer string it produces a valid
+ header; given the record it produces a malformed one that also inlines the
+ raw secret. This is the failure #2163 reported as a 401.
+ """
+ from services.segmented_download import _auth_headers
+
+ url = "https://huggingface.co/pyannote/speaker-diarization-3.1/resolve/main/config.yaml"
+ record = ResolvedToken(token="hf_gatedsecret", source="app", username="tester")
+
+ assert _auth_headers(url, record.token) == {"Authorization": "Bearer hf_gatedsecret"}
+
+ broken = _auth_headers(url, record)["Authorization"]
+ assert broken != "Bearer hf_gatedsecret"
+ assert "ResolvedToken" in broken
diff --git a/tests/test_hf_revisions.py b/tests/test_hf_revisions.py
index 93b4d2c6..2a09bcd9 100644
--- a/tests/test_hf_revisions.py
+++ b/tests/test_hf_revisions.py
@@ -8,10 +8,17 @@ from services import hf_revisions
def test_every_catalog_repo_has_an_immutable_revision():
catalog = yaml.safe_load(Path("backend/config/models.yaml").read_text(encoding="utf-8"))
+ # Dependency repos are downloaded by the installer exactly like top-level
+ # ones (setup/download.py resolves `revision_for(dependency["repo_id"])`),
+ # and `revision_for` raises on an unpinned repo — so an unpinned dependency
+ # ships an install that always fails. Pin them under the same rule (#2163).
+ curated = []
+ for model in catalog["models"]:
+ curated.append(model["repo_id"])
+ for dependency in model.get("dependencies") or ():
+ curated.append(dependency["repo_id"])
missing = {
- model["repo_id"]
- for model in catalog["models"]
- if model["repo_id"] not in hf_revisions.CURATED_REVISIONS
+ repo_id for repo_id in curated if repo_id not in hf_revisions.CURATED_REVISIONS
}
assert missing == set()
assert all(len(revision) == 40 for revision in hf_revisions.CURATED_REVISIONS.values())
diff --git a/tests/test_models_catalog.py b/tests/test_models_catalog.py
index f61bf9de..19055115 100644
--- a/tests/test_models_catalog.py
+++ b/tests/test_models_catalog.py
@@ -43,6 +43,36 @@ def test_every_repo_id_is_well_formed():
assert rid and _REPO_RE.match(rid), f"malformed repo_id: {rid!r}"
+def test_config_only_entries_declare_the_files_that_complete_them():
+ """A ``config_only`` repo carries no weights of its own, so the install
+ validator judges it by ``config_required_files`` instead of a weight floor.
+ Declare none and the entry is permanently uninstallable: the completeness
+ check returns False and the install fails with a message whose list of
+ required files is empty. That is the shape #2163 reported, so it is a
+ catalogue invariant rather than something a user should discover.
+ """
+ for m in _models():
+ if not m.get("config_only"):
+ continue
+ required = m.get("config_required_files")
+ assert required, f"{m['repo_id']}: config_only needs config_required_files"
+ assert all(
+ isinstance(name, str) and name.strip() for name in required
+ ), f"{m['repo_id']}: blank entry in config_required_files"
+
+
+def test_dependency_declarations_are_installable():
+ """Every declared dependency needs an id and the files that prove it landed
+ — the installer rejects a dependency snapshot that lacks them."""
+ for m in _models():
+ for dependency in m.get("dependencies") or ():
+ rid = dependency.get("repo_id")
+ assert rid and _REPO_RE.match(rid), f"malformed dependency repo_id: {rid!r}"
+ assert dependency.get("required_files"), (
+ f"{m['repo_id']} → {rid}: dependency needs required_files"
+ )
+
+
def test_required_fields_present():
for m in _models():
for field in ("repo_id", "label", "role"):
diff --git a/tests/test_pyannote_install_2163.py b/tests/test_pyannote_install_2163.py
new file mode 100644
index 00000000..d415c656
--- /dev/null
+++ b/tests/test_pyannote_install_2163.py
@@ -0,0 +1,151 @@
+"""#2163: installing the pyannote diarisation pipeline end to end.
+
+The pipeline repo (`pyannote/speaker-diarization-3.1`) carries a `config.yaml`
+and no weights of its own — the real checkpoints live in the two repositories
+its catalogue entry declares as `dependencies`. That shape exercises three
+things at once, and the report hit all three:
+
+* the finished-snapshot validator must accept a weightless `config_only` repo
+ instead of rejecting it as a truncated download;
+* both dependency repositories must actually be fetched, or the install
+ "succeeds" with nothing that can run;
+* every download must carry the resolved HF bearer token **as a string**, since
+ the pipeline and segmentation repos are gated.
+
+This is the integration guard for the whole scenario; the token seam itself is
+unit-tested in ``test_gated_install_token_2163.py``.
+"""
+import asyncio
+import importlib
+import os
+from pathlib import Path
+
+os.environ.setdefault("OMNIVOICE_MODEL", "test")
+os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
+
+import pytest
+
+from services.token_resolver import ResolvedToken
+
+
+PIPELINE = "pyannote/speaker-diarization-3.1"
+SEGMENTATION = "pyannote/segmentation-3.0"
+EMBEDDING = "pyannote/wespeaker-voxceleb-resnet34-LM"
+
+_WEIGHT_BYTES = 6 * 1024 * 1024 # clears the 5 MB .bin floor in setup/models
+
+
+@pytest.fixture
+def download():
+ return importlib.import_module("api.routers.setup.download")
+
+
+def _install_pyannote(download, monkeypatch, tmp_path):
+ """Run POST /models/install for the pipeline repo with the Hub mocked.
+
+ Returns (snapshot_download kwargs per call, emitted SSE events).
+ """
+ import huggingface_hub
+ from services import hf_revisions, performance_profiles, token_resolver
+ from utils import hf_progress
+
+ monkeypatch.setattr(
+ token_resolver,
+ "resolve",
+ lambda *a, **k: ResolvedToken(
+ token="hf_gatedsecret", source="app", username="tester"
+ ),
+ )
+
+ calls: list[dict] = []
+
+ def fake_snapshot_download(**kwargs):
+ calls.append(kwargs)
+ if kwargs.get("dry_run"):
+ return []
+ repo_id = kwargs["repo_id"]
+ path = tmp_path / repo_id.replace("/", "__")
+ path.mkdir(parents=True, exist_ok=True)
+ # Mirror the real repos: the pipeline ships only a config, each
+ # dependency ships a config plus its checkpoint.
+ (path / "config.yaml").write_text("pipeline: ok\n", encoding="utf-8")
+ if repo_id != PIPELINE:
+ (path / "pytorch_model.bin").write_bytes(b"\0" * _WEIGHT_BYTES)
+ return str(path)
+
+ monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
+ monkeypatch.setattr(download, "compute_plan", lambda _plan: {
+ "total_bytes": 1, "cached_bytes": 0, "to_download_bytes": 1,
+ "n_files": 1, "n_cached": 0,
+ })
+ monkeypatch.setattr(download, "disk_space_error", lambda *_a, **_k: None)
+ # Force the snapshot_download path so this test covers the install flow;
+ # the segmented accelerator has its own unit tests.
+ monkeypatch.setattr(download, "_segmented_enabled", lambda: False)
+ monkeypatch.setattr(hf_revisions, "remember_revision", lambda *_a: None)
+ monkeypatch.setattr(performance_profiles, "reconcile_active_profile", lambda: None)
+
+ events: list[dict] = []
+ listener_id = hf_progress.register_listener(lambda ev: events.append(ev))
+
+ async def _run():
+ await download.install_model(download.InstallModelRequest(repo_id=PIPELINE))
+ pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
+ if pending:
+ await asyncio.gather(*pending)
+
+ try:
+ asyncio.run(_run())
+ finally:
+ hf_progress.unregister_listener(listener_id)
+ download._install_cooldowns.pop(PIPELINE, None)
+ download._install_failures.pop(PIPELINE, None)
+
+ return calls, events
+
+
+def test_pyannote_pipeline_install_completes(download, monkeypatch, tmp_path):
+ calls, events = _install_pyannote(download, monkeypatch, tmp_path)
+
+ phases = [e.get("phase") for e in events]
+ errors = [e for e in events if e.get("phase") == "install_error"]
+ assert not errors, f"install failed: {[e.get('error') for e in errors]}"
+ assert "install_done" in phases
+
+ # A weightless pipeline repo is a valid install, not a truncated download —
+ # the "no model weights were found in the snapshot" rejection in the report.
+ assert PIPELINE not in str(errors)
+
+
+def test_pyannote_install_fetches_both_dependency_repositories(
+ download, monkeypatch, tmp_path
+):
+ calls, _events = _install_pyannote(download, monkeypatch, tmp_path)
+
+ real = [c for c in calls if not c.get("dry_run")]
+ fetched = [c["repo_id"] for c in real]
+ assert fetched == [PIPELINE, SEGMENTATION, EMBEDDING], (
+ "the pipeline config alone is not a runnable install"
+ )
+
+ # Each dependency is filtered to the files its catalogue entry declares.
+ by_repo = {c["repo_id"]: c for c in real}
+ for dependency in (SEGMENTATION, EMBEDDING):
+ assert by_repo[dependency]["allow_patterns"] == [
+ "config.yaml",
+ "pytorch_model.bin",
+ ]
+ # The pipeline repo itself is unfiltered — it has no allow_patterns.
+ assert "allow_patterns" not in by_repo[PIPELINE]
+
+
+def test_every_pyannote_download_carries_the_bearer_string(
+ download, monkeypatch, tmp_path
+):
+ calls, _events = _install_pyannote(download, monkeypatch, tmp_path)
+
+ assert calls, "no download was attempted"
+ for call in calls:
+ token = call.get("token")
+ assert token == "hf_gatedsecret", f"{call['repo_id']} sent {token!r}"
+ assert isinstance(token, str)
diff --git a/tests/test_sidecar_install.py b/tests/test_sidecar_install.py
index 96f903a5..c6ff7298 100644
--- a/tests/test_sidecar_install.py
+++ b/tests/test_sidecar_install.py
@@ -620,6 +620,64 @@ def test_weights_step_downloads_via_endpoint_autoselect(monkeypatch):
assert si._weights_present(spec)
+def test_weights_download_sends_the_bearer_string_not_the_token_record(monkeypatch):
+ """#2163: `token_resolver.resolve()` returns a ResolvedToken record, but
+ snapshot_download takes `token: str | None` and silently ignores a non-str
+ — falling back to huggingface_hub's own ambient token discovery. So gated
+ engine weights 401 for a user whose token lives in VoiceStudio's Settings
+ rather than HF's cache. Every other weights test here stubs resolve() to
+ None, which is exactly why this went unnoticed."""
+ from services.token_resolver import ResolvedToken
+
+ spec = _mk_spec(weights_repo_id="Example/Gated")
+ seen = {}
+
+ def fake_snapshot_download(**kwargs):
+ seen.update(kwargs)
+ Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
+ (Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
+ (Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
+
+ import huggingface_hub
+ monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
+ monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
+ monkeypatch.setattr(
+ "services.token_resolver.resolve",
+ lambda: ResolvedToken(token="hf_gatedsecret", source="app", username="tester"),
+ )
+
+ job = si._new_job(spec.engine_id)
+ si._job_step(job, "fetch_weights")["state"] = "running"
+ si._step_fetch_weights(spec, job)
+
+ assert seen["token"] == "hf_gatedsecret"
+ assert isinstance(seen["token"], str)
+
+
+def test_weights_download_sends_no_token_when_none_resolves(monkeypatch):
+ # The other half of the contract: no token anywhere must reach
+ # snapshot_download as a real None, never the string "None".
+ spec = _mk_spec(weights_repo_id="Example/Open")
+ seen = {}
+
+ def fake_snapshot_download(**kwargs):
+ seen.update(kwargs)
+ Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
+ (Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
+ (Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
+
+ import huggingface_hub
+ monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
+ monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
+ monkeypatch.setattr("services.token_resolver.resolve", lambda: None)
+
+ job = si._new_job(spec.engine_id)
+ si._job_step(job, "fetch_weights")["state"] = "running"
+ si._step_fetch_weights(spec, job)
+
+ assert seen["token"] is None
+
+
def test_weights_revision_is_pinned_and_old_marker_forces_upgrade(monkeypatch):
spec = _mk_spec(
weights_repo_id="Example/Weights",
From 834b305c05c6bb9ce69ea04457efa005226af1f8 Mon Sep 17 00:00:00 2001
From: Palash Debnath <4178343+debpalash@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:11:38 +0530
Subject: [PATCH 03/11] fix(electron): validate reused Python runtimes before
startup
---
CHANGELOG.md | 2 ++
docs/electron-migration.md | 5 +++
electron/src/main/backend-setup.test.ts | 28 +++++++++++++++
electron/src/main/backend.ts | 29 +++++++++-------
.../src/main/runtime-dependencies.test.ts | 34 +++++++++++++++++++
electron/src/main/runtime-project.ts | 24 +++++++++++++
6 files changed, 110 insertions(+), 12 deletions(-)
create mode 100644 electron/src/main/runtime-dependencies.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3301b96e..06ae043c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
+- Validate Python dependencies before reusing a desktop runtime and offer setup for incomplete environments (#2176)
+
- Repair CTranslate2 loading safely across ASR and translation, and retain the loaded Whisper model during CPU fallback (#2165) — thanks @guruthechosen!
- Avoid pedalboard wheels that crash on unsupported CPU instructions (#2080) — thanks @D3nii!
- Include cuDNN 8 compatibility libraries for CTranslate2 in CUDA containers (#2072) — thanks @basil-k-aji-dev!
diff --git a/docs/electron-migration.md b/docs/electron-migration.md
index c2fcf035..72d9f64d 100644
--- a/docs/electron-migration.md
+++ b/docs/electron-migration.md
@@ -18,3 +18,8 @@ permissions. No automatic installer-to-installer migration is provided.
The final Tauri updater feeds retain signed Tauri payloads at immutable URLs.
A Tauri updater must never receive an Electron installer.
+
+Electron checks required Python imports before reusing an existing runtime. An
+incomplete environment opens setup instead of repeatedly crashing; installation
+still requires your explicit action. Automatic selection skips broken legacy
+runtimes and uses the Electron runtime location, leaving Tauri data intact.
diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts
index 7085da1c..0e8a0887 100644
--- a/electron/src/main/backend-setup.test.ts
+++ b/electron/src/main/backend-setup.test.ts
@@ -2,6 +2,7 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
const mocks = vi.hoisted(() => ({
+ dependencies: vi.fn(async () => true),
ready: vi.fn(async () => false),
compatible: vi.fn(async () => false),
interrupted: vi.fn(async () => false),
@@ -15,6 +16,7 @@ vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/v
vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() }));
vi.mock('node:fs/promises', () => ({ rm: mocks.rm }));
vi.mock('./runtime-project', () => ({
+ runtimeDependenciesReady: mocks.dependencies,
runtimeReady: mocks.ready,
runtimeCompatible: mocks.compatible,
runtimeInstallInterrupted: mocks.interrupted,
@@ -34,6 +36,9 @@ afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.clearAllMocks();
+ mocks.dependencies.mockResolvedValue(true);
+ mocks.ready.mockResolvedValue(false);
+ mocks.compatible.mockResolvedValue(false);
mocks.interrupted.mockResolvedValue(false);
mocks.promoteCaches.mockResolvedValue(undefined);
});
@@ -364,3 +369,26 @@ it('reserves setup before asynchronous checks and cancels stale preflight', asyn
expect(mocks.install).not.toHaveBeenCalled();
expect(supervisor.status.stage).toBe('idle');
});
+
+it.each(['ready', 'compatible'] as const)(
+ 'offers setup instead of spawning a %s runtime with missing dependencies',
+ async (kind) => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('no backend');
+ }),
+ );
+ vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
+ vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
+ mocks[kind].mockResolvedValue(true);
+ mocks.dependencies.mockResolvedValue(false);
+ const supervisor = new BackendSupervisor();
+ await supervisor.start();
+ expect(supervisor.status.stage).toBe('setup_required');
+ expect(mocks.spawn).not.toHaveBeenCalled();
+ expect(mocks.install).not.toHaveBeenCalled();
+ expect(mocks.stage).not.toHaveBeenCalled();
+ await supervisor.shutdown();
+ },
+);
diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts
index 74506f06..e6babe95 100644
--- a/electron/src/main/backend.ts
+++ b/electron/src/main/backend.ts
@@ -2,6 +2,7 @@ import {
installRuntime,
promoteLegacyRuntimeCaches,
runtimeCompatible,
+ runtimeDependenciesReady,
runtimeInstallInterrupted,
runtimeReady,
runtimePython,
@@ -482,11 +483,8 @@ export class BackendSupervisor extends EventEmitter<{
}
if (app.isPackaged && !parseBackendCmdOverride(process.env.OMNIVOICE_BACKEND_CMD)) {
- const project = await this.resolveRuntimeProject();
- if (
- !(await runtimeReady(backendRoot(), project)) &&
- !(await runtimeCompatible(backendRoot(), project))
- ) {
+ const { project, ready } = await this.resolveRuntimeProject();
+ if (!ready) {
if (gen === this.generation) {
this.runtimeInterrupted = await runtimeInstallInterrupted(project);
this.setStage('setup_required');
@@ -537,8 +535,9 @@ export class BackendSupervisor extends EventEmitter<{
this.setStage('installing', { message: undefined });
try {
const reusable =
- (await runtimeReady(backendRoot(), project)) ||
- (await runtimeCompatible(backendRoot(), project));
+ ((await runtimeReady(backendRoot(), project)) ||
+ (await runtimeCompatible(backendRoot(), project))) &&
+ (await runtimeDependenciesReady(project));
if (gen !== this.generation || controller.signal.aborted) return;
if (reusable) {
await this.start();
@@ -797,7 +796,7 @@ export class BackendSupervisor extends EventEmitter<{
this.emitStatus();
}
- private async resolveRuntimeProject(): Promise {
+ private async resolveRuntimeProject(): Promise<{ project: string; ready: boolean }> {
const bundle = backendRoot();
const own = join(defaultRuntimeRoot(), 'project');
const configuredRoot = storedRuntimeRoot();
@@ -809,15 +808,18 @@ export class BackendSupervisor extends EventEmitter<{
...legacyTauriRuntimeProjects(),
].filter((candidate): candidate is string => Boolean(candidate));
for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) {
- if ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) {
+ if (
+ ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) &&
+ (await runtimeDependenciesReady(project))
+ ) {
this.runtimeProject = project;
if (project !== own && project !== configured)
this.pushLog('out', `Reusing compatible Tauri runtime: ${project}`);
- return project;
+ return { project, ready: true };
}
}
this.runtimeProject = configured ?? own;
- return this.runtimeProject;
+ return { project: this.runtimeProject, ready: false };
}
private emitStatus(): void {
@@ -889,7 +891,10 @@ export class BackendSupervisor extends EventEmitter<{
// Python passes this child-side descriptor to every nested operation.
// Reading the parent side keeps the ownership channel live and lets
// Node observe EOF only after the complete backend subtree releases it.
- const drain = child.stdio?.[processOptions.drainFd] as NodeJS.ReadableStream | null | undefined;
+ const drain = child.stdio?.[processOptions.drainFd] as
+ | NodeJS.ReadableStream
+ | null
+ | undefined;
drain?.on('error', (error: unknown) => {
if (!isExpectedPipeClose(error)) {
this.pushLog('err', `Backend drain stream failed: ${errorMessage(error)}`);
diff --git a/electron/src/main/runtime-dependencies.test.ts b/electron/src/main/runtime-dependencies.test.ts
new file mode 100644
index 00000000..7783fc8a
--- /dev/null
+++ b/electron/src/main/runtime-dependencies.test.ts
@@ -0,0 +1,34 @@
+// @vitest-environment node
+import { afterEach, expect, it, vi } from 'vitest';
+import { execFile } from 'node:child_process';
+import { runtimeDependenciesReady, runtimePython } from './runtime-project';
+vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
+afterEach(() => vi.clearAllMocks());
+it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new Error('ENOENT')])(
+ 'validates imports using the selected interpreter and fails closed (%s)',
+ async (error) => {
+ vi.mocked(execFile).mockImplementation(((
+ _command: unknown,
+ _args: unknown,
+ _options: unknown,
+ callback: (error: Error | null) => void,
+ ) => callback(error)) as never);
+ const project = '/runtime with spaces';
+ expect(await runtimeDependenciesReady(project)).toBe(error === null);
+ expect(execFile).toHaveBeenCalledWith(
+ runtimePython(project),
+ ['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'],
+ expect.objectContaining({
+ cwd: project,
+ timeout: 30_000,
+ windowsHide: true,
+ env: expect.objectContaining({
+ HF_HUB_OFFLINE: '1',
+ TRANSFORMERS_OFFLINE: '1',
+ PYTHONNOUSERSITE: '1',
+ }),
+ }),
+ expect.any(Function),
+ );
+ },
+);
diff --git a/electron/src/main/runtime-project.ts b/electron/src/main/runtime-project.ts
index eceeaa08..6c21f565 100644
--- a/electron/src/main/runtime-project.ts
+++ b/electron/src/main/runtime-project.ts
@@ -1,3 +1,4 @@
+import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import {
cp,
@@ -247,6 +248,29 @@ export async function runtimeInstallInterrupted(project: string): Promise {
+ return new Promise((resolve) => {
+ execFile(
+ runtimePython(project),
+ ['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'],
+ {
+ cwd: project,
+ windowsHide: true,
+ timeout: 30_000,
+ maxBuffer: 256 * 1024,
+ env: {
+ ...process.env,
+ HF_HUB_OFFLINE: '1',
+ TRANSFORMERS_OFFLINE: '1',
+ PYTHONNOUSERSITE: '1',
+ },
+ },
+ (error) => resolve(!error),
+ );
+ });
+}
+
export async function runtimeReady(bundle: string, project: string): Promise {
if (await runtimeIncomplete(project)) return false;
try {
From af8810be5394296a1eac143324017ab05646ecad Mon Sep 17 00:00:00 2001
From: Palash Debnath <4178343+debpalash@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:13:18 +0530
Subject: [PATCH 04/11] fix(electron): offer retry when engine capability
lookup fails
---
.../src/features/tools/convert-voice.test.tsx | 20 ++++++++++++++++++-
.../src/features/tools/convert-voice.tsx | 8 +++++++-
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/electron/src/renderer/src/features/tools/convert-voice.test.tsx b/electron/src/renderer/src/features/tools/convert-voice.test.tsx
index 6658e8df..9587a4b8 100644
--- a/electron/src/renderer/src/features/tools/convert-voice.test.tsx
+++ b/electron/src/renderer/src/features/tools/convert-voice.test.tsx
@@ -2,9 +2,13 @@ import { clearConversion } from './conversion-state';
import { cleanup, fireEvent, render, screen, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { afterEach, expect, it, vi } from 'vitest';
-const mock = vi.hoisted(() => ({ convert: vi.fn(), ready: true, cloning: true as boolean | null }));
+const mock = vi.hoisted(() => ({ queryError: false, retry: vi.fn(), convert: vi.fn(), ready: true, cloning: true as boolean | null }));
vi.mock('@/hooks/use-engines', () => ({
useEngines: () => ({
+ isError: mock.queryError,
+ error: new Error("Engine query failed"),
+ retry: mock.retry,
+ data: mock.queryError ? undefined : {},
activeTtsReady: mock.ready,
activeTts: { supports_cloning: mock.cloning },
}),
@@ -31,6 +35,7 @@ import { ConvertVoice } from './convert-voice';
afterEach(() => {
cleanup();
clearConversion();
+ mock.queryError = false;
mock.ready = true;
mock.cloning = true;
vi.clearAllMocks();
@@ -124,3 +129,16 @@ it('rechecks engine capability when returning from settings without losing input
expect(screen.getByRole('button', { name: 'convert.convert' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'source.wav' })).toBeInTheDocument();
});
+
+it('offers retry instead of model guidance when engine lookup fails', () => {
+ mock.queryError = true;
+ mock.ready = false;
+ const { upload } = mount();
+ upload();
+ fireEvent.click(screen.getByRole('button', { name: 'Alpha' }));
+ expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled();
+ expect(screen.queryByText('convert.cloning_required')).not.toBeInTheDocument();
+ expect(screen.getByText('Engine query failed')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
+ expect(mock.retry).toHaveBeenCalledOnce();
+});
diff --git a/electron/src/renderer/src/features/tools/convert-voice.tsx b/electron/src/renderer/src/features/tools/convert-voice.tsx
index fe2b5f6d..cf58ed6b 100644
--- a/electron/src/renderer/src/features/tools/convert-voice.tsx
+++ b/electron/src/renderer/src/features/tools/convert-voice.tsx
@@ -213,7 +213,13 @@ export function ConvertVoice() {