diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 144aa1fd37..0b29f42501 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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, diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index b858fe6f17..daee45950f 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -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 diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index baf6329dae..ac27750373 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -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 + _offline = False + try: + from utils.utils import hf_dns_dead, hf_probe_disabled - # Daemon thread so we don't mutate process-wide setdefaulttimeout. - _result: list = [None] - - def _probe() -> None: - try: - _socket.gethostbyname("huggingface.co") - _result[0] = False - except Exception: - _result[0] = True - - _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, ) diff --git a/studio/backend/picker/routes/templates.py b/studio/backend/picker/routes/templates.py index 02b8bf7184..628cc03841 100644 --- a/studio/backend/picker/routes/templates.py +++ b/studio/backend/picker/routes/templates.py @@ -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) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index d0a2d97f74..6f4cddc146 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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, diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 96c5b96d73..58b7433070 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -1855,75 +1855,81 @@ 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: - if not is_local_path(model_name): - resolved = resolve_cached_repo_id_case(model_name) - if resolved != model_name: - logger.info( - "Using cached repo_id casing '%s' for requested '%s'", - resolved, - model_name, - ) - model_name = resolved + # 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: + logger.info( + "Using cached repo_id casing '%s' for requested '%s'", + resolved, + model_name, + ) + model_name = resolved - logger.info(f"Getting model config for: {model_name}") - from utils.models.model_config import detect_audio_type + logger.info(f"Getting model config for: {model_name}") + from utils.models.model_config import detect_audio_type - config_dict = load_model_defaults(model_name) + config_dict = load_model_defaults(model_name) - # Detect capabilities (HF token for gated models). - is_vision = is_vision_model(model_name, hf_token = hf_token) - is_embedding = is_embedding_model(model_name, hf_token = hf_token) - audio_type = detect_audio_type(model_name, hf_token = hf_token) + # Detect capabilities (HF token for gated models). + is_vision = is_vision_model(model_name, hf_token = hf_token) + is_embedding = is_embedding_model(model_name, hf_token = hf_token) + audio_type = detect_audio_type(model_name, hf_token = hf_token) - is_lora = False - base_model = None - max_position_embeddings = None - try: - model_config = ModelConfig.from_identifier(model_name) - is_lora = model_config.is_lora - base_model = model_config.base_model if is_lora else None - max_position_embeddings = _get_max_position_embeddings(model_config) - except Exception: - pass - - # Fallback: read raw config.json (declarative fields only) -- a selection-time - # metadata probe that must never execute a repo's auto_map Python. - if max_position_embeddings is None: + is_lora = False + base_model = None + max_position_embeddings = None try: - from utils.transformers_version import _load_config_json - from types import SimpleNamespace - - _cfg = _load_config_json(model_name, hf_token = hf_token) - if _cfg is not None: - - def _to_ns(d): - if isinstance(d, dict): - return SimpleNamespace(**{k: _to_ns(v) for k, v in d.items()}) - return d - - max_position_embeddings = _get_max_position_embeddings(_to_ns(_cfg)) + model_config = ModelConfig.from_identifier(model_name) + is_lora = model_config.is_lora + base_model = model_config.base_model if is_lora else None + max_position_embeddings = _get_max_position_embeddings(model_config) except Exception: pass - logger.info( - f"Model config result for {model_name}: is_vision={is_vision}, is_embedding={is_embedding}, audio_type={audio_type}, is_lora={is_lora}, max_position_embeddings={max_position_embeddings}" - ) - return ModelDetails( - id = model_name, - model_name = model_name, - config = config_dict, - is_vision = is_vision, - is_embedding = is_embedding, - is_lora = is_lora, - is_audio = audio_type is not None, - audio_type = audio_type, - has_audio_input = is_audio_input_type(audio_type), - model_type = derive_model_type(is_vision, audio_type, is_embedding), - base_model = base_model, - max_position_embeddings = max_position_embeddings, - model_size_bytes = _get_model_size_bytes(model_name, hf_token), - ) + # Fallback: read raw config.json (declarative fields only) -- a selection-time + # metadata probe that must never execute a repo's auto_map Python. + if max_position_embeddings is None: + try: + from utils.transformers_version import _load_config_json + from types import SimpleNamespace + + _cfg = _load_config_json(model_name, hf_token = hf_token) + if _cfg is not None: + + def _to_ns(d): + if isinstance(d, dict): + return SimpleNamespace(**{k: _to_ns(v) for k, v in d.items()}) + return d + + max_position_embeddings = _get_max_position_embeddings(_to_ns(_cfg)) + except Exception: + pass + + logger.info( + f"Model config result for {model_name}: is_vision={is_vision}, is_embedding={is_embedding}, audio_type={audio_type}, is_lora={is_lora}, max_position_embeddings={max_position_embeddings}" + ) + return ModelDetails( + id = model_name, + model_name = model_name, + config = config_dict, + is_vision = is_vision, + is_embedding = is_embedding, + is_lora = is_lora, + is_audio = audio_type is not None, + audio_type = audio_type, + has_audio_input = is_audio_input_type(audio_type), + model_type = derive_model_type(is_vision, audio_type, is_embedding), + base_model = base_model, + max_position_embeddings = max_position_embeddings, + model_size_bytes = _get_model_size_bytes(model_name, hf_token), + ) except Exception as e: raise log_and_http_error( @@ -2585,7 +2591,12 @@ 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). - is_vision = is_vision_model(model_name, hf_token = hf_token) + # 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}") return VisionCheckResponse( diff --git a/studio/backend/tests/test_active_generations.py b/studio/backend/tests/test_active_generations.py index aa087fe4ea..2f86f8efdc 100644 --- a/studio/backend/tests/test_active_generations.py +++ b/studio/backend/tests/test_active_generations.py @@ -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", diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 6ec9c44e88..cce266446d 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -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)), ): diff --git a/studio/backend/tests/test_gguf_load_cache_reuse.py b/studio/backend/tests/test_gguf_load_cache_reuse.py index ccbe50bcb9..8b68af885a 100644 --- a/studio/backend/tests/test_gguf_load_cache_reuse.py +++ b/studio/backend/tests/test_gguf_load_cache_reuse.py @@ -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), ): diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index 43365bd3ca..354ce1dccc 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -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(), ) diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 7999eb4f73..93dff5cbd5 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -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), diff --git a/studio/backend/tests/test_offline_gguf_cache_fallback.py b/studio/backend/tests/test_offline_gguf_cache_fallback.py index d1e61d0546..61a30ebaed 100644 --- a/studio/backend/tests/test_offline_gguf_cache_fallback.py +++ b/studio/backend/tests/test_offline_gguf_cache_fallback.py @@ -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``).""" diff --git a/studio/backend/tests/test_offline_inference_parent.py b/studio/backend/tests/test_offline_inference_parent.py index 3e9f09bb2f..c8c440be46 100644 --- a/studio/backend/tests/test_offline_inference_parent.py +++ b/studio/backend/tests/test_offline_inference_parent.py @@ -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" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index b0a2da0e66..8af30a2d1b 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -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") - if "://" not in endpoint: - endpoint = "https://" + endpoint + # 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: diff --git a/studio/backend/utils/utils.py b/studio/backend/utils/utils.py index e4964b8d04..f536fdaf9c 100644 --- a/studio/backend/utils/utils.py +++ b/studio/backend/utils/utils.py @@ -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."""