Files
VoiceStudio/tests/test_install_disk_space.py
T
e2c4ea93b0 fix(models-settings): surface async install errors, disk-space guard, cancel wiring, honest restart (#908)
Live-audit fixes for the Models settings surface — the P1s were cases where
the feature silently didn't work for the user.

P1-A — Async install errors were invisible. The `install_error` SSE event
carries excellent mirror-aware text (#890 core/failure.py), but the Model
Store auto-purged the errored row ~800ms later (same as a success) and the
first-run WizardLibrary DELETED the row without ever reading `ev.error`. The
SSE→rowState reduction is now a pure, tested reducer (downloadReducer.js /
reduceWizardDownloadEvent); only SUCCESS terminals auto-purge
(isAutoPurgeTerminal), an error persists on the row with inline text + Retry +
Dismiss (Model Store) / a Retry (wizard).

P1-B — No disk-space check on install. `POST /models/install` now compares the
FDL-05 plan's exact `to_download_bytes` (+ MIN_FREE_GB headroom) against
`shutil.disk_usage(cache).free` BEFORE downloading and emits an actionable
install_error naming the sizes (needs X, headroom Y, have Z) instead of failing
mid-download. `/models` also surfaces `disk_free_gb` in the header. MIN_FREE_GB
+ disk_free_bytes are single-sourced in setup/models.py (wizard delegates).

P2-A — Wired the orphaned cancel. `POST /models/install/cancel` (FDL-11) had
zero frontend refs; the in-progress row now shows a Cancel button that calls it
and transitions the row to install_cancelled.

P2-B — Honest restart_required. The HF-mirror PUT returned restart_required:true
unconditionally; it now returns true only when the persisted value actually
changed, with accurate copy (Model Store downloads use the new mirror
immediately — resolved per-call; only transformers model loads need a restart).

P3 — i18n the un-localized panels (HFMirrorPanel, ApiKeysPanel source
labels/help/status, MODEL_ROLE_LABEL) via new en.json keys; other locales fall
back to en.

Tests: new tests/test_install_disk_space.py (reject-when-over-budget incl. the
worker wiring; allow-when-fits; degrade on unknown size/unprobeable volume),
updated tests/test_hf_mirror_settings.py (change-only restart_required), and new
frontend reducer + column-render tests for install_error persistence, Retry,
Dismiss, and Cancel.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:40:15 +05:30

121 lines
5.1 KiB
Python

"""Pre-install disk-space guard (Model Store "Install all" overrun).
`POST /models/install` computes an exact ``to_download_bytes`` in the FDL-05
preflight plan; before this guard it never compared that against free space, so
an "Install all" could fill the disk with no warning and fail mid-download with
a cryptic OSError. ``disk_space_error`` is the single decision point — it names
the three numbers a user needs (needs X, headroom Y, have Z) and returns None
when it fits / the size is unknown / the volume can't be probed. Platform-
agnostic: ``shutil.disk_usage`` works identically on macOS/Windows/Linux.
"""
import os
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import importlib
import pytest
@pytest.fixture
def models_mod():
return importlib.import_module("api.routers.setup.models")
_GIB = 1024 ** 3
def test_rejects_when_download_exceeds_free_space(models_mod, monkeypatch):
# 50 GB to download, only 20 GB free → rejected, and the message must name
# both the download size and the free size so the user can act.
monkeypatch.setattr(models_mod, "disk_free_bytes", lambda *a, **k: 20 * _GIB)
msg = models_mod.disk_space_error(50 * _GIB, cache_dir="/fake/cache")
assert msg is not None
assert "50.0 GB" in msg # needs X
assert "20.0 GB" in msg # have Z
assert f"{models_mod.MIN_FREE_GB} GB" in msg # headroom named
assert "/fake/cache" in msg # where
def test_rejects_when_download_plus_headroom_exceeds_free(models_mod, monkeypatch):
# Fits the raw bytes but NOT the MIN_FREE_GB headroom on top → still rejected,
# so "Install all" can't fill the disk to the brim.
monkeypatch.setattr(models_mod, "disk_free_bytes", lambda *a, **k: 12 * _GIB)
msg = models_mod.disk_space_error(5 * _GIB) # 5 + 10 headroom = 15 > 12 free
assert msg is not None
assert "5.0 GB" in msg
def test_allows_when_enough_space(models_mod, monkeypatch):
# 5 GB download + 10 GB headroom = 15 GB required, 100 GB free → proceeds.
monkeypatch.setattr(models_mod, "disk_free_bytes", lambda *a, **k: 100 * _GIB)
assert models_mod.disk_space_error(5 * _GIB) is None
def test_does_not_block_on_unknown_size(models_mod, monkeypatch):
# Older/gated repo or a mirror without dry-run → no plan → never block.
monkeypatch.setattr(models_mod, "disk_free_bytes", lambda *a, **k: 1 * _GIB)
assert models_mod.disk_space_error(None) is None
assert models_mod.disk_space_error(0) is None
def test_does_not_block_when_volume_unprobeable(models_mod, monkeypatch):
# disk_free_bytes returns 0 on any probe failure → don't block on missing info.
monkeypatch.setattr(models_mod, "disk_free_bytes", lambda *a, **k: 0)
assert models_mod.disk_space_error(999 * _GIB) is None
def test_install_worker_emits_install_error_and_skips_download(models_mod, monkeypatch):
"""Wiring guard: when the plan won't fit, the install worker emits an
``install_error`` SSE event (carrying the sizes) and never calls
snapshot_download for real — the reject happens BEFORE any byte flows."""
download = importlib.import_module("api.routers.setup.download")
from utils import hf_progress
# Force a known, over-budget plan and a rejection.
monkeypatch.setattr(
download, "compute_plan",
lambda plan_files: {
"total_bytes": 50 * _GIB, "cached_bytes": 0,
"to_download_bytes": 50 * _GIB, "n_files": 3, "n_cached": 0,
},
)
monkeypatch.setattr(download, "disk_space_error", lambda *a, **k: "Not enough disk space to install: needs 50.0 GB")
events: list[dict] = []
lid = hf_progress.register_listener(lambda ev: events.append(ev))
# snapshot_download: the dry-run preflight returns a dummy; a REAL download
# call must never happen (the guard returns first). Fail loudly if it does.
import huggingface_hub
def _fake_snapshot(**kwargs):
if kwargs.get("dry_run"):
return [] # preflight plan input (compute_plan is stubbed anyway)
raise AssertionError("snapshot_download called for a real download despite disk-full reject")
monkeypatch.setattr(huggingface_hub, "snapshot_download", _fake_snapshot)
repo_id = download.KNOWN_MODELS[0]["repo_id"]
import asyncio
async def _run():
await download.install_model(download.InstallModelRequest(repo_id=repo_id))
# install_model schedules the worker via loop.create_task(to_thread(_do));
# drain the pending task so the worker runs to completion in-loop.
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(lid)
errs = [e for e in events if e.get("phase") == "install_error"]
assert errs, f"expected an install_error event, got phases: {[e.get('phase') for e in events]}"
assert "disk space" in errs[0]["error"].lower()
# And the resolving heartbeat must not have leaked — no infinite 'resolving'
# stream after the bail (the worker set the stop event before returning).