* Studio: add shared Xet-primary download helper with HTTP stall fallback Xet is the fast default transport in huggingface_hub, but a stalled Xet transfer hangs with no progress and no exception, and a blocked native thread cannot be killed. The safetensors inference path already recovers (subprocess watchdog + respawn with HF_HUB_DISABLE_XET=1); the GGUF and training paths do not. Add a reusable helper that the in-process paths can adopt. utils/hf_xet_fallback.py: - DownloadStallError (moved here from core/inference/orchestrator.py, which now imports it; behavior unchanged, still a RuntimeError subclass). - get_hf_download_state / start_watchdog: a no-progress watchdog built on the sparse-aware hub.utils.hf_cache_state helpers; fires only while a .incomplete is present and the on-disk byte total is unchanged for stall_timeout. - hf_hub_download_with_xet_fallback: cached files short-circuit; otherwise the download runs in a spawn child (own process group) supervised by the watchdog. On a stall it kills the child, makes the partial safe for HTTP via prepare_cache_for_transport, and respawns once with HF_HUB_DISABLE_XET=1. Cancel and deterministic errors (auth/missing/disk) propagate without a fallback. Tests cover the watchdog state machine, the transport decision logic, and a regression lock that HF_HUB_DISABLE_XET is honored in a fresh interpreter. * Studio: route GGUF Chat-Mode downloads through the Xet->HTTP fallback The GGUF load path (_download_gguf main+shards, _download_companion_gguf for mmproj/MTP) called a bare blocking hf_hub_download with no recovery, so a Xet stall hung the Chat-Mode load with no fallback. Route those three calls through hf_hub_download_with_xet_fallback: Xet stays primary, HTTP is used only if Xet stalls, per-file so finished shards stay cached. The existing _cancel_event is threaded through, the Cancelled sentinel is preserved, and companions stay best-effort (a terminal stall is swallowed to None). Cached files short-circuit in the helper with no subprocess, so the fast path is unchanged. The two offline mmproj tests are repointed from huggingface_hub.hf_hub_download to the new call boundary (the helper) since the download now goes through it. * Studio: recover a stalled training model-load via Xet->HTTP respawn Training runs in a spawn subprocess and FastModel.from_pretrained downloads internally, so the download cannot be wrapped per-file like GGUF. Instead the worker now watches the HF cache during the model-load phase (emitting model_load_started / model_load_completed and a stall event), and the parent recovers a stall by terminating the worker and respawning it once with HF_HUB_DISABLE_XET=1. worker.py: set HF_HUB_DISABLE_XET=1 before any HF import when the parent passes disable_xet (respawn), and wrap trainer.load_model with start_watchdog. training.py: plumb disable_xet through the config; track the model-load window; on a first-load stall arm a one-shot respawn (handled on the exiting pump thread, so no pump self-join) that preserves the DB run row (history is not duplicated) and re-runs the load over HTTP. A second stall, or a stall outside model-load, surfaces as a normal error. W&B init happens after model-load, so a pre-load respawn cannot duplicate it; the dataset is re-formatted in the new worker. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add gated test-only fault-injection hook for the Xet stall path UNSLOTH_HF_XET_FORCE_STALL=1 makes the Xet download attempt write a partial blob and hang, so the no-progress watchdog and the HTTP fallback can be exercised end to end against a real repo (never set in production). Used to verify recovery on real models: a forced Xet stall on a 5.37GB Qwen3.5-35B-A3B shard triggered the watchdog and the HTTP retry downloaded the correct file (sha256 verified). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten Xet-fallback comments and consolidate its tests Trim docstrings and inline comments across the Xet->HTTP fallback code to the non-obvious why (spawn-not-thread, killpg-not-getpgid, the sparse-partial HTTP-resume hazard); drop comments that merely restate the code. Verified comment-only with an AST signature check. Merge the three helper-level test files (watchdog, transport policy, and the HF_HUB_DISABLE_XET regression lock) into tests/test_hf_xet_fallback.py, and prefer the real structlog over a bare stub so test collection order cannot leak an incomplete module to others that log at import. Full backend suite: 3455 passed, 14 pre-existing flash-attn failures only. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
352 lines
12 KiB
Python
352 lines
12 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Unit tests for utils.hf_xet_fallback: the no-progress watchdog, the Xet->HTTP
|
|
transport policy, and the HF_HUB_DISABLE_XET precondition the fallback rests on.
|
|
CPU-only, no network, no real subprocess (the per-attempt download seam is
|
|
monkeypatched).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Stub heavy/unavailable deps before importing the module under test. Use the
|
|
# real structlog when present; a bare stub left in sys.modules would break later
|
|
# modules that log at import time.
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
try:
|
|
import structlog # noqa: F401
|
|
except ImportError:
|
|
sys.modules["structlog"] = _types.ModuleType("structlog")
|
|
|
|
import huggingface_hub
|
|
from huggingface_hub import constants as hf_constants
|
|
|
|
import utils.hf_xet_fallback as xf
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Watchdog: fires only on a constant-size .incomplete, sparse-aware byte total.
|
|
# --------------------------------------------------------------------------- #
|
|
REPO = "ztest/xet-watchdog"
|
|
|
|
|
|
@pytest.fixture
|
|
def hf_cache(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
|
return tmp_path
|
|
|
|
|
|
def _blobs_dir(root: Path, repo_id: str = REPO) -> Path:
|
|
d = root / f"models--{repo_id.replace('/', '--')}" / "blobs"
|
|
d.mkdir(parents = True, exist_ok = True)
|
|
return d
|
|
|
|
|
|
def _wait(
|
|
predicate,
|
|
timeout: float = 2.0,
|
|
step: float = 0.02,
|
|
) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(step)
|
|
return predicate()
|
|
|
|
|
|
def test_constant_incomplete_fires_stall(hf_cache):
|
|
blobs = _blobs_dir(hf_cache)
|
|
(blobs / "deadbeef.incomplete").write_bytes(b"\0" * 1024) # never grows
|
|
|
|
calls: list[str] = []
|
|
stop = xf.start_watchdog(
|
|
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
|
|
)
|
|
try:
|
|
assert _wait(
|
|
lambda: len(calls) >= 1, timeout = 3.0
|
|
), "watchdog never fired on a constant-size .incomplete"
|
|
finally:
|
|
stop.set()
|
|
assert "stalled" in calls[0].lower()
|
|
|
|
|
|
def test_growing_incomplete_never_stalls(hf_cache):
|
|
blobs = _blobs_dir(hf_cache)
|
|
part = blobs / "growing.incomplete"
|
|
part.write_bytes(b"\0" * 1024)
|
|
|
|
grow_stop = threading.Event()
|
|
|
|
def _grow():
|
|
size = 1024
|
|
while not grow_stop.wait(0.05):
|
|
size += 4096
|
|
part.write_bytes(b"\0" * size)
|
|
|
|
grower = threading.Thread(target = _grow, daemon = True)
|
|
grower.start()
|
|
|
|
calls: list[str] = []
|
|
stop = xf.start_watchdog(
|
|
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
|
|
)
|
|
try:
|
|
time.sleep(1.0) # well past stall_timeout, but bytes keep growing
|
|
assert calls == [], "watchdog fired despite continuous progress"
|
|
finally:
|
|
stop.set()
|
|
grow_stop.set()
|
|
|
|
|
|
def test_no_incomplete_never_stalls(hf_cache):
|
|
blobs = _blobs_dir(hf_cache)
|
|
(blobs / "finalized_blob").write_bytes(b"\0" * 4096) # no .incomplete
|
|
|
|
calls: list[str] = []
|
|
stop = xf.start_watchdog(
|
|
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.3
|
|
)
|
|
try:
|
|
time.sleep(0.8)
|
|
assert calls == [], "watchdog fired with no active .incomplete"
|
|
finally:
|
|
stop.set()
|
|
|
|
|
|
def test_stall_fires_at_most_once(hf_cache):
|
|
blobs = _blobs_dir(hf_cache)
|
|
(blobs / "frozen.incomplete").write_bytes(b"\0" * 2048)
|
|
|
|
calls: list[str] = []
|
|
stop = xf.start_watchdog(
|
|
repo_ids = [REPO], on_stall = calls.append, interval = 0.05, stall_timeout = 0.2
|
|
)
|
|
try:
|
|
assert _wait(lambda: len(calls) >= 1, timeout = 3.0)
|
|
time.sleep(0.6) # keep ticking; must not fire again
|
|
assert len(calls) == 1, f"on_stall fired {len(calls)} times, expected exactly 1"
|
|
finally:
|
|
stop.set()
|
|
|
|
|
|
def test_get_state_empty_cache(hf_cache):
|
|
assert xf.get_hf_download_state([REPO]) == (0, False)
|
|
|
|
|
|
def test_get_state_absent_cache_root(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path / "no-such-cache"))
|
|
assert xf.get_hf_download_state([REPO]) == (0, False)
|
|
|
|
|
|
def test_get_state_skips_local_paths(hf_cache):
|
|
# Filesystem paths are not HF repo IDs and must be ignored without error.
|
|
assert xf.get_hf_download_state(["/abs/path", "./rel", "~user", "c:\\x"]) == (0, False)
|
|
|
|
|
|
def test_get_state_sparse_aware(hf_cache):
|
|
blobs = _blobs_dir(hf_cache)
|
|
sparse = blobs / "sparse.incomplete"
|
|
with open(sparse, "wb") as f:
|
|
f.truncate(64 * 1024 * 1024) # large apparent size, few allocated blocks
|
|
st = sparse.stat()
|
|
if getattr(st, "st_blocks", 0) == 0:
|
|
pytest.skip("filesystem does not report st_blocks; sparse accounting unavailable")
|
|
total, has_incomplete = xf.get_hf_download_state([REPO])
|
|
assert has_incomplete is True
|
|
assert total < st.st_size, "sparse partial counted at apparent size, not allocated blocks"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Transport policy: cached short-circuit, cancel, error propagation, and the
|
|
# single Xet->HTTP fallback. _run_download_attempt is faked, so no real spawn.
|
|
# --------------------------------------------------------------------------- #
|
|
DL_REPO, FILE = "ztest/xet-dl", "model-Q4_K_XL.gguf"
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _no_real_cache_hit(monkeypatch):
|
|
"""Default: the cached probe misses; tests override it to force a hit."""
|
|
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: None)
|
|
|
|
|
|
class _FakeAttempt:
|
|
"""Records calls to the download seam and returns scripted results."""
|
|
|
|
def __init__(self, results):
|
|
self._results = list(results)
|
|
self.calls = []
|
|
|
|
def __call__(
|
|
self,
|
|
repo_id,
|
|
filename,
|
|
token,
|
|
*,
|
|
repo_type,
|
|
disable_xet,
|
|
cancel_event,
|
|
stall_timeout,
|
|
interval,
|
|
grace_period,
|
|
on_status,
|
|
):
|
|
self.calls.append(
|
|
_types.SimpleNamespace(
|
|
repo_id = repo_id,
|
|
filename = filename,
|
|
disable_xet = disable_xet,
|
|
repo_type = repo_type,
|
|
)
|
|
)
|
|
return self._results[len(self.calls) - 1]
|
|
|
|
|
|
def _install(monkeypatch, results):
|
|
fake = _FakeAttempt(results)
|
|
monkeypatch.setattr(xf, "_run_download_attempt", fake)
|
|
return fake
|
|
|
|
|
|
def test_cached_file_short_circuits(monkeypatch, tmp_path):
|
|
cached = tmp_path / "cached.gguf"
|
|
cached.write_bytes(b"\0" * 8)
|
|
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: str(cached))
|
|
fake = _install(monkeypatch, []) # must not be called
|
|
|
|
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert out == str(cached)
|
|
assert fake.calls == [], "spawned a download for an already-cached file"
|
|
|
|
|
|
def test_cancel_before_start_raises_no_attempt(monkeypatch):
|
|
fake = _install(monkeypatch, [])
|
|
ev = threading.Event()
|
|
ev.set()
|
|
with pytest.raises(RuntimeError, match = "Cancelled"):
|
|
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None, cancel_event = ev)
|
|
assert fake.calls == []
|
|
|
|
|
|
def test_nonstall_error_propagates_without_fallback(monkeypatch):
|
|
fake = _install(monkeypatch, [("error", "RepositoryNotFoundError: 404 not found")])
|
|
with pytest.raises(RuntimeError, match = "RepositoryNotFoundError"):
|
|
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert len(fake.calls) == 1, "deterministic error must not trigger an HTTP fallback"
|
|
assert fake.calls[0].disable_xet is False
|
|
|
|
|
|
def test_immediate_success_uses_xet_only(monkeypatch):
|
|
prepared = []
|
|
monkeypatch.setattr(
|
|
"hub.utils.download_registry.prepare_cache_for_transport",
|
|
lambda *a, **k: prepared.append(a),
|
|
)
|
|
fake = _install(monkeypatch, [("ok", "/cache/model.gguf")])
|
|
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert out == "/cache/model.gguf"
|
|
assert len(fake.calls) == 1 and fake.calls[0].disable_xet is False
|
|
assert prepared == [], "no cache prep should run when Xet succeeds first try"
|
|
|
|
|
|
def test_stall_then_http_fallback_succeeds(monkeypatch):
|
|
prepared = []
|
|
monkeypatch.setattr(
|
|
"hub.utils.download_registry.prepare_cache_for_transport",
|
|
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
|
|
)
|
|
fake = _install(monkeypatch, [("stall", None), ("ok", "/cache/model.gguf")])
|
|
|
|
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert out == "/cache/model.gguf"
|
|
assert len(fake.calls) == 2
|
|
assert fake.calls[0].disable_xet is False # Xet first
|
|
assert fake.calls[1].disable_xet is True # HTTP fallback
|
|
assert prepared == [("model", DL_REPO, "http")], "must prep cache for HTTP before the retry"
|
|
|
|
|
|
def test_second_stall_raises_download_stall_error(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hub.utils.download_registry.prepare_cache_for_transport", lambda *a, **k: None
|
|
)
|
|
fake = _install(monkeypatch, [("stall", None), ("stall", None)])
|
|
with pytest.raises(xf.DownloadStallError):
|
|
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert len(fake.calls) == 2
|
|
|
|
|
|
def test_cancelled_midattempt_raises_no_fallback(monkeypatch):
|
|
fake = _install(monkeypatch, [("cancelled", None)])
|
|
with pytest.raises(RuntimeError, match = "Cancelled"):
|
|
xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert len(fake.calls) == 1
|
|
|
|
|
|
def test_per_file_independent_fallback(monkeypatch):
|
|
"""A stalled shard falls back; a sibling shard that succeeds does not."""
|
|
monkeypatch.setattr(
|
|
"hub.utils.download_registry.prepare_cache_for_transport", lambda *a, **k: None
|
|
)
|
|
fake = _install(monkeypatch, [("ok", "/a"), ("stall", None), ("ok", "/b")])
|
|
assert xf.hf_hub_download_with_xet_fallback(DL_REPO, "shardA.gguf", None) == "/a"
|
|
assert xf.hf_hub_download_with_xet_fallback(DL_REPO, "shardB.gguf", None) == "/b"
|
|
assert [c.disable_xet for c in fake.calls] == [False, False, True]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Precondition: HF_HUB_DISABLE_XET is read at import time, so assert its effect
|
|
# in a FRESH interpreter (huggingface/huggingface_hub#3266 once ignored it).
|
|
# --------------------------------------------------------------------------- #
|
|
def _safe_path() -> str:
|
|
import os
|
|
return os.environ.get("PATH", "")
|
|
|
|
|
|
def test_disable_xet_constant_set_in_fresh_interpreter():
|
|
code = (
|
|
"from huggingface_hub import constants as c; "
|
|
"import sys; sys.exit(0 if c.HF_HUB_DISABLE_XET is True else 17)"
|
|
)
|
|
proc = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
env = {"HF_HUB_DISABLE_XET": "1", "PATH": _safe_path()},
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
assert proc.returncode == 0, (
|
|
f"HF_HUB_DISABLE_XET=1 did not set constants.HF_HUB_DISABLE_XET=True "
|
|
f"(rc={proc.returncode}): {proc.stderr}"
|
|
)
|
|
|
|
|
|
def test_default_leaves_xet_enabled():
|
|
code = (
|
|
"from huggingface_hub import constants as c; "
|
|
"import sys; sys.exit(0 if c.HF_HUB_DISABLE_XET is False else 17)"
|
|
)
|
|
proc = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
env = {"PATH": _safe_path()}, # no HF_HUB_DISABLE_XET
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
assert proc.returncode == 0, (
|
|
f"without the env var, constants.HF_HUB_DISABLE_XET was not False "
|
|
f"(rc={proc.returncode}): {proc.stderr}"
|
|
)
|