Skip to content

Offline: detect an unreachable hub, not just dead DNS - #7591

Merged
danielhanchen merged 52 commits into
mainfrom
fix/offline-load-unreachable-hub
Jul 31, 2026
Merged

Offline: detect an unreachable hub, not just dead DNS#7591
danielhanchen merged 52 commits into
mainfrom
fix/offline-load-unreachable-hub

Conversation

@shimmyshimmer

Copy link
Copy Markdown
Member

The problem

A user reported that after downloading a model, turning off the internet and reloading it, Studio tries to fetch config.json from Hugging Face and the model never becomes usable.

It reproduces. Loading an already-downloaded GGUF with no connectivity took 11.4 minutes, of which 681 seconds were failed hub calls and 5.1 seconds were real work:

+227.9s  HF API unreachable ... using local cache to detect GGUF
 +75.0s  HF API unreachable ... using local cache snapshot
 +75.0s  Could not list repo files ... (connect timeout=None)
 +75.0s  Resolved variant from local HF cache
+228.1s  -> Reusing cached GGUF (it was there all along)
  +5.1s  llama-server ready

The model is not broken. Inference answers in 202 ms once loaded. The load just looks like a hang.

Root cause

Two separate problems, both in the offline guard.

1. We only checked DNS. _hf_offline_if_dns_dead decided we were offline only if huggingface.co failed to resolve. Plenty of genuinely offline setups still resolve names: WAN down behind a live router, captive portal, VPN split-DNS, corporate resolver, or just a stale OS DNS cache right after toggling wifi off. In those the guard never fired.

The three detectors, measured with the endpoint blackholed:

_env_offline()            -> False  (0.0s)  [env var only]
_probe_dns_dead()         -> False  (0.0s)  [DNS only]      <- what the load path used
hf_endpoint_unreachable() -> True   (3.0s)  [real reachability]

The correct probe already existed and was already shipping in the export path. It just was not used anywhere else.

2. Setting the env vars mid-process does nothing. Even once the guard fired, huggingface_hub and transformers read their offline constants at import time, and hub sessions cache a non-offline adapter. os.environ["HF_HUB_OFFLINE"] = "1" after startup left every call retrying as before. This is why the first version of this fix only got 686s down to 385s.

# before: 75s of retries
list_repo_files("unsloth/Qwen3.5-4B-GGUF")

# after force_hf_offline(): raises OfflineModeIsEnabled in 0.00s

The fix

  • utils/utils.py: hf_unreachable(), the bounded proxy-aware probe memoised for 60s, and force_hf_offline(), which flips the in-process constants and rebuilds hub sessions, restoring everything on exit.
  • llama_cpp.py: the guard escalates DNS -> reachability and forces offline in-process. Renamed _hf_offline_if_dns_dead to _hf_offline_if_unreachable since it is no longer DNS-only.
  • Guards the metadata routes that had none: /models/config, /models/check-vision, /picker/chat-template, and the per-request vision probe in _target_is_vision.
  • Training worker uses the same detection. It previously hardcoded huggingface.co and ignored HF_ENDPOINT, so mirror users got the wrong answer even online.

Results

Cached GGUF repo, endpoint blackholed:

Endpoint Before After
POST /inference/load 686s 4s
GET /models/config 378s 0s
GET /models/check-vision 28s 0s
GET /picker/chat-template 0s 0s

Compatibility

Online behaviour is unchanged. The probe only runs when the offline env vars are not already set, and when the endpoint is reachable the guard is a no-op, so the online path is byte-identical to today. Verified on the patched build with real internet:

  • cached-model endpoints return correct data
  • remote listing for a non-cached repo works (no false offline)
  • a fresh download of unsloth/SmolLM2-135M-Instruct-GGUF resolves, downloads, loads and chats in 4s

Three further safeguards:

  • hf_endpoint_unreachable treats an HTTP or TLS response as reachable, so only a genuine connection failure counts as offline.
  • hf_unreachable() fails open: if the probe itself errors, we report reachable and let the load decide.
  • UNSLOTH_OFFLINE_PROBE=0 restores the old DNS-only behaviour, matching the existing opt-out in the export path.

No new hardware or backend path is introduced. Nothing touches GPU selection, llama.cpp arguments, model formats or training kernels.

Tests

Full studio backend suite, before and after, on the same machine:

  • clean main: 31 failed, 11142 passed
  • this branch: 31 failed, 11148 passed

The failure lists are byte-identical (diff returns nothing). Those 31 are pre-existing environment failures on this box: no CUDA, flash-attn, cache-settings. The +6 are new tests here.

Added to test_offline_gguf_cache_fallback.py:

  • DNS resolves but the endpoint is unreachable -> guard engages
  • UNSLOTH_OFFLINE_PROBE=0 -> DNS-only behaviour preserved
  • probe is memoised, opts out, fails open, and resets on demand

Existing guard tests now pin reachability explicitly so no test can reach the network.

Not covered here

/models/check-vision returns is_vision: false for a GGUF repo that ships an mmproj. That is pre-existing and unrelated to connectivity (it reproduces online too): is_vision_model only does the mmproj check for local paths, while the load path resolves it correctly via ModelConfig.from_identifier. This PR makes that route fast but deliberately leaves its semantics alone. Happy to fix separately.

Loading an already-downloaded model with no internet took 11 minutes. The
offline guard only checked whether huggingface.co resolved, so the common
offline shapes where DNS still answers (WAN down behind a live router,
captive portal, stale DNS cache) were treated as online and every hub call
burned its full retry backoff.

Two fixes:

- Escalate from the DNS check to the bounded, proxy-aware reachability
  probe already used by export, memoised for 60s and opt-outable with
  UNSLOTH_OFFLINE_PROBE=0.
- Force offline in-process, not just via env vars. huggingface_hub and
  transformers read their offline constants at import and hub sessions
  cache a non-offline adapter, so setting the env mid-process left the
  calls retrying anyway.

Also guards the metadata routes that had none (/models/config,
/models/check-vision, /picker/chat-template, the per-request vision probe)
and applies the same detection in the training worker.

Measured on a cached GGUF repo with the endpoint blackholed:

  POST /inference/load        686s -> 4s
  GET  /models/config         378s -> 0s
  GET  /models/check-vision    28s -> 0s

