From 224b90eb0212eb61a7bc1301460bb61e5703a31d Mon Sep 17 00:00:00 2001 From: Unsloth Date: Sun, 26 Jul 2026 23:41:35 -0700 Subject: [PATCH] Make forced offline hub-specific and close the parent preflight gap A forced guard now only no-ops when HF_HUB_OFFLINE itself is truthy: huggingface_hub ignores TRANSFORMERS_OFFLINE, so with only that flag set the GGUF cache-size check could still call get_paths_info. Snapshot selection prefers revisions holding the inventoried safetensors weights, so a newest metadata-only revision no longer shadows a complete older one and fails a valid candidate. The orchestrator's parent-side preflight (transformers tier probe and GPU sizing, which calls hf model_info) runs under the local-only guard, closed before the worker spawn so the child does not inherit the offline env for its whole lifetime; ordinary loads keep their current unguarded behavior. --- studio/backend/core/inference/llama_cpp.py | 13 ++++++-- studio/backend/core/inference/orchestrator.py | 32 +++++++++++++++---- studio/backend/hub/utils/local_snapshot.py | 31 ++++++++++++++---- .../tests/test_local_snapshot_resolution.py | 29 +++++++++++++++++ .../tests/test_offline_guard_refcount.py | 16 +++++++++- tests/studio/test_model_picker_contracts.py | 28 +++++++++++++++- 6 files changed, 131 insertions(+), 18 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 0c562708fe..43a2bd20cf 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -506,6 +506,13 @@ def _hf_env_offline() -> bool: return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"} +def _hub_offline_env_truthy() -> bool: + """HF_HUB_OFFLINE specifically is truthy. huggingface_hub does not honor + TRANSFORMERS_OFFLINE, so a forced local-only guard may only no-op when the + Hub flag itself is already set.""" + return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"} + + # Overlapping offline guards share one env override: the count tracks active # guards and the saved values are restored only when the LAST guard exits, so # a request finishing early cannot re-enable network for one still running. @@ -531,9 +538,11 @@ def _hf_offline_if_dns_dead(force: bool = False): # exits, whichever request finishes first. _OFFLINE_GUARD_STATE["count"] += 1 entered = True - elif _hf_env_offline(): + elif _hf_env_offline() and (not force or _hub_offline_env_truthy()): # User-set truthy env (count is 0): already offline, nothing to - # arrange or restore. + # arrange or restore. A forced guard only trusts HF_HUB_OFFLINE + # itself: TRANSFORMERS_OFFLINE=1 alone leaves hub API calls (e.g. + # get_paths_info) online, so force still installs both flags. pass elif not force and "HF_HUB_OFFLINE" in os.environ: # A user-pinned falsy value stays authoritative for ordinary loads. diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py index 5aa0a181a6..4a213d591e 100644 --- a/studio/backend/core/inference/orchestrator.py +++ b/studio/backend/core/inference/orchestrator.py @@ -986,7 +986,24 @@ class InferenceOrchestrator: self.loading_models.add(model_name) try: - needed_major = "5" if needs_transformers_5(model_name) else "4" + # Parent-side metadata preflight stays offline for local-only + # loads: the tier probe reads model configs and GPU sizing calls + # hf model_info. The guard closes BEFORE the spawn below, so the + # worker does not inherit HF_HUB_OFFLINE for its whole lifetime + # (its own bootstrap guard scopes the load and restores for + # generation-time fetches). + import contextlib + + from core.inference.llama_cpp import _hf_offline_if_dns_dead + + def _preflight_offline(): + # Ordinary loads keep their current (unguarded) behavior. + if local_files_only: + return _hf_offline_if_dns_dead(force = True) + return contextlib.nullcontext() + + with _preflight_offline(): + needed_major = "5" if needs_transformers_5(model_name) else "4" # Build config dict for subprocess sub_config = { @@ -1013,12 +1030,13 @@ class InferenceOrchestrator: if mlx_distributed else None, } - resolved_gpu_ids, gpu_selection = prepare_gpu_selection( - gpu_ids, - model_name = model_name, - hf_token = hf_token, - load_in_4bit = load_in_4bit, - ) + with _preflight_offline(): + resolved_gpu_ids, gpu_selection = prepare_gpu_selection( + gpu_ids, + model_name = model_name, + hf_token = hf_token, + load_in_4bit = load_in_4bit, + ) sub_config["resolved_gpu_ids"] = resolved_gpu_ids sub_config["gpu_selection"] = gpu_selection # Parent-detected backend for the worker's apply_gpu_ids(). diff --git a/studio/backend/hub/utils/local_snapshot.py b/studio/backend/hub/utils/local_snapshot.py index 23d3f0a243..33c8d2e59e 100644 --- a/studio/backend/hub/utils/local_snapshot.py +++ b/studio/backend/hub/utils/local_snapshot.py @@ -7,13 +7,29 @@ import os from typing import Optional -def _snapshot_dir_fallback(repo_id: str, cache_dir: Optional[str]) -> Optional[str]: - """Newest snapshots/* dir (by mtime) holding a config.json. +def _snapshot_has_weights(rev: str) -> bool: + """Whether a snapshot dir holds at least one safetensors weight file. - This mirrors the inventory scanner's latest_snapshot_dir selection, so the - load targets the snapshot that made the row eligible. It also covers - revision-only layouts (refs/ missing or pruned) that - snapshot_download(local_files_only = True) cannot map through refs/main. + Background loads only target safetensors-format rows (adapters and + pickle checkpoints are excluded upstream), so this is the runnable-weight + signal that made the inventory row eligible. + """ + try: + return any(name.endswith(".safetensors") for name in os.listdir(rev)) + except OSError: + return False + + +def _snapshot_dir_fallback(repo_id: str, cache_dir: Optional[str]) -> Optional[str]: + """Newest snapshots/* dir (by mtime) holding a config.json, preferring + revisions that also hold weights. + + This mirrors the inventory scanner's selection (newest revision that + actually carries the inventoried weights), so the load targets the + snapshot that made the row eligible: a newest metadata-only revision must + not shadow an older complete one. It also covers revision-only layouts + (refs/ missing or pruned) that snapshot_download(local_files_only = True) + cannot map through refs/main. """ if cache_dir is None: try: @@ -33,7 +49,8 @@ def _snapshot_dir_fallback(repo_id: str, cache_dir: Optional[str]) -> Optional[s candidates = [rev for rev in revisions if os.path.isfile(os.path.join(rev, "config.json"))] if not candidates: return None - return max(candidates, key = os.path.getmtime) + weightful = [rev for rev in candidates if _snapshot_has_weights(rev)] + return max(weightful or candidates, key = os.path.getmtime) def resolve_local_snapshot_path( diff --git a/studio/backend/tests/test_local_snapshot_resolution.py b/studio/backend/tests/test_local_snapshot_resolution.py index 037d26f68e..903b3aa52d 100644 --- a/studio/backend/tests/test_local_snapshot_resolution.py +++ b/studio/backend/tests/test_local_snapshot_resolution.py @@ -162,6 +162,35 @@ def test_refless_fallback_picks_newest_snapshot_with_config(tmp_path): assert Path(resolved).resolve() == new.resolve() +def test_weightless_newest_snapshot_does_not_shadow_complete_older_one(tmp_path): + """A newest metadata-only revision (config.json, no weights) must not win + over an older revision holding the inventoried safetensors weights: the + inventory made the row eligible from the weightful revision, so the load + must resolve that one instead of failing on the weightless dir.""" + import os + import time + + complete = _build_cached_repo( + tmp_path, + "org/meta-newest", + {"config.json": "{}", "model.safetensors": "weights"}, + with_refs = False, + rev = "a" * 40, + ) + stale = time.time() - 1000 + os.utime(complete, (stale, stale)) + _build_cached_repo( + tmp_path, + "org/meta-newest", + {"config.json": "{}"}, + with_refs = False, + rev = "b" * 40, + ) + resolved = resolve_local_snapshot_path("org/meta-newest", cache_dir = str(tmp_path)) + assert resolved is not None + assert Path(resolved).resolve() == complete.resolve() + + def test_refless_fallback_without_config_resolves_to_none(tmp_path): """A snapshots dir with no config.json anywhere is not a loadable text model cache; resolution must stay None (409 upstream), not guess.""" diff --git a/studio/backend/tests/test_offline_guard_refcount.py b/studio/backend/tests/test_offline_guard_refcount.py index 933b22471e..e9d7b285d9 100644 --- a/studio/backend/tests/test_offline_guard_refcount.py +++ b/studio/backend/tests/test_offline_guard_refcount.py @@ -34,7 +34,7 @@ class _NullLogger: def _load_guard(dns_dead: bool = False): src = _LLAMA_CPP.read_text() - start = src.index("# Overlapping offline guards") + start = src.index("def _hub_offline_env_truthy") end = src.index("_SLOT_SAVE_MAX_BYTES") end = src.rindex("try:", start, end) block = src[start:end] @@ -100,6 +100,20 @@ def test_truthy_user_env_is_a_noop(clean_env): assert os.environ["HF_HUB_OFFLINE"] == "1" +def test_transformers_only_env_does_not_satisfy_forced_guard(clean_env): + """TRANSFORMERS_OFFLINE=1 alone is not hub-offline: huggingface_hub + ignores it, so a forced guard must still install HF_HUB_OFFLINE for the + block and restore the prior state after.""" + guard = _load_guard() + os.environ["TRANSFORMERS_OFFLINE"] = "1" + g = guard(force = True) + assert g.__enter__() is True + assert os.environ.get("HF_HUB_OFFLINE") == "1" + g.__exit__(None, None, None) + assert "HF_HUB_OFFLINE" not in os.environ + assert os.environ.get("TRANSFORMERS_OFFLINE") == "1" + + def test_nonforce_joins_active_override(clean_env): """A DNS-alive non-force guard entering while a forced guard is active must JOIN the refcount (deferring the restore) rather than no-op.""" diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 1e2a18926e..609aba9858 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -1115,7 +1115,7 @@ def test_background_candidate_filters_have_no_side_effects(): # TRUTHY env value short-circuits), and overlapping guards are refcounted # so the env is restored only when the LAST one exits; behavior is # exercised directly in test_offline_guard_refcount.py. - assert "elif _hf_env_offline():" in llama + assert "elif _hf_env_offline() and (not force or _hub_offline_env_truthy()):" in llama assert "_OFFLINE_GUARD_LOCK" in llama assert '_OFFLINE_GUARD_STATE["count"] += 1' in llama @@ -1218,6 +1218,32 @@ def test_background_picks_mirror_inventory_and_skip_installers(): assert "snapshot_size_bytes" in schema +def test_forced_offline_is_hub_specific_and_covers_parent_preflight(): + """Round-16 gates. A forced guard may only no-op when HF_HUB_OFFLINE + itself is truthy (huggingface_hub ignores TRANSFORMERS_OFFLINE), snapshot + selection prefers revisions that hold the inventoried safetensors weights + so a metadata-only newest revision cannot shadow a complete older one, + and the orchestrator's parent-side preflight (transformers tier probe, + GPU sizing via hf model_info) runs under the local-only guard, closed + before the worker spawn so the child does not inherit the env.""" + llama = _read_backend("core/inference/llama_cpp.py") + assert "def _hub_offline_env_truthy(" in llama + assert "not force or _hub_offline_env_truthy()" in llama + + helper = _read_backend("hub/utils/local_snapshot.py") + assert "def _snapshot_has_weights(" in helper + assert "max(weightful or candidates, key = os.path.getmtime)" in helper + + orchestrator = _read_backend("core/inference/orchestrator.py") + preflight = orchestrator.split("def _preflight_offline():", 1)[1] + assert "_hf_offline_if_dns_dead(force = True)" in preflight + # Both metadata call sites are guarded, and the guard closes before spawn. + assert preflight.count("with _preflight_offline():") == 2 + tier_probe = preflight.split("with _preflight_offline():", 1)[1] + assert "needs_transformers_5" in tier_probe.split("with _preflight_offline():", 1)[0] + assert "prepare_gpu_selection(" in tier_probe.split("_spawn_subprocess", 1)[0] + + def test_gguf_background_loads_never_download_companions(): """A cached GGUF load can still fetch from the Hub through its optional companions (mmproj, MTP drafter) or a cache-miss main quant. Background