unsloth/studio/backend/utils/utils.py
Unsloth 6492d36719 Address review round 4: refcount concurrent guards, distrust proxy TCP
- Overlapping requests lost offline mid-flight. A later guard saw the
  HF_HUB_OFFLINE that an earlier one had set and took the no-op branch, so
  when the earlier guard exited it restored the constants and sessions while
  the later request was still resolving hub files, dropping it back onto the
  retry path. Each guard now holds its own reference on the refcounted
  force_hf_offline window. A user-supplied offline variable is still left
  untouched, told apart via force_hf_offline_active().
- The socket-timeout fallback trusted a TCP handshake to the proxy, which
  only proves the proxy is up, not that it can reach the hub. A live proxy
  with a blackholed upstream therefore read as reachable. With a proxy
  configured the timeout now stays unreachable; the TCP check is only
  evidence when connecting to the endpoint directly.

Verified: second guard engages and offline survives the first guard's exit,
state fully restored after both; dead-upstream proxy reads unreachable while
a slow direct endpoint still reads reachable; 9 concurrent metadata requests
against an unreachable hub all return 200 in 5.1s total.
2026-07-29 01:49:45 -07:00

606 lines
22 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
"""Shared backend utilities."""
import os
import structlog
import threading
import time
from loggers import get_logger
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
import shutil
import tempfile
logger = get_logger(__name__)
# ── Offline / HF-cache helpers ──────────────────────────────────
# An offline load must never touch the network (a DNS-dead session hangs on hub retries);
# these read the local HF cache the load itself uses.
_HF_OFFLINE_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
def hf_env_offline() -> bool:
"""True when HF_HUB_OFFLINE or TRANSFORMERS_OFFLINE requests offline mode.
Also honors TRANSFORMERS_OFFLINE (hub honors only HF_HUB_OFFLINE) since users set it
to keep transformers loads local.
"""
for var in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE"):
if os.environ.get(var, "").strip().lower() in _HF_OFFLINE_TRUE_VALUES:
return True
return False
def hf_endpoint_url() -> str:
"""Configured hub endpoint, scheme-normalised. Mirror users point this elsewhere."""
endpoint = (os.environ.get("HF_ENDPOINT") or "").strip() or "https://huggingface.co"
return endpoint if "://" in endpoint else "https://" + endpoint
def hf_endpoint_host() -> str:
"""Host of the configured endpoint; probing huggingface.co would misjudge a mirror."""
try:
from urllib.parse import urlparse
return urlparse(hf_endpoint_url()).hostname or "huggingface.co"
except Exception:
return "huggingface.co"
def hf_proxy_configured() -> bool:
"""True when egress to the endpoint goes through a proxy.
The proxy resolves the hub host, so local DNS proves nothing about reachability and
must not be used to declare the hub offline.
"""
try:
import urllib.request
from urllib.parse import urlparse
url = hf_endpoint_url()
proxies = urllib.request.getproxies()
scheme = urlparse(url).scheme or "https"
if scheme not in proxies and "all" not in proxies:
return False
host = urlparse(url).hostname or ""
try:
if host and urllib.request.proxy_bypass(host):
return False
except Exception:
pass
return True
except Exception:
return False
def dns_host_dead(host: str, timeout: float = 2.0) -> bool:
"""True when host does not resolve. Runs on a daemon thread so a wedged resolver
cannot block past the deadline and so socket.setdefaulttimeout is left alone.
Uses getaddrinfo, not gethostbyname: the latter is IPv4-only and would call an
AAAA-only mirror or an IPv6 literal dead.
"""
result: list = [None]
def _probe() -> None:
import socket as _socket
try:
_socket.getaddrinfo(host, None)
result[0] = False
except Exception:
result[0] = True
t = threading.Thread(target = _probe, daemon = True)
t.start()
t.join(timeout)
# Still running -> resolver wedged -> treat as dead.
return True if result[0] is None else result[0]
def hf_connect_target(endpoint: Optional[str] = None):
"""(host, port) egress actually has to reach: the proxy when one applies, else the endpoint."""
from urllib.parse import urlparse
url = endpoint or hf_endpoint_url()
parsed = urlparse(url)
default_port = 443 if parsed.scheme == "https" else 80
try:
import urllib.request
proxies = urllib.request.getproxies()
proxy = proxies.get(parsed.scheme) or proxies.get("all")
host = parsed.hostname or ""
if proxy and not (host and urllib.request.proxy_bypass(host)):
p = urlparse(proxy if "://" in proxy else "http://" + proxy)
return p.hostname, p.port or 80
except Exception:
pass
return parsed.hostname, parsed.port or default_port
def hf_tcp_reachable(timeout: float = 3.0, endpoint: Optional[str] = None) -> bool:
"""True when a TCP connection to the hub (or its proxy) can be established.
Separates "no egress" from "slow to answer": a loaded server still completes the
handshake promptly, whereas a blackholed route times out. A refused connection also
counts as reachable, since something answered.
"""
import socket as _socket
host, port = hf_connect_target(endpoint)
if not host:
return False
try:
with _socket.create_connection((host, port), timeout = timeout):
return True
except ConnectionRefusedError:
return True
except Exception:
return False
def hf_dns_dead(timeout: float = 2.0) -> bool:
"""Fast offline shortcut: the endpoint's host does not resolve and no proxy is in play.
Returns False whenever a proxy is configured, so proxy-only setups fall through to the
real reachability probe instead of being wrongly declared offline.
"""
if hf_proxy_configured():
return False
return dns_host_dead(hf_endpoint_host(), timeout)
# One load makes many hub calls, so the verdict is shared briefly to avoid re-probing on
# each. Kept short in BOTH directions: a stale "reachable" misses the plug being pulled
# (the case this whole path exists for), and a stale "unreachable" sends a load to the
# cache after the user reconnected, failing it if the model is not cached.
_HF_REACHABILITY_TTL_S = 5.0
_hf_reachability: Optional[tuple] = None
_hf_reachability_lock = threading.Lock()
def _reachability_fresh(entry) -> bool:
"""True while a cached (timestamp, unreachable) verdict may still be reused."""
return entry is not None and (time.monotonic() - entry[0]) < _HF_REACHABILITY_TTL_S
def hf_probe_disabled() -> bool:
"""True when UNSLOTH_OFFLINE_PROBE opts out of the reachability probe."""
return os.environ.get("UNSLOTH_OFFLINE_PROBE", "1").strip().lower() in {
"0",
"false",
"no",
"off",
}
def reset_hf_reachability_cache() -> None:
"""Drop the memoised verdict so the next call re-probes (tests, network changes)."""
global _hf_reachability
with _hf_reachability_lock:
_hf_reachability = None
def hf_unreachable(timeout: int = 3) -> bool:
"""True when the HF endpoint is unreachable, memoised for _HF_REACHABILITY_TTL_S.
Resolving DNS does not mean the Hub is reachable: a router that is up with the WAN
down, a captive portal or a stale DNS cache all answer lookups while every request
then burns huggingface_hub's retry backoff. This is the bounded, proxy-aware probe
the export path already uses; disable it with UNSLOTH_OFFLINE_PROBE=0.
Fails open: an unavailable probe reports reachable, so the load decides as it does today.
"""
if hf_probe_disabled():
return False
global _hf_reachability
cached = _hf_reachability
if _reachability_fresh(cached):
return cached[1]
with _hf_reachability_lock:
cached = _hf_reachability
if _reachability_fresh(cached):
return cached[1]
try:
from utils.transformers_version import hf_endpoint_unreachable
unreachable = hf_endpoint_unreachable(timeout)
except Exception:
unreachable = False
_hf_reachability = (time.monotonic(), unreachable)
return unreachable
def _reset_hf_sessions() -> None:
"""Drop cached hub sessions so they remount with the current offline adapter."""
try:
from huggingface_hub.utils import _http
for name in ("_get_session_from_cache", "get_session"):
cache_clear = getattr(getattr(_http, name, None), "cache_clear", None)
if cache_clear is not None:
cache_clear()
reset = getattr(_http, "reset_sessions", None)
if reset is not None:
reset()
except Exception:
pass
# Process-global, so nested/concurrent loads refcount rather than restore out from under
# each other.
_force_offline_depth = 0
_force_offline_saved: list = []
_force_offline_saved_env: dict = {}
_force_offline_lock = threading.Lock()
_OFFLINE_ENV_KEYS = ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")
_OFFLINE_CONSTANTS = (
("huggingface_hub.constants", ("HF_HUB_OFFLINE",)),
("transformers.utils.hub", ("_is_offline_mode", "OFFLINE")),
)
def force_hf_offline_active() -> bool:
"""True while a force_hf_offline window is open anywhere in this process.
Lets a concurrent caller tell our own forced offline apart from one the user set, so
it can hold its own reference instead of no-opping and losing offline when the first
window exits.
"""
with _force_offline_lock:
return _force_offline_depth > 0
@contextmanager
def force_hf_offline():
"""Force HF offline for this block, in-process.
Setting the env vars is not enough once the process is running: huggingface_hub and
transformers read their offline constants at import, and hub sessions cache a
non-offline adapter. Flip the constants too and rebuild the sessions, so hub calls
fail fast instead of retrying. Everything is restored on exit.
"""
global _force_offline_depth, _force_offline_saved, _force_offline_saved_env
import importlib
with _force_offline_lock:
if _force_offline_depth == 0:
saved: list = []
saved_env: dict = {}
# Snapshot constants BEFORE forcing the env, else a module first imported
# inside the window reads the "1" and we would restore it as offline.
for mod_name, attrs in _OFFLINE_CONSTANTS:
try:
mod = importlib.import_module(mod_name)
except Exception:
continue
for attr in attrs:
if hasattr(mod, attr):
saved.append((mod, attr, getattr(mod, attr)))
for key in _OFFLINE_ENV_KEYS:
saved_env[key] = os.environ.get(key)
os.environ[key] = "1"
for mod, attr, _ in saved:
try:
setattr(mod, attr, True)
except Exception:
pass
_force_offline_saved = saved
_force_offline_saved_env = saved_env
_reset_hf_sessions()
_force_offline_depth += 1
try:
yield
finally:
with _force_offline_lock:
_force_offline_depth -= 1
if _force_offline_depth == 0:
for mod, attr, val in _force_offline_saved:
try:
setattr(mod, attr, val)
except Exception:
pass
_force_offline_saved = []
for key, val in _force_offline_saved_env.items():
if val is None:
os.environ.pop(key, None)
else:
os.environ[key] = val
_force_offline_saved_env = {}
_reset_hf_sessions()
def st_repo_id_candidates(model_name: str) -> list:
"""Repo ids a Sentence-Transformers load may resolve model_name to; a slashless name
also resolves under the sentence-transformers/ namespace, so both are candidates."""
name = (model_name or "").strip().strip("/")
if not name:
return []
candidates = [name]
if "/" not in name:
candidates.append(f"sentence-transformers/{name}")
return candidates
def _expand_path(raw: str) -> Path:
"""Expand ~ and $VARS as huggingface_hub does, so the gate resolves the loader's dir."""
return Path(os.path.expandvars(os.path.expanduser(raw)))
def _hf_cache_roots() -> list:
"""Cache roots to search for a model's local snapshot, most-authoritative first.
The app's selected hub cache (set via /settings) is searched first: after a
no-restart cache switch the process env is stale, yet the loader reads the
selected cache via ``cache_folder=active_hf_hub_cache()``, so the snapshot
and offline security lookups must match where it actually loads. The env
precedence (SENTENCE_TRANSFORMERS_HOME, HF_HUB_CACHE, HF_HOME/hub,
~/.cache/huggingface/hub) follows so a copy still in a previous cache resolves."""
roots: list = []
seen: set = set()
def _add(path) -> None:
if path is None:
return
expanded = _expand_path(str(path))
key = str(expanded)
if key not in seen:
seen.add(key)
roots.append(expanded)
try:
from utils.hf_cache_settings import get_hf_cache_paths
_add(get_hf_cache_paths().hub_cache)
except Exception:
pass
if st_home := os.environ.get("SENTENCE_TRANSFORMERS_HOME"):
_add(st_home)
if hub := (os.environ.get("HF_HUB_CACHE") or os.environ.get("HUGGINGFACE_HUB_CACHE")):
_add(hub)
if hf_home := os.environ.get("HF_HOME"):
_add(_expand_path(hf_home) / "hub")
if not roots:
_add(Path.home() / ".cache" / "huggingface" / "hub")
return roots
def hf_cache_snapshot_dir(model_name: str) -> Optional[Path]:
"""Active local snapshot dir for model_name's main revision, or None if not cached.
Reads refs/main then snapshots/<commit>; no network. Tries the ST alias for slashless names."""
try:
from huggingface_hub.file_download import repo_folder_name
except Exception:
repo_folder_name = None
for cache_root in _hf_cache_roots():
for repo_id in st_repo_id_candidates(model_name):
try:
if repo_folder_name is not None:
folder = repo_folder_name(repo_id = repo_id, repo_type = "model")
else:
folder = "models--" + repo_id.replace("/", "--")
repo_dir = cache_root / folder
ref = repo_dir / "refs" / "main"
if not ref.is_file():
continue
commit = ref.read_text(encoding = "utf-8").strip()
if not commit:
continue
snapshot = repo_dir / "snapshots" / commit
if snapshot.is_dir():
return snapshot
except (OSError, UnicodeDecodeError):
continue
return None
# A weight file plus a config distinguishes a real cached model from a metadata-only
# partial cache that resolves refs/main but would fail at load time.
_LOADABLE_WEIGHT_SUFFIXES = frozenset({".safetensors", ".bin", ".gguf", ".pt", ".pth", ".ckpt"})
def hf_cache_snapshot_is_loadable(model_name: str) -> bool:
"""True when model_name's snapshot is cached and loadable: a config (config.json or
modules.json) plus at least one weight file, not a metadata-only partial cache. No network."""
snapshot = hf_cache_snapshot_dir(model_name)
if snapshot is None:
return False
try:
has_config = (snapshot / "config.json").is_file() or (snapshot / "modules.json").is_file()
if not has_config:
return False
for path in snapshot.rglob("*"):
if path.suffix.lower() in _LOADABLE_WEIGHT_SUFFIXES and path.is_file():
return True
except OSError:
return False
return False
# ── Client-safe error helpers ───────────────────────────────────
# Never return raw exception text to clients; log server-side, return generic.
def safe_error_detail(error: Exception, fallback: str = "An internal error occurred") -> str:
"""Map an exception to a generic, client-safe message (never raw
``str(error)``, which can leak paths). Log the real exception server-side.
"""
text = str(error).lower()
if (
isinstance(error, (ConnectionError, TimeoutError))
or "connection" in text
or "timed out" in text
or "timeout" in text
):
return "Could not reach an upstream service. Please try again."
if "out of memory" in text or "cuda error" in text:
return "Ran out of memory. Try a smaller model or shorter input."
return fallback
def safe_curated_detail(error: Exception, fallback: str = "An internal error occurred") -> str:
"""Client-safe text for curated domain/validation exceptions.
Keeps the message (paths stripped) instead of a generic fallback; for known
exception types only (use ``safe_error_detail`` for generic ``Exception``).
"""
from utils.native_path_leases import redact_native_paths
msg = redact_native_paths(str(error)).strip()
return msg or fallback
def log_and_http_error(
error: Exception,
status_code: int,
public_message: str,
*,
event: str = "request_failed",
log = None,
):
"""Log ``error`` in full server-side and return an ``HTTPException`` whose
``detail`` is only ``public_message`` -- never the raw exception text.
Usage: raise log_and_http_error(e, 500, "Failed to start training")
"""
from fastapi import HTTPException
# exc_info=error works for both structlog and stdlib loggers.
(log or logger).error(f"{event}: {error}", exc_info = error)
return HTTPException(status_code = status_code, detail = public_message)
@contextmanager
def without_hf_auth():
"""
Temporarily disable HuggingFace authentication.
Usage:
with without_hf_auth():
# Code that should run without cached tokens
model_info(model_name, token=None)
"""
saved_env = {}
env_vars = ["HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_HOME"]
for var in env_vars:
if var in os.environ:
saved_env[var] = os.environ[var]
del os.environ[var]
saved_disable = os.environ.get("HF_HUB_DISABLE_IMPLICIT_TOKEN")
os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1"
# Move token files aside temporarily
token_files = []
token_locations = [
Path.home() / ".cache" / "huggingface" / "token",
Path.home() / ".huggingface" / "token",
]
for token_loc in token_locations:
if token_loc.exists():
temp = tempfile.NamedTemporaryFile(delete = False)
temp.close()
shutil.move(str(token_loc), temp.name)
token_files.append((token_loc, temp.name))
try:
yield
finally:
# Restore tokens
for original, temp in token_files:
try:
original.parent.mkdir(parents = True, exist_ok = True)
shutil.move(temp, str(original))
except Exception as e:
logger.error(f"Failed to restore token {original}: {e}")
# Restore env
for var, value in saved_env.items():
os.environ[var] = value
if saved_disable is not None:
os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = saved_disable
else:
os.environ.pop("HF_HUB_DISABLE_IMPLICIT_TOKEN", None)
def is_hf_authentication_error(error: Exception) -> bool:
"""Return whether an exception chain contains a definitive HF auth failure."""
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
seen.add(id(current))
response = getattr(current, "response", None)
status = getattr(response, "status_code", None)
try:
if status is not None and int(status) == 401:
return True
except (TypeError, ValueError):
pass
message = str(current).lower()
if "invalid user token" in message or "invalid hf token" in message:
return True
current = current.__cause__ or current.__context__
return False
def format_error_message(error: Exception, model_name: str) -> str:
"""
Format a user-friendly error message for common load issues.
Args:
error: The exception that occurred
model_name: Name of the model being loaded
"""
error_str = str(error).lower()
model_short = model_name.split("/")[-1] if "/" in model_name else model_name
if "repository not found" in error_str or "404" in error_str:
return f"Model '{model_short}' not found. Check the model name."
if "401" in error_str or "unauthorized" in error_str:
return f"Authentication failed for '{model_short}'. Please provide a valid HF token."
if "gated" in error_str or "access to model" in error_str:
return f"Model '{model_short}' requires authentication. Please provide a valid HF token."
if "invalid user token" in error_str:
return "Invalid HF token. Please check your token and try again."
if (
"out of memory" in error_str
or "out of device memory" in error_str
or "out_of_device_memory" in error_str # ZE_RESULT_ERROR_OUT_OF_DEVICE_MEMORY
or "out_of_host_memory" in error_str # ZE_RESULT_ERROR_OUT_OF_HOST_MEMORY
or "not enough memory" in error_str
or "cannot allocate memory" in error_str
or "memory allocation failed" in error_str
or "cublas_status_alloc_failed" in error_str # cuBLAS workspace OOM
or ("cuda error" in error_str and "alloc" in error_str)
or ("xpu" in error_str and ("alloc" in error_str or "memory" in error_str))
or isinstance(error, MemoryError)
or ("mlx" in error_str and ("memory" in error_str or "allocate" in error_str))
):
# Resolve get_device() at call time (not import time) so tests that
# monkey-patch utils.hardware.get_device after this module is loaded
# still see the patched backend.
from utils.hardware import get_device
device = get_device()
device_label = {
"cuda": "GPU",
"xpu": "Intel GPU",
"mlx": "Apple Silicon GPU",
"cpu": "system",
}.get(device.value, "GPU")
return f"Not enough {device_label} memory to load '{model_short}'. Try a smaller model or free memory."
return str(error)