Online is unchanged: reachable endpoints skip the guard entirely, and a
fresh download still resolves, downloads and loads normally.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: adbf88d8b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +77 to +79
cached = _hf_reachability
if cached is not None and time.monotonic() - cached[0] < _HF_REACHABILITY_TTL_S:
return cached[1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-probe after a cached reachable verdict

When the endpoint was reachable during an initial download and the WAN is then disconnected while DNS continues resolving, the cached False result is reused for 60 seconds. An immediate reload—the exact download-then-go-offline workflow this change targets—therefore skips the egress probe and can still enter the full Hugging Face retry backoff. Cache only unreachable results, or invalidate/re-probe reachable results before a guarded load.

Useful? React with 👍 / 👎.

Comment on lines +536 to +537
if _probe_dns_dead():
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Probe the configured endpoint before forcing offline

When HF_ENDPOINT points to a reachable mirror but huggingface.co does not resolve—for example on a restricted corporate network—this hard-coded DNS check returns early and never runs the endpoint-aware probe. The newly guarded config, vision, and template routes are then forced offline and cannot fetch uncached metadata from the working mirror. The preliminary DNS check must use the configured endpoint or be removed in favor of hf_endpoint_unreachable().

Useful? React with 👍 / 👎.

Comment thread studio/backend/core/training/worker.py Outdated
Comment on lines +2371 to +2372

if not hf_probe_disabled() and hf_endpoint_unreachable():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep transient gateway responses from disabling training

When the startup HEAD probe receives a transient 502, 503, or 504, hf_endpoint_unreachable() classifies that HTTP response as unreachable and this branch permanently sets HF_HUB_OFFLINE, TRANSFORMERS_OFFLINE, and HF_DATASETS_OFFLINE for the worker. A brief Hub or proxy outage at startup therefore prevents every uncached model or dataset download for the entire training job even if the service immediately recovers; only connection failures should set lifetime offline flags, or the worker must re-probe before remote loads.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines +3675 to +3678
# Guarded: this runs per request, so an unreachable hub would re-pay its retry
# backoff on every image/audio call.
with _hf_offline_if_unreachable():
return bool(is_vision_model(load_path, hf_token = os.environ.get("HF_TOKEN")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip the reachability probe for resolved local vision targets

During an image or audio auto-switch, target_id is guaranteed by _resolve_and_switch to be a concrete local path, and is_vision_model() returns from its local-GGUF/mmproj filesystem branch before any Hub access. Wrapping that check nevertheless adds a DNS lookup and potentially the full four-second HEAD timeout to the request when the WAN is unavailable but DNS still resolves, turning an otherwise immediate local capability check into a multi-second stall without preventing any retry.

Useful? React with 👍 / 👎.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

Thanks, all four were real. I reproduced each one against the branch before changing anything, and re-measured after.

Finding Reproduced? Fix
P1 stale reachable verdict Yes, and worse than described: still stale after 6s (60s TTL) Memo window cut to 5s in both directions
P1 DNS pre-check ignores HF_ENDPOINT Yes, a live mirror was forced offline Pre-check now resolves the configured endpoint's host
P2 gateway 502/503/504 pins the worker offline Yes, and the flags last the whole job Added gateway_errors_offline=False for lifetime callers
P2 guard on a local-path vision probe Yes, guard was present Removed

On the memo window, I did not go with "cache only unreachable results". Both directions are unsafe to hold: a stale reachable misses the disconnect (the case this path exists for), and a stale unreachable fails a download after the user reconnects. So the window is now 5s for both, which still dedupes the several guard entries within one load while bounding staleness. Not caching at all would cost a HEAD per guard entry, roughly 1.5s added to every online load.

On the gateway errors, I kept the default as-is so the scoped export callers are unchanged, and only the training worker (which sets flags for the whole job) opts into the strict mode. A downed hub should still send a single scoped operation to the cache.

On _target_is_vision, confirmed by the resolver: line 4288 states load_path is "a concrete local path (never the bare repo id)", and is_vision_model returns from its local-GGUF/mmproj branch before any hub access, so the guard bought nothing.

Verification after the fixes:

  • offline path unchanged: load 686s -> 5s, /models/config 378s -> 0s, /models/check-vision 28s -> 0s
  • full studio backend suite: 31 failed / 11164 passed, byte-identical failure list to clean main (those 31 are pre-existing CUDA/flash-attn/cache-settings failures on my machine). +22 tests from this PR
  • separate simulation suite of 78 cases run across a version matrix: huggingface_hub 0.25.2 through 0.36.2, with and without transformers, Python 3.10 to 3.13, all green. That matrix specifically covers the hub session-reset API moving between releases, which is what force_hf_offline depends on

New regression tests cover each finding: endpoint host parsing, DNS pre-check following HF_ENDPOINT, strict vs default gateway handling, and verdict expiry in both directions.

Unsloth and others added 2 commits July 28, 2026 22:31
…y mode

Four issues raised on the first commit, all reproduced before fixing:

- The DNS pre-check hardcoded huggingface.co, so a reachable HF_ENDPOINT
  mirror was forced offline whenever huggingface.co did not resolve. It now
  follows the configured endpoint.
- The reachability verdict was memoised for 60s, and a stale "reachable"
  hid the user pulling the plug right after a download, which is the exact
  workflow this fix targets. Window is now 5s in both directions: long
  enough to dedupe the probes within one load, short enough that neither
  direction goes stale.
- hf_endpoint_unreachable counts 502/503/504 as offline, and the training
  worker used it to set flags for the whole job, so a momentary hub blip
  blocked every download for the rest of the run. Added
  gateway_errors_offline=False for callers setting lifetime flags; scoped
  callers keep the existing behaviour.
- Dropped the guard from _target_is_vision. The resolver only yields local
  paths there, so it returns from the mmproj filesystem branch without
  touching the hub, and the probe only added latency per request.

Verified unchanged offline: load 686s -> 5s, /models/config 378s -> 0s,
/models/check-vision 28s -> 0s.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ec30c54d1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/core/training/worker.py Outdated
_t = _threading.Thread(target = _probe, daemon = True)
_t.start()
_t.join(2.0)
if _result[0] is False:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the worker probe against HF_ENDPOINT

When a training worker runs with HF_ENDPOINT pointing at a reachable mirror but huggingface.co itself does not resolve, the hard-coded DNS probe above sets _result[0] to True, so this if _result[0] is False block skips the endpoint-aware hf_endpoint_unreachable() probe and the worker then sets lifetime HF_HUB_OFFLINE/HF_DATASETS_OFFLINE flags. That prevents model or dataset downloads from the configured mirror even though it is reachable; use the configured endpoint for the DNS precheck or defer to the endpoint probe before setting permanent offline flags.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/models.py Outdated
# Offline the guard keeps this on the HF cache instead of retrying the hub.
from core.inference.llama_cpp import _hf_offline_if_unreachable

with _hf_offline_if_unreachable():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip the probe for local vision checks

When model_name is a local GGUF/path, is_vision_model() returns from its filesystem/mmproj branch before any Hub access, but this new unconditional wrapper still runs the HF reachability probe first. In the offline shape where DNS still resolves, every local /models/check-vision request waits for the bounded HEAD timeout even though no Hub retry is being avoided; only wrap non-local model IDs.

Useful? React with 👍 / 👎.

from core.inference.llama_cpp import _hf_offline_if_unreachable

def _read():
with _hf_offline_if_unreachable():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip the probe for local template reads

When model_name is a local path or GGUF, read_default_chat_template() stays on the filesystem and reads sidecar files or GGUF metadata, but this new wrapper still runs the HF reachability probe first. In WAN-down setups where DNS continues to resolve, opening the chat-template picker for a local model waits for the HEAD timeout even though no Hub call would have happened; guard only remote repo IDs or move the guard into the remote branch.

Useful? React with 👍 / 👎.

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.
"""
if _probe_dns_dead():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall through to the proxy-aware probe

When HTTPS_PROXY/HTTP_PROXY is the only path that can resolve and reach the Hub host, local DNS can fail while the proxy-aware urllib probe would still succeed. The fresh case is proxy-only DNS: this early return bypasses hf_unreachable() entirely and forces the newly guarded routes into cache-only mode, so uncached metadata/downloads fail despite working egress through the proxy; let the endpoint probe run before declaring the Hub offline.

Useful? React with 👍 / 👎.

…ocal skip

All four reproduced before fixing, and re-measured after.

- Proxy-only egress was declared offline. With HTTP(S)_PROXY set, the proxy
  resolves the hub host, so a failing local lookup says nothing. The DNS
  shortcut now stands down whenever a proxy applies (and honours NO_PROXY),
  letting the proxy-aware probe decide. Measured: endpoint probe reachable
  through the proxy while the guard still forced offline.
- The training worker kept its own inline probe hardcoded to huggingface.co,
  so a reachable HF_ENDPOINT mirror set lifetime offline flags. It now uses
  the shared endpoint- and proxy-aware helper.
- /models/check-vision, /models/config and /picker/chat-template ran the
  probe even for local paths, which never reach the hub. Measured 0.9s of
  pure latency per request; now skipped via _hf_offline_if_unreachable_for.

DNS/endpoint/proxy helpers now live in utils.utils so llama_cpp and the
training worker share one implementation instead of three copies.

The static pin in test_offline_inference_parent moved with the probe: the
worker block must delegate to the shared helper and must not hardcode a
host, and the daemon-thread/no-setdefaulttimeout property is pinned on
dns_host_dead where it now lives.

Offline path unchanged: load 686s -> 6s, /models/config 378s -> 0s, and a
local-path vision check is back to 0s.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Second round, all four valid again. Same approach: reproduce against the branch first, fix, then re-measure.

Finding Measured before After
P1 worker probe ignores HF_ENDPOINT Hardcoded huggingface.co, mirror got lifetime offline flags Uses the shared endpoint-aware helper
P1 proxy-only DNS bypasses the probe Endpoint probe said reachable through the proxy, guard still forced offline DNS shortcut stands down when a proxy applies
P2 local /models/check-vision pays the probe 0.9s per request, vs 0.0s for the check itself 0.0s
P2 local /picker/chat-template pays the probe same 0.0s

The proxy one was the interesting catch. My first attempt to reproduce it failed because https:// through a proxy uses CONNECT, which my stub could not tunnel, so the probe legitimately reported unreachable. Re-running against a plain-HTTP endpoint isolated the real behaviour: hf_endpoint_unreachable() returned reachable through the proxy while _hf_unreachable() still returned offline. That is exactly the reported bug. The fix keys off urllib.request.getproxies() and honours NO_PROXY, so a bypassed host correctly falls back to the DNS shortcut.

The worker finding was a genuine miss on my side: I made the DNS check endpoint-aware in llama_cpp last round but left the worker's own inline copy hardcoded. There were three separate DNS probes in this code path, so they now share one implementation in utils.utils (hf_endpoint_host, hf_proxy_configured, dns_host_dead, hf_dns_dead).

For the local-path findings I added _hf_offline_if_unreachable_for(model_name), which is a no-op for local paths and the full guard for remote ids, and used it on all three routes.

One thing worth flagging: this refactor broke test_offline_inference_parent.py::test_training_worker_source_uses_thread_probe, a static pin requiring the daemon-thread probe to be inline in the worker. The property still holds, it just moved into dns_host_dead. Rather than weaken the pin I retargeted it: the worker block must now delegate to the shared helper and must not hardcode a host, and the no-setdefaulttimeout / daemon-thread property is pinned on dns_host_dead where it now lives.

Verification:

  • offline path unchanged: load 686s -> 6s, /models/config 378s -> 0s, and a local-path vision check back to 0s
  • full studio backend suite: 31 failed / 11170 passed, failure list byte-identical to clean main (those 31 are pre-existing CUDA/flash-attn/cache-settings failures on my machine)
  • 78-case simulation suite still green, and the round-1 findings re-checked to confirm none regressed
  • new regression tests: proxy stands down / NO_PROXY bypass / DNS shortcut without a proxy, and local-path no-op vs remote-id guarded

@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26d205b910

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/utils/utils.py Outdated
Comment on lines +165 to +167
try:
from utils.transformers_version import hf_endpoint_unreachable
unreachable = hf_endpoint_unreachable(timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid forcing slow reachable endpoints offline

When a reachable Hub endpoint or corporate proxy takes more than the probe's three-second timeout to answer, hf_endpoint_unreachable() returns True, and this new call caches that verdict and makes the inference/model-loading guards force local-only mode. An uncached remote model then fails immediately instead of allowing the real Hub request to complete or retry, so slow connections are treated the same as no egress. A probe timeout should remain inconclusive/fail open rather than quarantine the load.

Useful? React with 👍 / 👎.

Comment on lines +536 to +538
if hf_dns_dead():
return True
return hf_unreachable()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize an empty HF_ENDPOINT before probing

When HF_ENDPOINT is present but empty or whitespace-only, hf_dns_dead() treats it as the default Hugging Face host, but this call reaches hf_endpoint_unreachable(), which reads the raw value and probes the invalid URL https://. The resulting exception is classified as unreachable, so every uncached remote inference/model load is forced into local-only mode even though the default Hub is available. Use the same normalized endpoint URL for both stages.

Useful? React with 👍 / 👎.

Comment on lines +86 to +92
def _probe() -> None:
import socket as _socket
try:
_socket.gethostbyname(host)
result[0] = False
except Exception:
result[0] = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve IPv6-only Hub endpoints with getaddrinfo

When a custom HF_ENDPOINT has only an AAAA record or uses an IPv6 literal and no proxy applies, socket.gethostbyname() fails because it performs IPv4-only resolution. hf_dns_dead() therefore returns early without running the proxy-aware HTTP probe, forcing a reachable IPv6 Hub into offline mode and preventing uncached models from loading. Resolve with getaddrinfo() so either address family counts as reachable.

Useful? React with 👍 / 👎.

- A reachable endpoint that answers slower than the probe deadline was
  classified offline, so an uncached load failed instead of merely being
  slow. A clean socket timeout is now resolved with a bounded TCP connect:
  a loaded server still completes the handshake, a blackholed route does
  not. A refused connection counts as egress.
- An empty or whitespace HF_ENDPOINT made the DNS shortcut fall back to the
  default hub while the HTTP probe probed "https://" and reported offline.
  Both stages now share one normaliser.
- dns_host_dead used gethostbyname, which is IPv4-only and called an
  AAAA-only mirror or an IPv6 literal dead. It now uses getaddrinfo.

A hang past the deadline still counts as unreachable: the real hub calls
would hang the same way, so cache-only is the useful answer there. That
distinction is what test_hung_probe_is_bounded pins, and it caught an
earlier version of this change that treated every deadline overrun as
inconclusive.

Verified: slow endpoint reachable, blackholed route still offline, blank
endpoint falls back, IPv6 literal resolves. Offline path unchanged, load
686s -> 9s and /models/config 378s -> 0s, chat still answers.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Round 3. All three were valid, and one of them exposed an overreach in my own first attempt at the fix.

Finding Measured before After
P1 slow reachable endpoint forced offline 5s-delay server with a 2s deadline reported offline Reported reachable
P1 blank HF_ENDPOINT probes https:// Host normalised to huggingface.co, probe still said offline Both stages agree
P2 IPv6-only endpoint called dead dns_host_dead("::1") was True False

On the slow-link one, the fix is not "treat every deadline overrun as inconclusive". I tried that first and it silently broke the case this PR exists for: a blackholed route also times out, so the blackhole test went from offline to reachable and the whole guard stopped engaging. The real discriminator is TCP: a loaded server still completes the handshake in milliseconds, a blackholed route does not. So a clean socket timeout now triggers a bounded create_connection to the endpoint (or the proxy, when one applies), and a refused connection counts as egress since something answered.

Worth calling out that my first attempt also flipped the hung-probe case to reachable, and test_transformers_version.py::test_hung_probe_is_bounded caught it. That test is right: if urlopen hangs past its own timeout the real hub calls would hang identically, so cache-only is the useful answer. A hang and a clean timeout are different signals and are now handled differently. I kept the existing pin rather than adjusting it.

The blank-endpoint one was a straightforward inconsistency: os.environ.get("HF_ENDPOINT", default) returns "" when the variable is set but empty, so the default never applied in the probe while hf_endpoint_url() handled it correctly. Both now use the shared normaliser.

Verification:

  • offline path unchanged: load 686s -> 9s, /models/config 378s -> 0s, chat still answers
  • full studio backend suite: 31 failed / 11180 passed, failure list byte-identical to clean main
  • 78-case simulation suite green, and rounds 1 and 2 re-checked so nothing regressed
  • new tests: timeout with and without TCP egress, bare TimeoutError, refused connection as egress, blank/whitespace endpoint normalisation in both stages, IPv6 literal resolving, unresolvable host still dead

The DNS fixtures also had to move from patching gethostbyname to getaddrinfo, since the probe no longer uses the IPv4-only call.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 59929802c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +560 to +561
force_ctx = force_hf_offline()
force_ctx.__enter__()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain each concurrent offline guard

When Studio handles overlapping remote metadata/load requests while the Hub is unreachable, the first guard sets HF_HUB_OFFLINE, causing every later _hf_offline_if_unreachable() call to take the early no-op branch instead of entering this ref-counted context. If the first request finishes while another is still resolving multiple Hub files, its exit restores the constants and sessions, and the remaining request resumes the long online retry path this change is intended to prevent. Each internally forced guard must hold its own reference until its operation exits, while still distinguishing a user-supplied offline variable.

Useful? React with 👍 / 👎.

Comment on lines +134 to +135
from utils.utils import hf_tcp_reachable
return not hf_tcp_reachable(min(timeout, 2.0), endpoint)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not equate proxy reachability with Hub reachability

When the HEAD request times out through a configured proxy, this fallback calls hf_tcp_reachable, whose hf_connect_target deliberately connects only to the proxy host. An available proxy whose upstream route to Hugging Face is blackholed therefore returns True, so hf_endpoint_unreachable reports the Hub as reachable and all guarded Studio handlers still incur the full Hub retry delay. The timeout fallback needs to verify the proxy can reach the endpoint, not merely that the proxy accepts TCP connections.

Useful? React with 👍 / 👎.

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

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

Copy link
Copy Markdown
Member Author

Round 4. Both valid, and the concurrency one is the sharpest catch so far.

Concurrent guards. Reproduced with two overlapping guards: the second took the no-op branch (engaged=False), and when the first exited, HF_HUB_OFFLINE went back to False while the second was still inside its window. Exactly as described. The no-op branch existed to respect a user-supplied offline variable, so the fix distinguishes the two cases with force_hf_offline_active(): a variable we set means an in-flight guard, and the new guard takes its own reference on the refcounted window; a variable the user set is still left completely alone. Measured after: second guard engages, offline holds past the first exit, and both constants and env are fully restored once the last window closes.

Proxy TCP. Also correct, and it undercut my own round-3 fix. Reproduced with a listener that accepts TCP and never replies, standing in for a live proxy with a dead upstream: the probe reported the hub reachable. The TCP handshake is only evidence when we connect to the endpoint directly, so with a proxy configured a socket timeout now stays unreachable. The direct slow-endpoint case from round 3 still reports reachable, so that fix is intact.

One correction to my own testing: my first end-to-end concurrency run appeared to hang for 40 minutes. That was my shell, not the code. A bare wait also waits on the backgrounded studio process, which never exits. Re-run properly with threads, 9 concurrent metadata requests against an unreachable hub all returned 200 in 5.1s total, no deadlock.

Verification:

  • full studio backend suite: 31 failed / 11184 passed, failure list byte-identical to clean main
  • 78-case simulation suite green; rounds 1 through 3 re-checked so nothing regressed
  • offline path unchanged: load 686s -> 8s
  • new tests: second guard engages and offline survives the first exit, user-set offline still a no-op, timeout through a proxy stays unreachable, timeout without a proxy still uses the TCP check

@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93dafbd429

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# 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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Probe through ALL_PROXY before forcing offline

When egress is available only through ALL_PROXY and direct DNS is blocked, this new guard incorrectly forces the handler offline: hf_proxy_configured() recognizes the all entry and skips the DNS shortcut, but hf_endpoint_unreachable() probes with urllib.request.urlopen, which does not apply an ALL_PROXY-only setting and therefore fails its direct lookup. The Hugging Face client can still use that proxy, so uncached remote model configuration requests that previously worked now fail as cache-only; the reachability request should use the same proxy transport as the Hub client or map ALL_PROXY to the endpoint scheme.

Useful? React with 👍 / 👎.

Comment on lines +558 to +560
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor TRANSFORMERS_OFFLINE before probing

When a user explicitly sets only TRANSFORMERS_OFFLINE=1, this check does not recognize their offline request and _hf_unreachable() now performs an HTTP HEAD probe before entering the guarded operation. That introduces network traffic in an explicitly offline process (the shared hf_env_offline() already treats either offline variable as authoritative); skip the reachability probe when either truthy offline variable is set.

Useful? React with 👍 / 👎.

…OFFLINE

Resolve the hub proxy the way requests does (scheme-specific, then all_proxy,
NO_PROXY wins) and issue the reachability HEAD through it. urllib ignores
all_proxy, so a proxy-only setup failed the probe's direct lookup and was called
offline while real hub calls would have succeeded.

Skip the probe when TRANSFORMERS_OFFLINE alone is truthy: that is still an
offline request, and the hub does not read it. Engage the guard directly instead
of putting a DNS lookup and a HEAD in an explicitly offline process.
HF_HUB_OFFLINE=0 remains an explicit stay-online opt-out.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Round 5, both comments checked against a real forward proxy on loopback rather than mocks. Both were correct.

Probe through ALL_PROXY (P1). Confirmed. urllib.request.getproxies() reports the all entry, so hf_proxy_configured() correctly stood the DNS shortcut down, but urlopen never applies an all_proxy-only setting: it went direct, the lookup failed, and the endpoint was called unreachable while requests.utils.select_proxy (what the Hub client uses) picks the proxy for the same URL. Measured with the endpoint pointed at a host that does not resolve and egress only via ALL_PROXY: probe said unreachable, proxy received zero requests.

Fixed by adding hf_proxy_for_endpoint(), which resolves like select_proxy (scheme-specific first, then the all catch-all, None when NO_PROXY covers the host), and pinning that proxy onto the endpoint's scheme for the probe's opener. hf_proxy_configured() and hf_connect_target() now share it instead of duplicating the lookup. After the fix the proxy sees HEAD http://<host> for an http endpoint and CONNECT <host>:443 for an https one, and the probe reports reachable. Controls held: NO_PROXY still forces the direct path, an explicit HTTPS_PROXY still wins over ALL_PROXY, and a dead host with no proxy is still unreachable. End to end at the guard: live ALL_PROXY stays online, ALL_PROXY pointing at a closed port still goes offline.

Honor TRANSFORMERS_OFFLINE before probing (P2). Confirmed. The guard tested "HF_HUB_OFFLINE" in os.environ only, so with TRANSFORMERS_OFFLINE=1 alone it fell through to a DNS lookup and a HEAD. Instrumented probe count in that state was 1, now 0.

It now engages the guard directly when hf_env_offline() is true rather than just skipping the probe, because the hub reads only HF_HUB_OFFLINE: no-opping would have left hub calls going online against the user's stated intent. This matches what export._hf_offline already does. The membership check on HF_HUB_OFFLINE is kept ahead of it so HF_HUB_OFFLINE=0 remains an explicit stay-online opt-out that outranks TRANSFORMERS_OFFLINE, and TRANSFORMERS_OFFLINE=0 still probes normally.

Ten regression tests added (TestAllProxyIsHonoured, TestEnvOfflineSkipsTheProbe), covering proxy resolution precedence, the opener actually carrying the proxy, the no-proxy path never building one, and all three env-var states. test_offline_gguf_cache_fallback.py is 120 to 130 tests, all passing. Full backend suite: 31 failed, 11194 passed, failure set byte-identical to clean main. Cross-platform sim suite: 77 passed, 1 skipped. Rounds 1 through 4 verifiers all re-run and still green.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2249ea0f97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +558 to +562
ours = force_hf_offline_active()
# A user-set HF_HUB_OFFLINE is theirs: don't probe it, don't touch it. Membership, not
# truthiness, so HF_HUB_OFFLINE=0 stays an explicit "stay online" opt-out.
if "HF_HUB_OFFLINE" in os.environ and not ours:
yield False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the offline ownership check atomic

When two guards start concurrently, one thread can read ours == False, pause, and then observe HF_HUB_OFFLINE after the other thread has entered force_hf_offline(). This branch consequently treats the variable as user-owned and yields without acquiring its own reference; if the other guard exits first, it restores the online constants and sessions while this operation is still running, sending an offline cache load back into Hub retries. Determine whether the environment value is guard-owned atomically with the refcount, or recheck the active state before taking this no-op branch.

Useful? React with 👍 / 👎.

Comment on lines +293 to +295
for key in _OFFLINE_ENV_KEYS:
saved_env[key] = os.environ.get(key)
os.environ[key] = "1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent spawned workers from inheriting transient offline flags

When any guarded metadata or GGUF operation is in this window, these process-global environment writes are inherited by a concurrently spawned inference or training worker. The orchestrators use multiprocessing's spawn, so the child retains HF_HUB_OFFLINE=1 after this context restores the parent; because worker startup treats an existing variable as user-owned, a worker started during a DNS-live outage or transient gateway failure can remain cache-only after connectivity recovers and reject later downloads. Avoid exposing the temporary flags to child environments, or mark and clear auto-set values during worker startup.

Useful? React with 👍 / 👎.

Comment thread studio/backend/utils/utils.py Outdated
Comment on lines +71 to +74
host = parsed.hostname or ""
try:
if host and urllib.request.proxy_bypass(host):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match the Hub client's NO_PROXY rules

When HF_ENDPOINT is an IP covered by a CIDR entry such as NO_PROXY=10.0.0.0/8, or the bypass entry includes the endpoint port such as huggingface.co:443, the Hub client's requests proxy logic bypasses the proxy but urllib.request.proxy_bypass(host) does not. This helper therefore probes through the configured proxy even though the real Hub request goes direct; if that proxy cannot reach the internal endpoint, the probe marks a reachable endpoint offline and uncached loads fail immediately. Apply the same CIDR and host-with-port matching used by the Hub client's proxy selection.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2249ea0f97

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/core/training/worker.py Outdated
Comment on lines +2360 to +2361
from utils.transformers_version import hf_endpoint_unreachable
_offline = hf_endpoint_unreachable(gateway_errors_offline = False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip the worker probe when Transformers is offline

When a training job is launched with only TRANSFORMERS_OFFLINE=1 set, this new endpoint probe still runs because the surrounding guard only checks for HF_HUB_OFFLINE. On a DNS-resolving but WAN-down machine that means an explicitly offline worker now emits a network HEAD request and waits for its timeout before startup; use the shared offline-env check before hf_dns_dead()/hf_endpoint_unreachable() so TRANSFORMERS_OFFLINE remains authoritative.

Useful? React with 👍 / 👎.

def _probe_raising(self, monkeypatch, exc):
import urllib.request

monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: (_ for _ in ()).throw(exc))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear proxy env before monkeypatching urlopen

When the test runner has HTTPS_PROXY/ALL_PROXY set, hf_endpoint_unreachable() takes its new proxy-opener path and calls opener.open instead of the urllib.request.urlopen symbol patched here. These timeout tests then make real proxy/network calls and can pass or fail based on ambient connectivity rather than the injected URLError; clear proxy env for this class or patch build_opener/hf_proxy_for_endpoint as the proxy-specific tests do.

Useful? React with 👍 / 👎.

Comment thread studio/backend/utils/utils.py Outdated
Comment on lines +71 to +74
host = parsed.hostname or ""
try:
if host and urllib.request.proxy_bypass(host):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor port-scoped NO_PROXY entries

When HTTPS_PROXY is set but the endpoint is bypassed with a port-scoped entry such as NO_PROXY=huggingface.co:443, this passes only the bare hostname to proxy_bypass, so the bypass is missed and the new reachability probe goes through the proxy even though the Hub client would connect directly. If that proxy is unavailable while direct egress works, Studio incorrectly forces uncached metadata/model loads into offline mode; include the endpoint port/netloc in the bypass check or delegate proxy selection to the Hub client's transport.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d5c2636ef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/core/training/worker.py Outdated
return False
model = config.get("model_name")
try:
return bool(model) and is_local_path(model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check local LoRA bases before skipping the probe

When model_name is a local LoRA directory whose adapter_config.json names a remote Hub base and no hf_dataset is configured, this returns True solely because the adapter path is local. run_training_process() consequently skips both reachability checks even though activate_transformers_for_subprocess() resolves that remote base and later training/security code accesses it. On a DNS-dead or WAN-down machine, a cached-base training job therefore runs through Hub metadata/model retry paths instead of enabling the worker's cache-only fallback; inspect the local adapter's recorded base before classifying the job as filesystem-only, as the inference worker already does.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/inference.py Outdated
# 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_for(model_identifier):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the load-time reachability guard off the event loop

When a remote load encounters slow DNS or an endpoint whose HEAD request times out but whose TCP handshake succeeds, entering this context synchronously runs hf_dns_dead() and hf_endpoint_unreachable(), including their thread joins and possible follow-up TCP timeout (roughly six seconds in the default direct-connect path). _load_model_impl() is an async function directly awaited by the route, so this blocks every unrelated API request and generation-control action on the event loop; the new validation and models handlers already avoid the same problem with asyncio.to_thread. Run this guard and ModelConfig.from_identifier() in a worker thread as well.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member

Round 12: two items, both correct, both regressions from my own round-11 change. Fixed in ba5704118.

core/training/worker.py local LoRA bases. My round-11 gate _training_job_is_local only checked is_local_path(model_name). A local adapter directory can name a remote base in its adapter_config.json, and activation resolves that base over the hub, so the job is not filesystem-only and the probe must not be skipped. The inference worker already handled this. The training gate now reads the recorded base off disk (no network needed to decide) and matches:

if not (model and is_local_path(model)):
    return False
base = _recorded_local_adapter_base(model)
return not base or is_local_path(base)

routes/inference.py load-time guard on the event loop. _load_model_impl is async def and the route awaits it directly, so entering the reachability window inline blocked the loop for up to the probe timeout on every load. Config resolution now runs in a worker thread with the guard inside it:

def _resolve_config():
    with _hf_offline_if_unreachable_for(model_identifier):
        return ModelConfig.from_identifier(...)

config = await asyncio.to_thread(_resolve_config)

Tests. Added TestLocalLoraTrainingJobStillProbes (5 tests, including test_both_workers_agree which pins the two gates to the same verdict) and TestLoadRouteResolvesConfigOffTheLoop (AST check that the guard is inside the to_thread callable and no inline guard remains). Both are non-vacuous: reverting the two fixes fails 3 of the 6, restoring them passes 6.

Verification. CI-equivalent invocation 795 passed, 22 deselected, 6 subtests passed. Full suite against a same-tip main worktree gives 648 failure identities on both sides, zero new. Ruff clean, pre-push gate PASS.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 30, 2026
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 30, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ac914a2ab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/core/inference/worker.py Outdated
Comment on lines +834 to +835
_probe_base = _recorded_local_adapter_base(_probe_model)
if "HF_HUB_OFFLINE" not in os.environ and not _hub_targets_are_local(_probe_model, _probe_base):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include full-checkpoint bases in the worker probe gate

When a local full checkpoint's config.json records a remote model_name or _name_or_path, this gate examines only adapter_config.json and classifies the job as filesystem-only. Later, _resolve_base_model() deliberately resolves that recorded remote base and _activate_transformers_version() performs Hub metadata reads for it, so a WAN-down/DNS-live load can still incur the metadata retry timeouts that the worker probe is intended to avoid. Include the full-checkpoint base used for tier activation when deciding whether to skip the probe.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member

Round 13: correct, and the same class of bug as round 12. Fixed in ccae38b28.

_resolve_base_model does two disk reads before tier activation runs on the result: an adapter's base_model_name_or_path, and then a full checkpoint's config.json model_name/_name_or_path. My gate only did the first, so a local full checkpoint recording a remote base was classified filesystem-only, the probe was skipped, and _activate_transformers_version then walked into exactly the Hub metadata timeouts the probe exists to avoid.

Rather than add a third copy of that logic, there is now one disk-only reader next to the resolver it mirrors, in utils/transformers_version.py:

def recorded_local_base(model_name) -> "tuple[str | None, bool]":

Both worker gates delegate to it. It lives there because that module is import-light (stdlib plus local utils), so calling it from the gate cannot pin the default transformers into sys.modules before the sidecar venv is prepended, which is why the gate cannot just call _resolve_base_model itself. needs_hub is set when only the get_base_model_from_lora branch could answer, so the gate fails closed rather than guessing.

Tests. Added TestFullCheckpointBaseKeepsTheProbe: remote model_name, remote _name_or_path, a self-reference (which HF writes routinely and must not cost a probe), adapter-before-config ordering, and a baseless adapter. Plus test_the_gate_agrees_with_the_resolver, which pins the gate to _resolve_base_model across five on-disk shapes. That last one is the point: both this bug and round 12's were the gate reading less than the resolver does, so the invariant is now enforced rather than restated.

This also corrected one of my own round-12 tests. It asserted a baseless adapter was filesystem-only; it is not, since the resolver falls through to a Hub call. Renamed and flipped.

Verification. Reverting the fix fails 5 tests, restoring them passes. Full suite against a same-tip main worktree (969e32f8a), same command both sides: zero new failures, 19 vs 21 failed, 77 errors on both, 12903 vs 12805 passed. Ruff clean, pre-push gate PASS.

Unrelated, but worth flagging separately since it cost me a detour: hub/tests/test_dataset_services.py installs stubs for fastapi and pydantic into sys.modules at import time and never restores them, so on collection orders where it lands early, 84 later modules fail to collect with cannot import name 'Request' from '<unknown module name>'. It reproduces identically on main, so it has nothing to do with this PR, but it makes any full-suite run seed-dependent. Happy to send a separate PR for it.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 4708d1a3b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member

Merged main and fixed the one real CI break, in 2ac9da099.

The Python 3.10-3.13 and Core jobs went red on three tests. Only one belongs to this PR:

  • tests/test_tunnel_safe_long_post.py:341 patched route._hf_offline_if_dns_dead, which this branch renamed. That test arrived on main in Studio: keep a slow model load alive through a proxy timeout #7635 after my last merge here, so it never existed in my tree and only the merge CI builds could see it. It now patches both current guards, since the load path resolves its config under the per-model one:
monkeypatch.setattr(route, "_hf_offline_if_unreachable", contextlib.nullcontext)
monkeypatch.setattr(route, "_hf_offline_if_unreachable_for", contextlib.nullcontext)

18 passed.

  • The other two, test_explicit_vulkan_prebuilt_failure_does_not_change_backend and test_explicit_vulkan_source_build_fails_closed, fail identically on a clean main worktree at 969e32f8a, and nothing here touches the llama.cpp installer. Not from this PR.

Worth correcting something I said last round: I reported this branch as fixing those two Vulkan failures. That was an artifact of comparing a tree that had not yet merged the main commit which introduced them, not a real improvement. With main merged in they fail on both sides, as they should.

Verification. Full suite on the merged tree: 21 failed, 12948 passed, 77 errors, and the failure identities are byte-identical to the same-tip main baseline, so zero new. Ruff clean, pre-push gate PASS.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ac9da099d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

base = cfg.get(_key)
if isinstance(base, str) and base and not _is_same_path(base, root):
return base, False
return None, adapter_cfg

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe adapter-only LoRAs before worker activation

When a local LoRA contains only adapter_model*.safetensors/.bin and follows the supported unsloth_<model>_<timestamp> naming convention, it has neither JSON file, so this returns (None, False). Both the inference and training worker gates consequently classify the job as filesystem-only and skip reachability detection, even though _resolve_base_model() later derives a remote unsloth/... base from that directory name and tier activation reads its Hub metadata. On a WAN-down/DNS-live machine, such a cached adapter therefore re-enters Hub retry paths and may select the default Transformers tier; account for the adapter-only inference here or report needs_hub=True when adapter weights exist without recorded metadata.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member

Round 15: correct, and it is the third time the gate has read less than the resolver. Fixed in ece74ebd2.

_resolve_base_model has a fourth offline branch I had not mirrored. For an adapter dir carrying only adapter_model*.safetensors and no JSON at all, it derives a remote base from the unsloth_<model>_<timestamp> directory name by pure string parse. recorded_local_base returned (None, False), so both gates classified the load as filesystem-only and skipped the probe, while activation then read Hub metadata for unsloth/.... Mirrored:

# Only reachable without a Hub call when there is no adapter_config.json; with one,
# the resolver tries get_base_model_from_lora first, which needs_hub already covers.
if not adapter_cfg and root.name.startswith("unsloth_") and _has_adapter_weights(root):
    parts = root.name.split("_")
    if len(parts) >= 2:
        return "unsloth/" + "_".join(parts[1:-1]), False

The more useful part is why the guard I added last round did not catch this: test_the_gate_agrees_with_the_resolver had five on-disk shapes and none of them was adapter-only. It now has nine, including adapter-only dirs, a non-unsloth_ name and a name with no timestamp, plus a direct end-to-end test. Reverting the fix fails both.

Separately, verifying this turned up a real defect in two of this PR's own test files. test_offline_inference_parent.py and test_offline_gguf_cache_fallback.py did:

sys.modules.setdefault("structlog", _types.ModuleType("structlog"))

That puts an empty module into sys.modules for the whole session and never restores it, so anything imported later that calls structlog.get_logger fails. It was taking out 15 tests in test_tunnel_safe_long_post.py. The httpx stub a few lines below already had the right shape, so both files now use it: prefer the real module, stub only on ImportError. That pair goes from 15 errors to 52 passed.

Verification. Full suite: 21 failed, 12949 passed, 77 errors, failure identities byte-identical to the same-tip main baseline, so zero new and zero missing. Ruff clean, pre-push gate PASS.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: ece74ebd2c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a77a9f8e5c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# be turned cache-only here. Matches the worker's call.
unreachable = hf_endpoint_unreachable(
timeout,
gateway_errors_offline = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat gateway failures as offline in scoped guards

When the Hub or a configured mirror is returning 502/503/504, this shared verdict reports the endpoint as reachable because gateway_errors_offline is forced to False. The per-request _hf_offline_if_unreachable() guard reaches this helper via _hf_unreachable(), so cached /load, /validate, and metadata preflights do not enter force_hf_offline() during a Hub outage and can still take the Hub retry/failure path instead of staying cache-only; reserve the fail-open gateway behavior for worker lifetime flags or pass the default for scoped guards.

Useful? React with 👍 / 👎.

@danielhanchen
danielhanchen merged commit 3c400a6 into main Jul 31, 2026
46 of 53 checks passed
@danielhanchen
danielhanchen deleted the fix/offline-load-unreachable-hub branch July 31, 2026 11:26
danielhanchen added a commit that referenced this pull request Jul 31, 2026
Git merged all three files without a conflict, but the result was wrong in
each case. The tests only started failing once the merged tree was run.

routes/inference.py duplicated the GGUF load block: this branch had moved
the gguf_load_in_flight marker and the _hub_download_blocks_gguf_load guard
under "if config.is_gguf and config.gguf_hf_repo", and the conflict
resolution re-added main's copy at the old position, so both ran. Dropped
main's copy; the earlier placement is the deliberate one, so a 409 from the
hub guard cannot tear down a resident Images or Video pipeline.

test_gpu_selection.py still called _hf_offline_if_dns_dead, which main
renamed to _hf_offline_if_unreachable_for (#7591). Disjoint edits, so no
conflict, but four route-error tests referenced a function that no longer
exists.

test_gguf_load_cache_reuse.py anchored its ordering assertion with rindex
over "if config.is_gguf:", taking the last one before the load marker. That
only held while _resolve_inherited_extra_args sat above every such line;
main has since hoisted it above the gpu_ids preflight, so the anchor landed
between the call and the marker and the assertion compared against an
unrelated later call site. The ordering it checks is a property of
_load_model_impl as a whole, so it now anchors on the function.

The ordering itself is intact: _resolve_inherited_extra_args, then the
gguf_load_in_flight marker, then the hub guard, then the chat handoff, then
unload_model.
AdamPlatin123 pushed a commit to AdamPlatin123/unsloth that referenced this pull request Aug 19, 2026
Rebase onto main (resolves the merge conflicts from the four-month gap)
and extend the mirror support into the hub module Daniel flagged:

Backend
- utils/hf_endpoint.py: get_hf_endpoint() now delegates to
  utils.utils.hf_endpoint_url() (landed via unslothai#7591) so HF_ENDPOINT has a
  single parsing implementation; keeps the HF_DATASETS_SERVER split and
  its once-only mirror warning. Exposed via /api/health.
- Replaced the remaining hardcoded huggingface.co URLs in
  orchestrator.py, model_config.py and data_recipe/huggingface.py.
- transformers_version.py: kept upstream's URL routing; added graded
  logging (404 stays debug, 5xx/URLError warn with a mirror hint) so a
  broken mirror no longer hides behind a silent debug line.

Frontend
- lib/hf-endpoint.ts + config/env.ts: endpoint values flow from
  /api/health into the platform store (hf_endpoint is in the
  unauthenticated health block, so it works pre-login).
- Ported the search hooks onto the new hub layout: use-hub-model-search
  (10 listModels/cachedModelInfo call sites), use-hub-dataset-search,
  use-hf-dataset-splits.
- Hub lib de-hardcoded: hf-readme, hf-owner-avatar, dataset-size
  (datasets-server via HF_DATASETS_SERVER), and network.ts — the offline
  backoff now keys on the configured endpoint's origin instead of
  huggingface.co, matching where requests actually go.
- Hub catalog external links and the seed-config default endpoint follow
  the configured mirror; dropped the pinned endpoint from the
  instruction-from-answer template so it stops overriding the default.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants