fix(engines): engine installs never inherit VoiceStudio's own uv config

The backend runs inside the app's tree, and the uv processes it starts for
engine installs had no working directory of their own. uv therefore
discovered VoiceStudio's pyproject.toml and applied its [tool.uv]
constraint-dependencies, torch==2.8.0 among them, to each engine's venv.

Resolved that way, MOSS-TTS-v1.5 (torch==2.9.1+cu128) and Confucius4
(torch==2.7.0) are unsatisfiable, so their one-click installs and
bootstraps could never succeed. An engine that pins no torch got the app's
instead of its own, so its venv was not really independent.

uv_subprocess_env, which every one-click install step and every engine
bootstrap already uses, now always sets UV_NO_CONFIG=1. It used to return
None in two cases and let the call inherit the environment; it now always
returns a copy. Region and custom mirrors still apply, because they reach
uv as UV_INDEX_URL. The app's own `uv sync` and the translation installer,
which install into the app's environment on purpose, are unchanged.
This commit is contained in:
Palash Debnath
2026-09-10 08:46:49 -07:00
parent 5298c8e5e1
commit 1af1ecc5bd
3 changed files with 98 additions and 18 deletions
+1
View File
@@ -9,6 +9,7 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#NOCFGPR)
- MOSS-TTS-v1.5, Confucius4-TTS, dots.tts, Supertonic-3 and PocketTTS install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015, #2016)
- A pronunciation entry that is stored but not applied yet says so, instead of looking like it did not match (#1949)
- A bare 500 report now names the backend error class, so two unrelated faults stop filing the same issue (#1773)
+16 -11
View File
@@ -549,13 +549,20 @@ def _default_uv_cache_root() -> Path:
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "uv"
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str]":
"""Environment for ``uv`` subprocesses that install into *cache_parent*'s volume.
Returns ``None`` (inherit the parent environment untouched) when uv's
default cache already shares a volume with *cache_parent* or the user
pinned both variables themselves. Otherwise returns a copy of
``os.environ`` with the *unset* one(s) of ``UV_CACHE_DIR`` /
Always a copy of ``os.environ`` with ``UV_NO_CONFIG=1``: an engine's
install resolves its own requirements, never VoiceStudio's. The backend
runs inside the app's tree, so uv would otherwise discover the app's
``pyproject.toml`` and apply its ``[tool.uv] constraint-dependencies``
(``torch==2.8.0``) to the engine's venv. An engine pinning another torch
(MOSS-TTS-v1.5, Confucius4) could then never resolve, and one that pins
none got the app's torch instead of its own. Mirrors still apply: they
arrive as ``UV_INDEX_URL``, an environment variable, not a config file.
When uv's default cache is on another volume than *cache_parent*, the
copy also places the *unset* one(s) of ``UV_CACHE_DIR`` /
``UV_PYTHON_INSTALL_DIR`` placed inside *cache_parent*, so downloads, the
unpacked wheel cache, managed Pythons, and the venv all stay on the
target volume — and same-volume hardlink installs work again. The two
@@ -568,17 +575,15 @@ def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
pass the directory that should hold the shared ``.uv-cache`` — typically
the common parent of the engine venvs on that volume.
"""
if _same_volume(cache_parent, _default_uv_cache_root()):
return None
env = dict(os.environ)
overrode = False
env["UV_NO_CONFIG"] = "1"
if _same_volume(cache_parent, _default_uv_cache_root()):
return env
if not env.get("UV_CACHE_DIR"): # explicit user choice always wins
env["UV_CACHE_DIR"] = str(Path(cache_parent) / ".uv-cache")
overrode = True
if not env.get("UV_PYTHON_INSTALL_DIR"):
env["UV_PYTHON_INSTALL_DIR"] = str(Path(cache_parent) / ".uv-python")
overrode = True
return env if overrode else None
return env
# ── Disk preflight ─────────────────────────────────────────────────────────
+81 -7
View File
@@ -73,12 +73,13 @@ def test_same_volume_false_across_devices(tmp_path, monkeypatch):
# ── uv_subprocess_env ──────────────────────────────────────────────────────
def test_uv_env_is_none_on_the_default_cache_volume(tmp_path, monkeypatch):
"""Same volume as uv's default cache → inherit env untouched (default
installs stay byte-identical)."""
def test_uv_env_moves_no_cache_on_the_default_cache_volume(tmp_path, monkeypatch):
"""Same volume as uv's default cache → the cache stays where uv puts it."""
monkeypatch.delenv("UV_CACHE_DIR", raising=False)
monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False)
monkeypatch.setattr(si, "_default_uv_cache_root", lambda: tmp_path / "uv")
assert si.uv_subprocess_env(tmp_path / "engines") is None
env = si.uv_subprocess_env(tmp_path / "engines")
assert "UV_CACHE_DIR" not in env and "UV_PYTHON_INSTALL_DIR" not in env
def test_uv_env_colocates_cache_on_a_foreign_volume(tmp_path, monkeypatch):
@@ -111,12 +112,14 @@ def test_uv_env_respects_user_pinned_cache_dir(tmp_path, monkeypatch):
assert env["UV_PYTHON_INSTALL_DIR"] == str(tmp_path / "engines" / ".uv-python")
def test_uv_env_is_none_when_both_vars_pinned(tmp_path, monkeypatch):
"""Both pinned → nothing left to override → inherit env untouched."""
def test_uv_env_keeps_both_vars_when_both_pinned(tmp_path, monkeypatch):
"""Both pinned → the user's values stand."""
monkeypatch.setenv("UV_CACHE_DIR", str(tmp_path / "my-cache"))
monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", str(tmp_path / "my-pythons"))
monkeypatch.setattr(si, "_same_volume", lambda a, b: False)
assert si.uv_subprocess_env(tmp_path / "engines") is None
env = si.uv_subprocess_env(tmp_path / "engines")
assert env["UV_CACHE_DIR"] == str(tmp_path / "my-cache")
assert env["UV_PYTHON_INSTALL_DIR"] == str(tmp_path / "my-pythons")
def test_uv_env_respects_user_pinned_python_dir(tmp_path, monkeypatch):
@@ -244,3 +247,74 @@ def test_every_bootstrap_uv_call_passes_env(path):
"subprocess calls in _bootstrap_engines_venv without env= "
f"(cross-drive uv cache class): {offenders}"
)
# ── Engine installs never inherit the app's uv config ─────────────────────
#
# The backend runs inside VoiceStudio's tree, so a uv process it starts
# discovers the app's pyproject.toml and applies its [tool.uv]
# constraint-dependencies (torch==2.8.0). Resolved that way,
# torch==2.9.1+cu128 (MOSS-TTS-v1.5) and torch==2.7.0 (Confucius4) are
# unsatisfiable. Every engine install and bootstrap gets its environment from
# uv_subprocess_env; these pin that it always opts out of config discovery.
@pytest.mark.parametrize("same_volume", [True, False])
@pytest.mark.parametrize("pinned", [(), ("UV_CACHE_DIR",), ("UV_CACHE_DIR", "UV_PYTHON_INSTALL_DIR")])
def test_uv_env_always_ignores_the_apps_uv_config(tmp_path, monkeypatch, same_volume, pinned):
for var in ("UV_CACHE_DIR", "UV_PYTHON_INSTALL_DIR"):
if var in pinned:
monkeypatch.setenv(var, str(tmp_path / var.lower()))
else:
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(si, "_same_volume", lambda a, b: same_volume)
env = si.uv_subprocess_env(tmp_path / "engines")
assert env["UV_NO_CONFIG"] == "1"
def test_uv_env_keeps_the_mirror_setting(tmp_path, monkeypatch):
"""Region and custom mirrors reach uv as UV_INDEX_URL, which config opt-out
leaves alone."""
monkeypatch.setenv("UV_INDEX_URL", "https://mirror.example/simple")
env = si.uv_subprocess_env(tmp_path / "engines")
assert env["UV_INDEX_URL"] == "https://mirror.example/simple"
@pytest.mark.parametrize(
"module",
[
"engines.indextts.bootstrap",
"engines.moss_tts_v15.bootstrap",
"engines.confucius4.bootstrap",
"engines.dots_tts.bootstrap",
],
)
def test_every_engine_bootstrap_ignores_the_apps_uv_config(module):
import importlib
env = importlib.import_module(module)._uv_env()
assert env is not None and env["UV_NO_CONFIG"] == "1", module
def test_one_click_install_steps_ignore_the_apps_uv_config(tmp_path, monkeypatch):
envs = []
def capture(job, argv, *, timeout, env=None):
envs.append((argv[1], env))
if argv[1] == "venv":
py = si._venv_python(Path(argv[2]))
py.parent.mkdir(parents=True, exist_ok=True)
py.write_text("#!fake\n")
return 0
monkeypatch.setattr(si, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "_run_logged", capture)
spec = si.get_spec("indextts2")
si.managed_checkout(spec).mkdir(parents=True)
job = si._new_job(spec.engine_id)
si._step_create_venv(spec, job)
si._step_install_deps(spec, job)
assert [step for step, _ in envs] == ["venv", "pip"]
for step, env in envs:
assert env is not None and env["UV_NO_CONFIG"] == "1", step