Compare commits
10 commits
main
...
fix/offlin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93dafbd429 | ||
|
|
6492d36719 | ||
|
|
59929802c0 | ||
|
|
5c9b82e024 | ||
|
|
26d205b910 | ||
|
|
d5758ecf58 | ||
|
|
1ec30c54d1 | ||
|
|
c4973c76f6 | ||
|
|
b3ca10d04e | ||
|
|
adbf88d8b6 |
15 changed files with 984 additions and 162 deletions
|
|
@ -495,23 +495,17 @@ _SWA_CACHE: Optional[dict] = None
|
|||
_SWA_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _probe_dns_dead(host: str = "huggingface.co", timeout: float = 2.0) -> bool:
|
||||
def _hf_endpoint_host() -> str:
|
||||
"""Host of the configured hub endpoint (HF_ENDPOINT aware)."""
|
||||
from utils.utils import hf_endpoint_host
|
||||
return hf_endpoint_host()
|
||||
|
||||
|
||||
def _probe_dns_dead(host: Optional[str] = None, timeout: float = 2.0) -> bool:
|
||||
"""Quick DNS check on a daemon thread, so concurrent sockets aren't
|
||||
affected by socket.setdefaulttimeout."""
|
||||
result: list[Optional[bool]] = [None]
|
||||
|
||||
def _probe() -> None:
|
||||
try:
|
||||
socket.gethostbyname(host)
|
||||
result[0] = False
|
||||
except Exception:
|
||||
result[0] = True
|
||||
|
||||
t = threading.Thread(target = _probe, daemon = True)
|
||||
t.start()
|
||||
t.join(timeout)
|
||||
# Thread still running -> resolver wedged -> dead.
|
||||
return True if result[0] is None else result[0]
|
||||
affected by socket.setdefaulttimeout. Defaults to the configured endpoint's host."""
|
||||
from utils.utils import dns_host_dead, hf_endpoint_host
|
||||
return dns_host_dead(host or hf_endpoint_host(), timeout)
|
||||
|
||||
|
||||
def _hf_env_offline() -> bool:
|
||||
|
|
@ -527,29 +521,69 @@ def _hf_env_offline() -> bool:
|
|||
return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _hf_unreachable() -> bool:
|
||||
"""True when the Hub can't be reached: dead DNS, or DNS that resolves with no egress.
|
||||
|
||||
DNS alone misses the common offline shapes (WAN down behind a live router, captive
|
||||
portal, stale DNS cache), leaving every hub call to burn its full retry backoff.
|
||||
The DNS shortcut is skipped when a proxy is configured, since the proxy resolves the
|
||||
hub host and local DNS then says nothing about reachability.
|
||||
"""
|
||||
try:
|
||||
from utils.utils import hf_dns_dead, hf_unreachable
|
||||
except Exception:
|
||||
return False
|
||||
if hf_dns_dead():
|
||||
return True
|
||||
return hf_unreachable()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _hf_offline_if_dns_dead():
|
||||
"""Set HF_HUB_OFFLINE for this block only when DNS to huggingface.co fails;
|
||||
restores env on exit so a transient hiccup can't quarantine the process.
|
||||
No-op if the user already set it."""
|
||||
if "HF_HUB_OFFLINE" in os.environ:
|
||||
yield False
|
||||
return
|
||||
if not _probe_dns_dead():
|
||||
def _hf_offline_if_unreachable():
|
||||
"""Force HF offline for this block only when the Hub is unreachable; restores on exit
|
||||
so a transient hiccup can't quarantine the process. No-op if the USER set the env var.
|
||||
|
||||
Env vars alone do not take effect mid-process (huggingface_hub and transformers read
|
||||
their offline constants at import), so this holds a refcounted force_hf_offline
|
||||
window. Overlapping requests each take their own reference: no-opping because another
|
||||
guard already set HF_HUB_OFFLINE would let that guard's exit restore the network
|
||||
mid-operation and drop this request back onto the retry path.
|
||||
"""
|
||||
try:
|
||||
from utils.utils import force_hf_offline, force_hf_offline_active
|
||||
except Exception:
|
||||
yield False
|
||||
return
|
||||
|
||||
transformers_was_set = "TRANSFORMERS_OFFLINE" in os.environ
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
if not transformers_was_set:
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
logger.warning("huggingface.co unreachable; using local HF cache for this load.")
|
||||
try:
|
||||
ours = force_hf_offline_active()
|
||||
# A user-set offline var is theirs: don't probe it, don't touch it.
|
||||
if "HF_HUB_OFFLINE" in os.environ and not ours:
|
||||
yield False
|
||||
return
|
||||
# Already forced by an in-flight guard: reuse that verdict and hold a reference.
|
||||
if not ours and not _hf_unreachable():
|
||||
yield False
|
||||
return
|
||||
|
||||
if not ours:
|
||||
logger.warning("Hugging Face endpoint unreachable; using local HF cache for this load.")
|
||||
with force_hf_offline():
|
||||
yield True
|
||||
finally:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
if not transformers_was_set:
|
||||
os.environ.pop("TRANSFORMERS_OFFLINE", None)
|
||||
|
||||
|
||||
def _hf_offline_if_unreachable_for(model_name):
|
||||
"""Guard, but only for remote repo ids.
|
||||
|
||||
A local path is served from the filesystem and never reaches the hub, so probing
|
||||
would add seconds per request and prevent no retry.
|
||||
"""
|
||||
try:
|
||||
from utils.paths import is_local_path
|
||||
if isinstance(model_name, str) and is_local_path(model_name):
|
||||
return contextlib.nullcontext()
|
||||
except Exception:
|
||||
pass
|
||||
return _hf_offline_if_unreachable()
|
||||
|
||||
|
||||
try:
|
||||
|
|
@ -6889,7 +6923,7 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
)
|
||||
hf_repo = _resolved_repo
|
||||
with _hf_offline_if_dns_dead():
|
||||
with _hf_offline_if_unreachable():
|
||||
_preflight_model_path = self._download_gguf(
|
||||
hf_repo = hf_repo,
|
||||
hf_variant = hf_variant,
|
||||
|
|
@ -6931,7 +6965,7 @@ class LlamaCppBackend:
|
|||
hf_repo,
|
||||
)
|
||||
hf_repo = _resolved_repo
|
||||
with _hf_offline_if_dns_dead():
|
||||
with _hf_offline_if_unreachable():
|
||||
model_path = _preflight_model_path or self._download_gguf(
|
||||
hf_repo = hf_repo,
|
||||
hf_variant = hf_variant,
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ import pandas as pd
|
|||
from datasets import Dataset
|
||||
from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset
|
||||
|
||||
from core.inference.llama_cpp import _hf_offline_if_dns_dead
|
||||
from core.inference.llama_cpp import _hf_offline_if_unreachable
|
||||
from utils.models import is_vision_model, detect_audio_type
|
||||
from utils.models.model_config import _env_offline
|
||||
from utils.datasets import format_and_template_dataset
|
||||
|
|
|
|||
|
|
@ -2344,31 +2344,30 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
flush = True,
|
||||
)
|
||||
|
||||
# Offline auto-detect: skip ~25s of HF retries per call when DNS is dead.
|
||||
# Offline auto-detect: skip ~25s of HF retries per call when the hub is unreachable.
|
||||
if "HF_HUB_OFFLINE" not in os.environ:
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
|
||||
# Daemon thread so we don't mutate process-wide setdefaulttimeout.
|
||||
_result: list = [None]
|
||||
|
||||
def _probe() -> None:
|
||||
_offline = False
|
||||
try:
|
||||
_socket.gethostbyname("huggingface.co")
|
||||
_result[0] = False
|
||||
except Exception:
|
||||
_result[0] = True
|
||||
from utils.utils import hf_dns_dead, hf_probe_disabled
|
||||
|
||||
_t = _threading.Thread(target = _probe, daemon = True)
|
||||
_t.start()
|
||||
_t.join(2.0)
|
||||
if _result[0] is None or _result[0] is True:
|
||||
# hf_dns_dead follows HF_ENDPOINT and stands down when a proxy is configured,
|
||||
# so a reachable mirror (or proxy-only egress) is never called offline.
|
||||
_offline = hf_dns_dead()
|
||||
if not _offline and not hf_probe_disabled():
|
||||
# DNS answers even without egress (WAN down, captive portal). These flags
|
||||
# last the whole job, so only a connection failure counts: a momentary
|
||||
# 502/503 must not block every download for the rest of the run.
|
||||
from utils.transformers_version import hf_endpoint_unreachable
|
||||
_offline = hf_endpoint_unreachable(gateway_errors_offline = False)
|
||||
except Exception:
|
||||
_offline = False
|
||||
if _offline:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
||||
os.environ.setdefault("HF_DATASETS_OFFLINE", "1")
|
||||
# logger isn't configured yet; print to stderr instead.
|
||||
print(
|
||||
"huggingface.co unreachable; HF_HUB_OFFLINE=1 set for this worker.",
|
||||
"Hugging Face endpoint unreachable; HF_HUB_OFFLINE=1 set for this worker.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,9 +37,15 @@ async def get_default_chat_template_route(
|
|||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ModelTemplateResponse:
|
||||
template = await asyncio.to_thread(
|
||||
read_default_chat_template, model_name, hf_token, gguf_variant
|
||||
)
|
||||
# Cached repos resolve from disk, but a cache miss falls through to the hub and
|
||||
# offline that costs one retry backoff per candidate template file.
|
||||
from core.inference.llama_cpp import _hf_offline_if_unreachable_for
|
||||
|
||||
def _read():
|
||||
with _hf_offline_if_unreachable_for(model_name):
|
||||
return read_default_chat_template(model_name, hf_token, gguf_variant)
|
||||
|
||||
template = await asyncio.to_thread(_read)
|
||||
if template is not None and len(template.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
|
||||
template = None
|
||||
return ModelTemplateResponse(model_name = model_name, chat_template = template)
|
||||
|
|
|
|||
|
|
@ -1007,7 +1007,7 @@ try:
|
|||
_canonicalize_spec_mode,
|
||||
_extra_args_n_ubatch,
|
||||
_extra_args_set_spec_type,
|
||||
_hf_offline_if_dns_dead,
|
||||
_hf_offline_if_unreachable,
|
||||
_kv_bytes_per_elem,
|
||||
_kv_unified_from_args,
|
||||
_planned_main_cache_types,
|
||||
|
|
@ -1051,7 +1051,7 @@ except ImportError:
|
|||
_canonicalize_spec_mode,
|
||||
_extra_args_n_ubatch,
|
||||
_extra_args_set_spec_type,
|
||||
_hf_offline_if_dns_dead,
|
||||
_hf_offline_if_unreachable,
|
||||
_kv_bytes_per_elem,
|
||||
_kv_unified_from_args,
|
||||
_planned_main_cache_types,
|
||||
|
|
@ -3672,6 +3672,9 @@ def _target_is_vision(load_path: str) -> bool:
|
|||
# paths, where the token is unused, but the rule requires it regardless).
|
||||
from utils.models.model_config import is_vision_model
|
||||
try:
|
||||
# Deliberately unguarded: the resolver only yields local paths, so this returns
|
||||
# from the mmproj filesystem branch without touching the hub. A reachability
|
||||
# probe here would add seconds per request and prevent nothing.
|
||||
return bool(is_vision_model(load_path, hf_token = os.environ.get("HF_TOKEN")))
|
||||
except Exception as exc:
|
||||
# Detection failure: don't block the swap, let the load decide.
|
||||
|
|
@ -5467,7 +5470,7 @@ async def _load_model_impl(
|
|||
# is_lora auto-detected from adapter_config.json on disk/HF.
|
||||
# DNS-probe wrap so offline loads skip 30-60s of soft-failed network
|
||||
# checks before the worker starts.
|
||||
with _hf_offline_if_dns_dead():
|
||||
with _hf_offline_if_unreachable():
|
||||
config = ModelConfig.from_identifier(
|
||||
model_id = model_identifier,
|
||||
hf_token = request.hf_token,
|
||||
|
|
|
|||
|
|
@ -1855,7 +1855,13 @@ async def get_model_config(
|
|||
):
|
||||
"""Get configuration for a specific model (wraps load_model_defaults)."""
|
||||
hf_token = _normalize_hf_token(header_hf_token) or _normalize_hf_token(hf_token)
|
||||
from core.inference.llama_cpp import _hf_offline_if_unreachable_for
|
||||
|
||||
try:
|
||||
# Each probe below can reach the hub, so the guard wraps the whole handler:
|
||||
# offline they must all resolve from the HF cache instead of retrying. Local
|
||||
# paths stay on disk, so they skip the probe entirely.
|
||||
with _hf_offline_if_unreachable_for(model_name):
|
||||
if not is_local_path(model_name):
|
||||
resolved = resolve_cached_repo_id_case(model_name)
|
||||
if resolved != model_name:
|
||||
|
|
@ -2585,6 +2591,11 @@ async def check_vision_model(
|
|||
try:
|
||||
logger.info(f"Checking if vision model: {model_name}")
|
||||
# Authenticate so a gated/private VLM classifies correctly (else 404 -> non-vision).
|
||||
# Offline the guard keeps this on the HF cache instead of retrying the hub.
|
||||
# A local path resolves from disk, so it skips the probe.
|
||||
from core.inference.llama_cpp import _hf_offline_if_unreachable_for
|
||||
|
||||
with _hf_offline_if_unreachable_for(model_name):
|
||||
is_vision = is_vision_model(model_name, hf_token = hf_token)
|
||||
|
||||
logger.info(f"Vision check result for {model_name}: is_vision={is_vision}")
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ def test_a_forced_load_that_fails_preflight_leaves_the_chats_alone(monkeypatch):
|
|||
from models.inference import LoadRequest
|
||||
|
||||
inf_mod = _stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
||||
monkeypatch.setattr(inf_mod, "_hf_offline_if_dns_dead", contextlib.nullcontext)
|
||||
monkeypatch.setattr(inf_mod, "_hf_offline_if_unreachable", contextlib.nullcontext)
|
||||
# Stands in for any preflight refusal; a None here is the route's own 400.
|
||||
monkeypatch.setattr(inf_mod.ModelConfig, "from_identifier", staticmethod(lambda **kwargs: None))
|
||||
|
||||
|
|
@ -375,7 +375,7 @@ def _stub_standard_load_route(monkeypatch):
|
|||
_stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
||||
# _stub_load_route neutralises the sidecar guard; this test is about it.
|
||||
monkeypatch.setattr(inf_mod, "_raise_if_sidecar_swap_in_progress", real_sidecar_check)
|
||||
monkeypatch.setattr(inf_mod, "_hf_offline_if_dns_dead", contextlib.nullcontext)
|
||||
monkeypatch.setattr(inf_mod, "_hf_offline_if_unreachable", contextlib.nullcontext)
|
||||
monkeypatch.setattr(inf_mod, "_mlx_distributed_launch_detected", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
inf_mod.ModelConfig,
|
||||
|
|
@ -1582,7 +1582,7 @@ def test_a_forced_load_that_loses_to_a_sidecar_install_leaves_the_chats_alone(mo
|
|||
from models.inference import LoadRequest
|
||||
|
||||
inf_mod = _stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
||||
monkeypatch.setattr(inf_mod, "_hf_offline_if_dns_dead", contextlib.nullcontext)
|
||||
monkeypatch.setattr(inf_mod, "_hf_offline_if_unreachable", contextlib.nullcontext)
|
||||
monkeypatch.setattr(
|
||||
inf_mod.ModelConfig,
|
||||
"from_identifier",
|
||||
|
|
|
|||
|
|
@ -1163,7 +1163,9 @@ class TestLoadModelGuardIntegration(unittest.TestCase):
|
|||
patch.object(self.route, "resolve_effective_chat_template_override", return_value = None),
|
||||
patch.object(self.route, "get_inference_backend", return_value = inf),
|
||||
patch.object(self.route, "get_llama_cpp_backend", return_value = llama),
|
||||
patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()),
|
||||
patch.object(
|
||||
self.route, "_hf_offline_if_unreachable", lambda: contextlib.nullcontext()
|
||||
),
|
||||
patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg),
|
||||
_stub_guard_deps(training_active = True, decision = (False, info)),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -914,7 +914,7 @@ class TestLoadHubDownloadExclusion:
|
|||
patch.object(route, "_resolve_gguf_gpu_ids_for_request", _no_gguf_gpu_ids),
|
||||
patch.object(route, "_guard_chat_load_against_training", return_value = None),
|
||||
patch.object(route, "_effective_load_in_4bit", return_value = False),
|
||||
patch.object(route, "_hf_offline_if_dns_dead", nullcontext),
|
||||
patch.object(route, "_hf_offline_if_unreachable", nullcontext),
|
||||
patch.object(route.asyncio, "to_thread", new = _inline_to_thread),
|
||||
patch.object(llama_cpp_module, "_hub_download_blocks_gguf_load", _fake_blocks),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -737,7 +737,7 @@ def test_remote_vulkan_diffusion_rejection_keeps_active_server(monkeypatch):
|
|||
)
|
||||
monkeypatch.setattr(
|
||||
llama_cpp_module,
|
||||
"_hf_offline_if_dns_dead",
|
||||
"_hf_offline_if_unreachable",
|
||||
lambda: __import__("contextlib").nullcontext(),
|
||||
)
|
||||
|
||||
|
|
@ -786,7 +786,7 @@ def test_remote_vulkan_preflight_download_failure_keeps_active_server(monkeypatc
|
|||
monkeypatch.setattr(llama_cpp_module, "_resolve_repo_id_casing", lambda repo: repo)
|
||||
monkeypatch.setattr(
|
||||
llama_cpp_module,
|
||||
"_hf_offline_if_dns_dead",
|
||||
"_hf_offline_if_unreachable",
|
||||
lambda: __import__("contextlib").nullcontext(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1025,7 +1025,7 @@ class TestRouteErrors(unittest.TestCase):
|
|||
return_value = None,
|
||||
),
|
||||
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||
patch.object(inference_route, "_hf_offline_if_unreachable", nullcontext),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
|
|
@ -1095,7 +1095,7 @@ class TestRouteErrors(unittest.TestCase):
|
|||
return_value = None,
|
||||
) as training_guard,
|
||||
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||
patch.object(inference_route, "_hf_offline_if_unreachable", nullcontext),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
|
|
@ -1194,7 +1194,7 @@ class TestRouteErrors(unittest.TestCase):
|
|||
return_value = None,
|
||||
),
|
||||
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||
patch.object(inference_route, "_hf_offline_if_unreachable", nullcontext),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
|
|
@ -1340,7 +1340,7 @@ class TestRouteErrors(unittest.TestCase):
|
|||
return_value = None,
|
||||
),
|
||||
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||
patch.object(inference_route, "_hf_offline_if_unreachable", nullcontext),
|
||||
patch(
|
||||
"core.export.get_export_backend",
|
||||
return_value = SimpleNamespace(current_checkpoint = None),
|
||||
|
|
@ -1411,7 +1411,7 @@ class TestRouteErrors(unittest.TestCase):
|
|||
return_value = None,
|
||||
),
|
||||
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||
patch.object(inference_route, "_hf_offline_if_unreachable", nullcontext),
|
||||
patch(
|
||||
"core.export.get_export_backend",
|
||||
return_value = SimpleNamespace(current_checkpoint = None),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ from core.inference.llama_cpp import (
|
|||
LlamaCppBackend,
|
||||
_cached_colocated_split_main,
|
||||
_gguf_files_for_variant,
|
||||
_hf_offline_if_dns_dead,
|
||||
_hf_offline_if_unreachable,
|
||||
_probe_dns_dead,
|
||||
_resolve_repo_id_casing,
|
||||
)
|
||||
|
|
@ -871,28 +871,37 @@ class TestDetectGgufModelRemoteOffline:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _probe_dns_dead / _hf_offline_if_dns_dead
|
||||
# _probe_dns_dead / _hf_offline_if_unreachable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DnsState:
|
||||
"""Tiny helper that toggles ``socket.gethostbyname`` failure mode."""
|
||||
"""Toggles resolver failure. The probe uses ``getaddrinfo`` (IPv4 + IPv6), so patch
|
||||
both entry points."""
|
||||
|
||||
def __init__(self, monkeypatch):
|
||||
self._mp = monkeypatch
|
||||
self._real = socket.gethostbyname
|
||||
self._real_addr = socket.getaddrinfo
|
||||
|
||||
def fail(self):
|
||||
def _fail(*a, **k):
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
|
||||
self._mp.setattr(socket, "gethostbyname", _fail)
|
||||
self._mp.setattr(socket, "getaddrinfo", _fail)
|
||||
|
||||
def ok(self):
|
||||
self._mp.setattr(socket, "gethostbyname", lambda *a, **k: "127.0.0.1")
|
||||
self._mp.setattr(
|
||||
socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))],
|
||||
)
|
||||
|
||||
def restore(self):
|
||||
self._mp.setattr(socket, "gethostbyname", self._real)
|
||||
self._mp.setattr(socket, "getaddrinfo", self._real_addr)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -900,6 +909,18 @@ def dns(monkeypatch):
|
|||
return _DnsState(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reachable(monkeypatch):
|
||||
"""Control the endpoint reachability probe; defaults to reachable so no test hits the network."""
|
||||
import utils.utils as _utils
|
||||
|
||||
def _set(unreachable: bool):
|
||||
monkeypatch.setattr(_utils, "hf_unreachable", lambda *a, **k: unreachable)
|
||||
|
||||
_set(False)
|
||||
return _set
|
||||
|
||||
|
||||
class TestProbeDnsDead:
|
||||
def test_returns_false_on_success(self, dns):
|
||||
dns.ok()
|
||||
|
|
@ -919,11 +940,11 @@ class TestProbeDnsDead:
|
|||
socket.setdefaulttimeout(None)
|
||||
|
||||
|
||||
class TestHfOfflineIfDnsDead:
|
||||
def test_dns_fail_sets_env_inside_block_only(self, dns, clean_offline_env):
|
||||
class TestHfOfflineIfUnreachable:
|
||||
def test_dns_fail_sets_env_inside_block_only(self, dns, reachable, clean_offline_env):
|
||||
dns.fail()
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
with _hf_offline_if_dns_dead() as did_set:
|
||||
with _hf_offline_if_unreachable() as did_set:
|
||||
assert did_set is True
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
|
||||
|
|
@ -931,38 +952,58 @@ class TestHfOfflineIfDnsDead:
|
|||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
assert "TRANSFORMERS_OFFLINE" not in os.environ
|
||||
|
||||
def test_dns_ok_is_noop(self, dns, clean_offline_env):
|
||||
def test_dns_ok_and_reachable_is_noop(self, dns, reachable, clean_offline_env):
|
||||
dns.ok()
|
||||
with _hf_offline_if_dns_dead() as did_set:
|
||||
with _hf_offline_if_unreachable() as did_set:
|
||||
assert did_set is False
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
def test_dns_recovers_between_calls(self, dns, clean_offline_env):
|
||||
def test_dns_ok_but_endpoint_unreachable_engages(self, dns, reachable, clean_offline_env):
|
||||
# WAN down behind a live router: DNS answers, egress does not.
|
||||
dns.ok()
|
||||
reachable(True)
|
||||
with _hf_offline_if_unreachable() as did_set:
|
||||
assert did_set is True
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
def test_probe_opt_out_keeps_dns_only_behaviour(self, dns, clean_offline_env, monkeypatch):
|
||||
monkeypatch.setenv("UNSLOTH_OFFLINE_PROBE", "0")
|
||||
dns.ok()
|
||||
with _hf_offline_if_unreachable() as did_set:
|
||||
assert did_set is False
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
def test_dns_recovers_between_calls(self, dns, reachable, clean_offline_env):
|
||||
# First call: DNS dead -> env set inside, cleared on exit.
|
||||
dns.fail()
|
||||
with _hf_offline_if_dns_dead():
|
||||
with _hf_offline_if_unreachable():
|
||||
pass
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
# Second call: DNS healthy -> no env mutation.
|
||||
dns.ok()
|
||||
with _hf_offline_if_dns_dead() as did_set:
|
||||
with _hf_offline_if_unreachable() as did_set:
|
||||
assert did_set is False
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
def test_user_set_hf_hub_offline_is_preserved(self, dns, clean_offline_env, monkeypatch):
|
||||
def test_user_set_hf_hub_offline_is_preserved(
|
||||
self, dns, reachable, clean_offline_env, monkeypatch
|
||||
):
|
||||
# User explicitly set offline before launching Unsloth.
|
||||
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
||||
dns.fail()
|
||||
with _hf_offline_if_dns_dead() as did_set:
|
||||
with _hf_offline_if_unreachable() as did_set:
|
||||
assert did_set is False
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
# Helper must not pop a variable it did not set.
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
|
||||
def test_user_set_transformers_offline_is_preserved(self, dns, clean_offline_env, monkeypatch):
|
||||
def test_user_set_transformers_offline_is_preserved(
|
||||
self, dns, reachable, clean_offline_env, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1")
|
||||
dns.fail()
|
||||
with _hf_offline_if_dns_dead():
|
||||
with _hf_offline_if_unreachable():
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
|
||||
# HF_HUB_OFFLINE was set by helper -> removed.
|
||||
|
|
@ -970,16 +1011,403 @@ class TestHfOfflineIfDnsDead:
|
|||
# TRANSFORMERS_OFFLINE pre-existed -> preserved.
|
||||
assert os.environ.get("TRANSFORMERS_OFFLINE") == "1"
|
||||
|
||||
def test_exception_inside_block_still_restores_env(self, dns, clean_offline_env):
|
||||
def test_exception_inside_block_still_restores_env(self, dns, reachable, clean_offline_env):
|
||||
dns.fail()
|
||||
with pytest.raises(RuntimeError, match = "boom"):
|
||||
with _hf_offline_if_dns_dead():
|
||||
with _hf_offline_if_unreachable():
|
||||
raise RuntimeError("boom")
|
||||
# Cleanup must happen on exception as well.
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
assert "TRANSFORMERS_OFFLINE" not in os.environ
|
||||
|
||||
|
||||
class TestEndpointAwareOfflineDetection:
|
||||
"""A reachable HF_ENDPOINT mirror must not be declared offline just because
|
||||
huggingface.co does not resolve (air-gapped / corporate networks)."""
|
||||
|
||||
@pytest.fixture
|
||||
def no_upstream_dns(self, monkeypatch):
|
||||
def _host(h, *a, **k):
|
||||
if "huggingface.co" in str(h):
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
return "127.0.0.1"
|
||||
|
||||
def _addr(h, *a, **k):
|
||||
if "huggingface.co" in str(h):
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))]
|
||||
|
||||
monkeypatch.setattr(socket, "gethostbyname", _host)
|
||||
monkeypatch.setattr(socket, "getaddrinfo", _addr)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint,expected",
|
||||
[
|
||||
("https://hf-mirror.com", "hf-mirror.com"),
|
||||
("hf-mirror.com", "hf-mirror.com"),
|
||||
("https://hf-mirror.com:8443", "hf-mirror.com"),
|
||||
("https://hf-mirror.com/path", "hf-mirror.com"),
|
||||
("", "huggingface.co"),
|
||||
],
|
||||
)
|
||||
def test_endpoint_host_parsing(self, monkeypatch, endpoint, expected):
|
||||
from core.inference.llama_cpp import _hf_endpoint_host
|
||||
monkeypatch.setenv("HF_ENDPOINT", endpoint)
|
||||
assert _hf_endpoint_host() == expected
|
||||
|
||||
def test_dns_precheck_follows_endpoint(self, monkeypatch, no_upstream_dns):
|
||||
monkeypatch.setenv("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
assert _probe_dns_dead() is False
|
||||
|
||||
def test_default_endpoint_still_probes_huggingface(self, monkeypatch, no_upstream_dns):
|
||||
monkeypatch.delenv("HF_ENDPOINT", raising = False)
|
||||
assert _probe_dns_dead() is True
|
||||
|
||||
|
||||
class TestProxyOnlyEgress:
|
||||
"""With a proxy, the proxy resolves the hub host, so local DNS proves nothing and
|
||||
must not be used to declare the hub offline."""
|
||||
|
||||
@pytest.fixture
|
||||
def dns_all_dead(self, monkeypatch):
|
||||
def _fail(*a, **k):
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
|
||||
monkeypatch.setattr(socket, "gethostbyname", _fail)
|
||||
monkeypatch.setattr(socket, "getaddrinfo", _fail)
|
||||
|
||||
def test_dns_shortcut_stands_down_when_proxy_configured(self, monkeypatch, dns_all_dead):
|
||||
from utils.utils import hf_dns_dead
|
||||
|
||||
monkeypatch.delenv("HF_ENDPOINT", raising = False)
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128")
|
||||
monkeypatch.setenv("HTTP_PROXY", "http://proxy.internal:3128")
|
||||
assert hf_dns_dead() is False
|
||||
|
||||
def test_dns_shortcut_applies_without_a_proxy(self, monkeypatch, dns_all_dead):
|
||||
from utils.utils import hf_dns_dead
|
||||
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "ALL_PROXY"):
|
||||
monkeypatch.delenv(key, raising = False)
|
||||
monkeypatch.setattr("utils.utils.hf_proxy_configured", lambda: False)
|
||||
assert hf_dns_dead() is True
|
||||
|
||||
def test_no_proxy_bypass_restores_the_shortcut(self, monkeypatch, dns_all_dead):
|
||||
"""A host listed in NO_PROXY does not go through the proxy, so DNS matters again."""
|
||||
from utils.utils import hf_proxy_configured
|
||||
|
||||
monkeypatch.setenv("HF_ENDPOINT", "https://huggingface.co")
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128")
|
||||
monkeypatch.setenv("NO_PROXY", "huggingface.co")
|
||||
assert hf_proxy_configured() is False
|
||||
|
||||
|
||||
class TestSlowLinkIsNotOffline:
|
||||
"""A slow but reachable endpoint must not be quarantined: that would fail an uncached
|
||||
load that would otherwise merely be slow. A blackholed route must still be offline."""
|
||||
|
||||
def _probe_raising(self, monkeypatch, exc):
|
||||
import urllib.request
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: (_ for _ in ()).throw(exc))
|
||||
from utils.transformers_version import hf_endpoint_unreachable
|
||||
|
||||
return hf_endpoint_unreachable
|
||||
|
||||
def test_timeout_with_tcp_egress_is_reachable(self, monkeypatch):
|
||||
import urllib.error
|
||||
|
||||
probe = self._probe_raising(monkeypatch, urllib.error.URLError(TimeoutError("slow")))
|
||||
monkeypatch.setattr("utils.utils.hf_tcp_reachable", lambda *a, **k: True)
|
||||
assert probe(timeout = 1) is False
|
||||
|
||||
def test_timeout_without_tcp_egress_is_offline(self, monkeypatch):
|
||||
import urllib.error
|
||||
|
||||
probe = self._probe_raising(monkeypatch, urllib.error.URLError(TimeoutError("dead")))
|
||||
monkeypatch.setattr("utils.utils.hf_tcp_reachable", lambda *a, **k: False)
|
||||
assert probe(timeout = 1) is True
|
||||
|
||||
def test_bare_timeout_error_takes_the_same_path(self, monkeypatch):
|
||||
probe = self._probe_raising(monkeypatch, TimeoutError("slow"))
|
||||
monkeypatch.setattr("utils.utils.hf_tcp_reachable", lambda *a, **k: True)
|
||||
assert probe(timeout = 1) is False
|
||||
|
||||
def test_refused_connection_counts_as_egress(self, monkeypatch):
|
||||
"""Something answered, so the network works even though nothing is listening."""
|
||||
import socket as _socket
|
||||
|
||||
from utils.utils import hf_tcp_reachable
|
||||
|
||||
def _refuse(*a, **k):
|
||||
raise ConnectionRefusedError()
|
||||
|
||||
monkeypatch.setattr(_socket, "create_connection", _refuse)
|
||||
assert hf_tcp_reachable(1, "http://127.0.0.1:9") is True
|
||||
|
||||
|
||||
class TestConcurrentGuardsHoldTheirOwnReference:
|
||||
"""Overlapping requests must each hold a reference. If a later guard no-ops because an
|
||||
earlier one already set HF_HUB_OFFLINE, the earlier guard's exit restores the network
|
||||
while the later request is still resolving hub files."""
|
||||
|
||||
def test_second_guard_engages_and_offline_survives_first_exit(
|
||||
self, monkeypatch, clean_offline_env
|
||||
):
|
||||
import threading
|
||||
|
||||
import core.inference.llama_cpp as lc
|
||||
|
||||
monkeypatch.setattr(lc, "_hf_unreachable", lambda: True)
|
||||
|
||||
a_in, b_in, a_done = threading.Event(), threading.Event(), threading.Event()
|
||||
seen: dict = {}
|
||||
|
||||
def worker_a():
|
||||
with lc._hf_offline_if_unreachable():
|
||||
a_in.set()
|
||||
b_in.wait(5)
|
||||
a_done.set()
|
||||
|
||||
def worker_b():
|
||||
a_in.wait(5)
|
||||
with lc._hf_offline_if_unreachable() as engaged:
|
||||
seen["engaged"] = engaged
|
||||
b_in.set()
|
||||
a_done.wait(5)
|
||||
seen["offline_after_a_exit"] = hf_constants.HF_HUB_OFFLINE
|
||||
seen["env_after_a_exit"] = os.environ.get("HF_HUB_OFFLINE")
|
||||
|
||||
ta, tb = threading.Thread(target = worker_a), threading.Thread(target = worker_b)
|
||||
ta.start()
|
||||
tb.start()
|
||||
ta.join(20)
|
||||
tb.join(20)
|
||||
|
||||
assert seen.get("engaged") is True, "second guard no-opped instead of taking a reference"
|
||||
assert seen.get("offline_after_a_exit") is True
|
||||
assert seen.get("env_after_a_exit") == "1"
|
||||
# Both windows closed -> fully restored.
|
||||
assert hf_constants.HF_HUB_OFFLINE is False
|
||||
assert "HF_HUB_OFFLINE" not in os.environ
|
||||
|
||||
def test_user_set_offline_is_still_a_noop(self, monkeypatch, clean_offline_env):
|
||||
import core.inference.llama_cpp as lc
|
||||
|
||||
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
||||
monkeypatch.setattr(lc, "_hf_unreachable", lambda: True)
|
||||
with lc._hf_offline_if_unreachable() as engaged:
|
||||
assert engaged is False
|
||||
assert os.environ["HF_HUB_OFFLINE"] == "1"
|
||||
|
||||
|
||||
class TestProxyTimeoutIsNotExcused:
|
||||
"""A live proxy proves the proxy is up, not that it can reach the hub."""
|
||||
|
||||
def _probe_timing_out(self, monkeypatch):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
monkeypatch.setattr(
|
||||
urllib.request,
|
||||
"urlopen",
|
||||
lambda *a, **k: (_ for _ in ()).throw(urllib.error.URLError(TimeoutError("slow"))),
|
||||
)
|
||||
from utils.transformers_version import hf_endpoint_unreachable
|
||||
|
||||
return hf_endpoint_unreachable
|
||||
|
||||
def test_timeout_through_a_proxy_stays_unreachable(self, monkeypatch):
|
||||
probe = self._probe_timing_out(monkeypatch)
|
||||
monkeypatch.setattr("utils.utils.hf_proxy_configured", lambda: True)
|
||||
# Even if the proxy itself accepts TCP, the hub behind it may be blackholed.
|
||||
monkeypatch.setattr("utils.utils.hf_tcp_reachable", lambda *a, **k: True)
|
||||
assert probe(timeout = 1) is True
|
||||
|
||||
def test_timeout_without_a_proxy_still_uses_tcp(self, monkeypatch):
|
||||
probe = self._probe_timing_out(monkeypatch)
|
||||
monkeypatch.setattr("utils.utils.hf_proxy_configured", lambda: False)
|
||||
monkeypatch.setattr("utils.utils.hf_tcp_reachable", lambda *a, **k: True)
|
||||
assert probe(timeout = 1) is False
|
||||
|
||||
|
||||
class TestEndpointNormalisation:
|
||||
"""An empty or whitespace HF_ENDPOINT must fall back to the default hub in BOTH the
|
||||
DNS shortcut and the HTTP probe, or the two stages disagree."""
|
||||
|
||||
@pytest.mark.parametrize("value", ["", " ", "\t"])
|
||||
def test_blank_endpoint_falls_back(self, monkeypatch, value):
|
||||
from utils.utils import hf_endpoint_host, hf_endpoint_url
|
||||
|
||||
monkeypatch.setenv("HF_ENDPOINT", value)
|
||||
assert hf_endpoint_host() == "huggingface.co"
|
||||
assert hf_endpoint_url() == "https://huggingface.co"
|
||||
|
||||
def test_probe_uses_the_same_normalised_url(self, monkeypatch):
|
||||
seen: list = []
|
||||
import urllib.request
|
||||
|
||||
def _capture(req, *a, **k):
|
||||
seen.append(req.full_url)
|
||||
raise urllib.error.URLError("stop")
|
||||
|
||||
import urllib.error
|
||||
|
||||
monkeypatch.setenv("HF_ENDPOINT", " ")
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _capture)
|
||||
from utils.transformers_version import hf_endpoint_unreachable
|
||||
|
||||
hf_endpoint_unreachable(timeout = 1)
|
||||
assert seen and seen[0].startswith("https://huggingface.co"), seen
|
||||
|
||||
|
||||
class TestIpv6Endpoint:
|
||||
def test_ipv6_literal_resolves(self):
|
||||
"""gethostbyname is IPv4-only and would call an AAAA-only mirror dead."""
|
||||
from utils.utils import dns_host_dead
|
||||
assert dns_host_dead("::1", timeout = 2.0) is False
|
||||
|
||||
def test_unresolvable_host_still_dead(self):
|
||||
from utils.utils import dns_host_dead
|
||||
assert dns_host_dead("no-such-host.invalid", timeout = 2.0) is True
|
||||
|
||||
|
||||
class TestGuardSkipsLocalPaths:
|
||||
"""A local path never reaches the hub, so probing costs time and prevents nothing."""
|
||||
|
||||
def test_local_path_is_a_noop(self, tmp_path, monkeypatch):
|
||||
from core.inference.llama_cpp import _hf_offline_if_unreachable_for
|
||||
|
||||
called: list = []
|
||||
monkeypatch.setattr("utils.utils.hf_unreachable", lambda *a, **k: called.append(1) or True)
|
||||
with _hf_offline_if_unreachable_for(str(tmp_path / "model.gguf")) as engaged:
|
||||
assert engaged is None # nullcontext yields None
|
||||
assert called == [], "probed the hub for a local path"
|
||||
|
||||
def test_remote_id_still_guarded(self, monkeypatch, clean_offline_env):
|
||||
import core.inference.llama_cpp as lc
|
||||
monkeypatch.setattr(lc, "_hf_unreachable", lambda: True)
|
||||
with lc._hf_offline_if_unreachable_for("unsloth/Qwen3.5-4B-GGUF") as engaged:
|
||||
assert engaged is True
|
||||
assert os.environ.get("HF_HUB_OFFLINE") == "1"
|
||||
|
||||
|
||||
class TestGatewayErrorsAreNotConnectionFailures:
|
||||
"""Lifetime offline flags must not be set by a momentary 502/503/504."""
|
||||
|
||||
def _probe_with(self, monkeypatch, exc):
|
||||
import urllib.request
|
||||
|
||||
def _urlopen(*a, **k):
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _urlopen)
|
||||
from utils.transformers_version import hf_endpoint_unreachable
|
||||
|
||||
return hf_endpoint_unreachable
|
||||
|
||||
@pytest.mark.parametrize("code", [502, 503, 504])
|
||||
def test_strict_mode_treats_gateway_error_as_reachable(self, monkeypatch, code):
|
||||
import urllib.error
|
||||
|
||||
exc = urllib.error.HTTPError("u", code, "err", {}, None)
|
||||
probe = self._probe_with(monkeypatch, exc)
|
||||
assert probe(timeout = 1, gateway_errors_offline = False) is False
|
||||
# Default (scoped callers) keeps treating a downed hub as offline.
|
||||
assert probe(timeout = 1) is True
|
||||
|
||||
@pytest.mark.parametrize("code", [401, 403, 404, 429])
|
||||
def test_other_http_errors_always_reachable(self, monkeypatch, code):
|
||||
import urllib.error
|
||||
|
||||
exc = urllib.error.HTTPError("u", code, "err", {}, None)
|
||||
probe = self._probe_with(monkeypatch, exc)
|
||||
assert probe(timeout = 1) is False
|
||||
assert probe(timeout = 1, gateway_errors_offline = False) is False
|
||||
|
||||
|
||||
class TestHfUnreachableProbe:
|
||||
"""``utils.utils.hf_unreachable``: memoised, opt-outable, fails open."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _reset(self):
|
||||
from utils.utils import reset_hf_reachability_cache
|
||||
|
||||
reset_hf_reachability_cache()
|
||||
yield
|
||||
reset_hf_reachability_cache()
|
||||
|
||||
def _patch_probe(self, monkeypatch, result, calls):
|
||||
import utils.transformers_version as tv
|
||||
def _probe(*_a, **_k):
|
||||
calls.append(1)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(tv, "hf_endpoint_unreachable", _probe)
|
||||
|
||||
def test_probes_once_then_memoises(self, monkeypatch, clean_offline_env):
|
||||
from utils.utils import hf_unreachable
|
||||
|
||||
calls: list = []
|
||||
self._patch_probe(monkeypatch, True, calls)
|
||||
assert hf_unreachable() is True
|
||||
assert hf_unreachable() is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_opt_out_skips_probe(self, monkeypatch, clean_offline_env):
|
||||
from utils.utils import hf_unreachable
|
||||
|
||||
calls: list = []
|
||||
self._patch_probe(monkeypatch, True, calls)
|
||||
monkeypatch.setenv("UNSLOTH_OFFLINE_PROBE", "0")
|
||||
assert hf_unreachable() is False
|
||||
assert calls == []
|
||||
|
||||
def test_probe_failure_reports_reachable(self, monkeypatch, clean_offline_env):
|
||||
from utils.utils import hf_unreachable
|
||||
|
||||
calls: list = []
|
||||
self._patch_probe(monkeypatch, RuntimeError("boom"), calls)
|
||||
# Fail open: a broken probe must not strand a working install offline.
|
||||
assert hf_unreachable() is False
|
||||
|
||||
def test_reset_forces_reprobe(self, monkeypatch, clean_offline_env):
|
||||
from utils.utils import hf_unreachable, reset_hf_reachability_cache
|
||||
|
||||
calls: list = []
|
||||
self._patch_probe(monkeypatch, True, calls)
|
||||
assert hf_unreachable() is True
|
||||
reset_hf_reachability_cache()
|
||||
assert hf_unreachable() is True
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_memo_window_is_short_in_both_directions(self):
|
||||
"""Stale either way is a bug: a stale 'reachable' hides the plug being pulled,
|
||||
a stale 'unreachable' fails a download after the user reconnects."""
|
||||
import utils.utils as uu
|
||||
assert uu._HF_REACHABILITY_TTL_S <= 10.0
|
||||
|
||||
def test_verdict_expires_so_a_disconnect_is_noticed(self, monkeypatch, clean_offline_env):
|
||||
import time as _time
|
||||
|
||||
import utils.utils as uu
|
||||
from utils.utils import hf_unreachable
|
||||
|
||||
monkeypatch.setattr(uu, "_HF_REACHABILITY_TTL_S", 0.2)
|
||||
verdict = {"value": False}
|
||||
monkeypatch.setattr(
|
||||
__import__("utils.transformers_version", fromlist = ["x"]),
|
||||
"hf_endpoint_unreachable",
|
||||
lambda *a, **k: verdict["value"],
|
||||
)
|
||||
assert hf_unreachable() is False # online during the download
|
||||
verdict["value"] = True # plug pulled
|
||||
_time.sleep(0.3)
|
||||
assert hf_unreachable() is True
|
||||
|
||||
|
||||
class TestExtractQuantLabelSubdir:
|
||||
"""``_extract_quant_label`` must consider parent dirs when the basename has
|
||||
no quant token (subdir layouts like ``BF16/foo.gguf``)."""
|
||||
|
|
|
|||
|
|
@ -218,6 +218,23 @@ class TestTrainingWorkerProbeNoGlobalTimeout:
|
|||
"training worker still calls socket.setdefaulttimeout; "
|
||||
"concurrent sockets would inherit the probe timeout"
|
||||
)
|
||||
assert (
|
||||
"threading" in block and "Thread" in block
|
||||
), "training worker probe must run on a daemon thread"
|
||||
# The probe now lives in the shared helper (endpoint- and proxy-aware), so the
|
||||
# worker must delegate to it rather than resolve a hardcoded host itself.
|
||||
assert "hf_dns_dead" in block, "training worker must use the shared DNS helper"
|
||||
assert 'gethostbyname("huggingface.co")' not in block, (
|
||||
"training worker must not hardcode huggingface.co; a reachable HF_ENDPOINT "
|
||||
"mirror would be declared offline"
|
||||
)
|
||||
|
||||
def test_shared_dns_helper_uses_thread_probe(self):
|
||||
"""The daemon-thread property moved with the probe; pin it where it now lives."""
|
||||
import inspect
|
||||
|
||||
from utils.utils import dns_host_dead
|
||||
|
||||
src = inspect.getsource(dns_host_dead)
|
||||
assert ".setdefaulttimeout(" not in src, (
|
||||
"shared DNS probe calls socket.setdefaulttimeout; "
|
||||
"concurrent sockets would inherit the probe timeout"
|
||||
)
|
||||
assert "Thread" in src and "daemon" in src, "shared DNS probe must run on a daemon thread"
|
||||
|
|
|
|||
|
|
@ -63,10 +63,13 @@ def _env_offline() -> bool:
|
|||
)
|
||||
|
||||
|
||||
def hf_endpoint_unreachable(timeout: int = 3) -> bool:
|
||||
def hf_endpoint_unreachable(timeout: int = 3, *, gateway_errors_offline: bool = True) -> bool:
|
||||
"""Bounded reachability probe to the HF endpoint. A HEAD request runs in a daemon thread
|
||||
joined with a deadline, so a resolver blackhole cannot block past ~timeout+1s. True if
|
||||
unreachable. urllib natively honors *_PROXY / NO_PROXY, so this verifies real egress
|
||||
unreachable. A clean socket timeout is resolved with a TCP connect, so a slow-but-live
|
||||
endpoint is not mistaken for no egress (a hang past the deadline still counts as
|
||||
unreachable, since the real hub calls would hang too).
|
||||
urllib natively honors *_PROXY / NO_PROXY, so this verifies real egress
|
||||
(the proxy can reach HF), not just that the proxy is up. No ML imports, so it is safe to
|
||||
call before transformers version activation. Mirrors the probe in export._hf_offline."""
|
||||
import ssl
|
||||
|
|
@ -74,11 +77,17 @@ def hf_endpoint_unreachable(timeout: int = 3) -> bool:
|
|||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
endpoint = os.environ.get("HF_ENDPOINT", "https://huggingface.co")
|
||||
# Shared normaliser, so an empty or whitespace HF_ENDPOINT falls back to the default
|
||||
# hub here exactly as it does in the DNS shortcut, instead of probing "https://".
|
||||
try:
|
||||
from utils.utils import hf_endpoint_url
|
||||
endpoint = hf_endpoint_url()
|
||||
except Exception:
|
||||
endpoint = (os.environ.get("HF_ENDPOINT") or "").strip() or "https://huggingface.co"
|
||||
if "://" not in endpoint:
|
||||
endpoint = "https://" + endpoint
|
||||
|
||||
result = {"online": False}
|
||||
result = {"online": False, "timed_out": False}
|
||||
|
||||
def _probe():
|
||||
try:
|
||||
|
|
@ -86,21 +95,52 @@ def hf_endpoint_unreachable(timeout: int = 3) -> bool:
|
|||
with urllib.request.urlopen(req, timeout = timeout):
|
||||
result["online"] = True
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The server/proxy answered: reachable unless it is a gateway error.
|
||||
result["online"] = exc.code not in (502, 503, 504)
|
||||
# The server/proxy answered, so we have egress. A gateway error usually means
|
||||
# the hub itself is down, which callers scoping offline to one operation want
|
||||
# to treat as offline; callers setting a lifetime flag pass
|
||||
# gateway_errors_offline=False so a momentary 503 can't strand the process.
|
||||
result["online"] = (
|
||||
True if not gateway_errors_offline else exc.code not in (502, 503, 504)
|
||||
)
|
||||
except urllib.error.URLError as exc:
|
||||
# A TLS/cert failure means we DID reach the server; treat as reachable so the real
|
||||
# load surfaces it (consistent with _is_offline_related_error not retrying TLS).
|
||||
result["online"] = isinstance(exc.reason, ssl.SSLError)
|
||||
if isinstance(exc.reason, ssl.SSLError):
|
||||
result["online"] = True
|
||||
elif isinstance(exc.reason, TimeoutError):
|
||||
# Resolved below, off-thread, so the extra probe cannot outrun the join.
|
||||
result["timed_out"] = True
|
||||
else:
|
||||
result["online"] = False
|
||||
except ssl.SSLError:
|
||||
result["online"] = True
|
||||
except TimeoutError:
|
||||
result["timed_out"] = True
|
||||
except Exception:
|
||||
result["online"] = False
|
||||
|
||||
t = threading.Thread(target = _probe, daemon = True)
|
||||
t.start()
|
||||
t.join(timeout + 1)
|
||||
return t.is_alive() or not result["online"]
|
||||
if t.is_alive():
|
||||
# Hung past its own timeout (wedged resolver): the real hub calls would hang the
|
||||
# same way, so cache-only is the useful answer. Distinct from the slow-answer case
|
||||
# below, where the socket timed out cleanly.
|
||||
return True
|
||||
if result["timed_out"]:
|
||||
# A slow server still completes the TCP handshake; a blackholed route does not.
|
||||
# Bounded separately so the whole probe stays within a predictable deadline.
|
||||
try:
|
||||
from utils.utils import hf_proxy_configured, hf_tcp_reachable
|
||||
if hf_proxy_configured():
|
||||
# Through a proxy the handshake only proves the proxy is up, not that it
|
||||
# can reach the hub, so a timeout stays unreachable rather than being
|
||||
# excused by a live proxy with a dead upstream.
|
||||
return True
|
||||
return not hf_tcp_reachable(min(timeout, 2.0), endpoint)
|
||||
except Exception:
|
||||
return True
|
||||
return not result["online"]
|
||||
|
||||
|
||||
def _safe_is_file(p: Path) -> bool:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
import os
|
||||
import structlog
|
||||
import threading
|
||||
import time
|
||||
from loggers import get_logger
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
|
@ -35,6 +37,286 @@ def hf_env_offline() -> bool:
|
|||
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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue