167 lines
6.2 KiB
Python
167 lines
6.2 KiB
Python
"""Runtime install progress: the slow legs must tick, never hang silently.
|
|
|
|
The incident: 'Installing runtime…' sat frozen for minutes on a slow
|
|
line — ensure_runtime_installed downloaded and extracted with no
|
|
progress stream, so both the quickstart hero and the pane's install row
|
|
showed a dead bar. Contract: _download and _extract tick per chunk /
|
|
per member, ensure_runtime_installed forwards a staged stream, and the
|
|
router's hook translates it into live job fields."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import hermes_cli.local_runtime.binaries as binaries
|
|
from hermes_cli.web_routers.local_models import _job, _runtime_progress_hook
|
|
|
|
|
|
def _make_zip(path: Path, names_sizes: dict[str, int]) -> None:
|
|
with zipfile.ZipFile(path, "w") as z:
|
|
for name, size in names_sizes.items():
|
|
z.writestr(name, b"x" * size)
|
|
|
|
|
|
def test_extract_ticks_per_member(tmp_path):
|
|
archive = tmp_path / "runtime.zip"
|
|
_make_zip(archive, {"a.bin": 1000, "b.bin": 3000, "c.bin": 500})
|
|
ticks: list[tuple[int, int]] = []
|
|
binaries._extract(archive, tmp_path / "out",
|
|
progress=lambda d, t: ticks.append((d, t)))
|
|
assert len(ticks) == 3
|
|
total = 4500
|
|
assert all(t == total for _, t in ticks)
|
|
assert [d for d, _ in ticks] == sorted(d for d, _ in ticks)
|
|
assert ticks[-1][0] == total
|
|
assert (tmp_path / "out" / "b.bin").stat().st_size == 3000
|
|
|
|
|
|
def test_download_ticks_with_content_length(tmp_path, monkeypatch):
|
|
payload = b"y" * (3 << 20) # 3 MiB -> several 1 MiB chunks
|
|
|
|
class _Resp(io.BytesIO):
|
|
headers = {"Content-Length": str(len(payload))}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
monkeypatch.setattr(binaries.urllib.request, "urlopen",
|
|
lambda url, timeout=0: _Resp(payload))
|
|
ticks: list[tuple[int, int]] = []
|
|
dest = tmp_path / "asset.zip"
|
|
binaries._download("http://x/asset.zip", dest,
|
|
progress=lambda d, t: ticks.append((d, t)))
|
|
assert dest.read_bytes() == payload
|
|
assert len(ticks) >= 3
|
|
assert ticks[-1] == (len(payload), len(payload))
|
|
|
|
|
|
def test_ensure_runtime_installed_forwards_staged_progress(tmp_path, monkeypatch):
|
|
"""The full install path emits download -> verify -> extract stages
|
|
(per asset) and a final verify, all through one callback."""
|
|
monkeypatch.setattr(binaries, "runtimes_root", lambda: tmp_path)
|
|
|
|
class _Plan:
|
|
assets = ["a.zip", "b.zip"]
|
|
backend = "cuda"
|
|
install_dir = tmp_path / "b1" / "cuda"
|
|
|
|
monkeypatch.setattr(binaries, "resolve_assets", lambda tag, backend: _Plan())
|
|
monkeypatch.setattr(binaries, "verify_install", lambda d, t: "ok")
|
|
|
|
def _fake_download(url, dest, progress=None):
|
|
_make_zip(dest, {"f.bin": 2048})
|
|
if progress is not None:
|
|
progress(1024, 2048)
|
|
progress(2048, 2048)
|
|
|
|
monkeypatch.setattr(binaries, "_download", _fake_download)
|
|
|
|
events: list[tuple[str, str]] = []
|
|
binaries.ensure_runtime_installed(
|
|
"b1", "cuda",
|
|
progress=lambda stage, d, t, label: events.append((stage, label)))
|
|
|
|
stages = [s for s, _ in events]
|
|
assert "download" in stages and "extract" in stages and "verify" in stages
|
|
# Two assets -> per-asset labels on the slow stages.
|
|
assert ("download", "1/2") in events and ("download", "2/2") in events
|
|
assert ("extract", "1/2") in events and ("extract", "2/2") in events
|
|
# Stage order per asset: download before extract.
|
|
assert stages.index("download") < stages.index("extract")
|
|
|
|
|
|
def test_progress_hook_translates_stages_to_job_fields():
|
|
job = _job("quickstart", "Test Model")
|
|
hook = _runtime_progress_hook(job)
|
|
|
|
hook("download", 5 << 20, 100 << 20, "1/2")
|
|
assert job["phase"] == "downloading-runtime"
|
|
assert "1/2" in job["detail"]
|
|
assert job["done_bytes"] == 5 << 20
|
|
assert job["total_bytes"] == 100 << 20
|
|
|
|
# Rapid second tick inside the throttle window is dropped...
|
|
hook("download", 6 << 20, 100 << 20, "1/2")
|
|
assert job["done_bytes"] == 5 << 20
|
|
# ...but a terminal tick (done == total) always lands.
|
|
hook("download", 100 << 20, 100 << 20, "1/2")
|
|
assert job["done_bytes"] == 100 << 20
|
|
|
|
hook("extract", 10, 100, "")
|
|
assert job["phase"] in ("downloading-runtime", "unpacking-runtime")
|
|
|
|
job2 = _job("runtime-install", "x")
|
|
hook2 = _runtime_progress_hook(job2)
|
|
hook2("extract", 100, 100, "")
|
|
assert job2["phase"] == "unpacking-runtime"
|
|
hook2("verify", 0, 0, "")
|
|
assert job2["phase"] == "verifying-runtime"
|
|
assert job2["total_bytes"] is None # indeterminate bar, not a stuck 0%
|
|
|
|
|
|
def test_progress_hook_accumulates_across_assets(monkeypatch):
|
|
"""A two-asset engine reads as ONE growing download: the second asset's
|
|
bytes stack on the first's instead of restarting the bar at zero, and
|
|
unpack/verify leave the finished download's counters standing."""
|
|
# Drive the throttle's clock so every tick lands (the real hook drops
|
|
# sub-250ms non-terminal ticks; this test is about arithmetic, not
|
|
# pacing — pacing has its own assertions above).
|
|
from hermes_cli.web_routers import local_models as lm
|
|
|
|
clock = {"now": 0.0}
|
|
|
|
def fake_monotonic():
|
|
clock["now"] += 1.0
|
|
return clock["now"]
|
|
|
|
monkeypatch.setattr(lm.time, "monotonic", fake_monotonic)
|
|
|
|
job = _job("runtime-install", "engine")
|
|
hook = _runtime_progress_hook(job)
|
|
|
|
hook("download", 40 << 20, 40 << 20, "1/2")
|
|
assert job["done_bytes"] == 40 << 20
|
|
assert job["total_bytes"] == 40 << 20
|
|
|
|
# Second asset starts: counters continue from the first asset's total.
|
|
hook("download", 0, 60 << 20, "2/2")
|
|
assert job["done_bytes"] == 40 << 20
|
|
assert job["total_bytes"] == 100 << 20
|
|
|
|
hook("download", 60 << 20, 60 << 20, "2/2")
|
|
assert job["done_bytes"] == 100 << 20
|
|
assert job["total_bytes"] == 100 << 20
|
|
|
|
# Unpack and verify narrate without rewinding the finished bar.
|
|
hook("extract", 1, 100, "2/2")
|
|
assert job["phase"] == "unpacking-runtime"
|
|
assert job["done_bytes"] == 100 << 20
|
|
hook("verify", 0, 0, "")
|
|
assert job["phase"] == "verifying-runtime"
|
|
assert job["done_bytes"] == 100 << 20
|