unsloth/studio/backend/tests/test_async_singleton_access.py
Daniel Han 4316454dff
Studio: bring the login screen up before the ML stack loads (#7607)
* Defer the backend's torch import and warm it after the port binds

* Keep the warm window off the event loop and degrade a failed detection

Deferring torch moved hardware detection behind the port bind, so the routes
the SPA fetches on first paint now reach get_device() while the warm thread is
still importing torch. They were calling it inline from async def, which blocks
uvicorn's loop: a measured /api/liveness request, which touches nothing, took
1547ms right after the socket bound.

Move the five first-paint / polled call sites off the loop, make a broken torch
count as no torch, and cache a failed detection as CPU + chat-only so a raise
cannot make every request retry the import (or 500 /api/health).

* Lock in the warm-window invariants with tests

* Purge half-imported packages when a warm stage fails

A failed package import leaves its submodules in sys.modules with the parent
evicted, so the retry re-runs __init__ against cache hits and gets a package
that imports but is missing attributes. The warm makes that reachable: it
imports torch and unsloth_zoo on a thread and swallows the failure, so the
retry belongs to whichever request needs them next.

* Warm unsloth_zoo through the shim, and warm datasets too

Two gaps found by diffing the modules, env vars and torch state a booted
backend ends up with against main, on this box:

The unsloth_zoo stage did a bare `import unsloth_zoo`. The edge it replaces
was orchestrator.py's `from utils.hf_xet_fallback import DownloadStallError`,
and that shim does more than import: when unsloth_zoo's GPU init raises it
retries under UNSLOTH_ZOO_DISABLE_GPU_INIT=1, which makes unsloth_zoo skip the
init and inject its triton/bitsandbytes stubs. A bare import skips the retry,
so on a host whose bitsandbytes cannot find libcudart the stage failed at
something startup used to complete. Reproduced here: bare import raises
"CUDA Setup failed despite GPU being available", the shim succeeds.

`datasets` was imported by utils/datasets/raw_text.py for an annotation and
is now deferred with nothing warming it, so its 0.3s moved onto the first
dataset operation instead of onto the warm thread. Add it as a stage, between
transformers and unsloth_zoo, which is where `import main` reached it.

With both fixed the two trees end with the same top-level module set, the same
env (PYTORCH_CUDA_ALLOC_CONF included), the same 4-device CUDA enumeration and
the same torch globals.

* Apply the repo's ruff formatting to the new code

* Never purge a package whose C extensions are already loaded

The full backend suite died with SIGABRT at 90%, deterministically, on this
branch and not on main. The cause is purge_partial_import().

Evicting a loaded C extension from sys.modules does not undo its module init,
it only makes the next import run that init a second time. pybind11 answers a
duplicate type registration with std::terminate. Reduced:

  import torch
  saved = sys.modules.pop("torch")
  purge_partial_import("torch")      # evicts all 708 torch.* entries
  sys.modules["torch"] = saved
  import torch._C
  -> terminate called after throwing an instance of 'std::runtime_error'
       what():  generic_type: type "GradBucket" is already registered!

_has_torch() calls the purge on any failed `import torch`, so a torch that
raises after torch._C has loaded would take the backend down mid-request
rather than degrade. Leaving the zombie gives a torch that is missing
attributes, which is the bad-but-alive outcome the purge was chosen over.

purge_partial_import() now declines, and says so in the log, as soon as any
submodule of the package is a loaded extension. Pure-Python zombies, which is
what the unsloth_zoo case actually is, still get cleared.

test_a_broken_torch_purges_its_own_zombie was the trigger: it left sys.modules
holding `torch` with every torch.* submodule evicted, so the next test that
touched torch aborted the pytest process. It now snapshots and restores the
whole torch slice and builds its zombie out of a pure-Python submodule.

* Fix the two tests the full suite turned up

test_desktop_auth patched CHAT_ONLY and CHAT_ONLY_REASON but left DEVICE unset,
which was consistent while detection ran in the lifespan and is not now:
/api/health waits on ensure_hardware_detected(), which re-detects while DEVICE
is None and so recomputes the value the test had just pinned. Pin DEVICE with
them; CPU plus "mlx_unavailable" is the state detection actually ends in on an
Apple Silicon host without a usable MLX stack. The assertions are unchanged.

test_the_unsloth_zoo_stage_goes_through_the_shim reached the shim as
`from utils import hf_xet_fallback`. test_hf_xet_fallback re-imports that module
and leaves the `utils` package attribute pointing at a different object than
sys.modules holds, so the patch landed on a module the code under test never
looks at. Resolve it through import_module, the way the code does.

While here: the failing-stage test asserted on capsys alone, which depends on
whether an earlier test rebound structlog to the stdlib handlers. Accept either
sink.

Full backend suite, this host: main 5 failed / 11418 passed, branch 5 failed /
11443 passed, failure sets identical. Collection is append-only: 25 new ids
(23 in the new file, 2 from test_text_io_encoding parametrising over sources),
none removed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments on the deferred-import path

* Build the inference orchestrator under a lock

get_inference_backend() was a bare check-then-set on a module global. That was
fine while the first caller was the lifespan, but first paint now reaches it
from three routes at once (routes/models.py:list_models,
routes/inference.py:get_status and get_api_monitor), each through
asyncio.to_thread, so the calls are genuinely parallel. The first construction
is slow -- InferenceOrchestrator.__init__ runs get_default_models(), which
calls hw.get_device() and so blocks on the torch warm -- and every thread that
enters that window observes None and builds its own. The last assignment wins;
orchestrator state is per-instance (its own subprocess handle, loading_models,
active_model_name), so a load started on a loser is invisible to every later
status or generation call.

Double-checked locking: the cheap read keeps the warm path lock-free, the
recheck under the lock decides who constructs. A plain Lock is safe here --
get_default_models() calls only get_device(), so the constructor cannot re-enter
the getter.

tests/test_inference_backend_singleton.py: 8 threads through a barrier into a
cold getter with the constructor stubbed to hold the window open. Against the
old getter: 8 orchestrators constructed, assert 8 == 1. With the lock: 1, and
every caller is handed the module global. Also covers that the warm path does
not serialize on the lock (a warm call from another thread returns while the
lock is held) and an AST guard that the construction stays inside the with.

* Detect hardware before the MLX streaming guard

/training/start rejects dataset_streaming on Apple Silicon by comparing
hardware.DEVICE against DeviceType.MLX. Detection used to run inline in the
lifespan, so that global was always set before the socket bound. It is filled in
by the warm thread now, so a start submitted in the first moment of serving
reads the pre-detection default (None), the comparison is False, and the
rejection is skipped. _build_training_worker_config() detects MLX only after
validation has passed, so the request runs on and hands a streaming dataset to a
loader that materializes the whole thing.

Force detection at the guard. Off the loop via asyncio.to_thread because
detection imports torch: inline it would stall every other request for that
import, which is the stall the deferred startup exists to remove. Same treatment
as /api/health and /api/system/gpu-visibility. Only the streaming branch pays
it, and only until the warm finishes -- after that ensure_hardware_detected()
returns the cached DEVICE.

tests/test_training_streaming_mlx_warm.py covers the behaviour: with DEVICE
still None on a pretend-Apple-Silicon host the start must be rejected 400 (it
returned 200 and called start_training before this change), the guard itself is
what moves DEVICE None -> MLX, and forcing detection must not become a blanket
rejection -- the same request on a CPU host still queues. The lexical half goes
in test_startup_defers_torch.py's _OFFLOAD_REQUIRED table so the call cannot
drift back onto the loop.

* Probe torch once per detection pass

_detect_hardware_locked() called _has_torch() twice: once for the CUDA/ROCm
branch and again for the Intel-XPU fallback below it. On a host where the import
works that is a cheap sys.modules hit, but on the path that matters -- a wheel
whose CUDA libs do not resolve -- it is the whole failing import run twice.
purge_partial_import() cannot clean up after the first failure whenever a
compiled submodule is already loaded (evicting one makes the next import re-run
its init and pybind11 answers duplicate registration by aborting), so the
partial tree stays and the second probe re-runs torch/__init__ against those
cache hits: same seconds again, same declined purge, on a request that is
already degrading to CPU. It also need not fail the same way twice, so the two
branches could disagree about whether torch exists.

Hoist it: one probe, both branches read it.

Reproduced with a fake torch that fails after leaving a compiled submodule
behind: 2 import attempts and 2 "not purging torch" warnings before, 1 and 1
after. tests/test_startup_defers_torch.py::test_one_detection_pass_probes_torch_once
pins it (assert 2 == 1 against the old code).

* Bound the health check's wait on hardware detection

/api/health awaited ensure_hardware_detected() with no ceiling. That is safe
only while detection has already run, which is what the old inline lifespan
detection guaranteed: TAURI_PORT is emitted after the lifespan returns, so by
the time the desktop launcher probed, health answered instantly. Detection is on
the warm thread now and TAURI_PORT comes first, so the probe lands on an
endpoint that is blocked on a cold `import torch`.

The launcher does not tolerate that. preflight/backend.rs builds its probe
client with a 2s timeout; on timeout probe_ownerless_spawned_backend() returns
Missing, choose_ownerless_spawned_preflight() falls through to
ExternalConflict/"desktop_owned_backend_starting", and use-tauri-backend.ts
renders it as setBackendError("The desktop-owned Unsloth backend is still
starting. Wait a moment, then try again.") -- terminal, not a retry.

Wait up to 1.5s and answer either way. When the budget expires, chat_only is
still published (the pre-detection default, i.e. the conservative direction --
Train/Export hidden, never wrongly offered) alongside a new hardware_detecting
flag so a client can tell a provisional value from a measured one. Every field
the launcher actually reads is correct either way; only the web UI reads
chat_only, and its first health call is the unauthenticated one behind the login
screen, which does not cache (fetched tracks device_type).

The wait polls DEVICE on the event loop rather than awaiting a to_thread:
asyncio.wait_for cannot cancel a to_thread, so a timed-out call would hold its
executor slot for the rest of the import and a polled endpoint would drain the
pool. start_background_detection() puts up at most one daemon thread so
detection still happens when the warm is disabled or already past its hardware
stage.

Measured with detection stubbed at 10s: 10.01s before, under the budget after.
tests/test_health_answers_within_probe_budget.py pins the deadline, that a
detection finishing inside the budget is still waited for (chat_only=False, no
flag), and -- as a cross-language guard -- that the budget stays under whatever
timeout backend.rs sets, with headroom.

* Say what the unsloth_zoo purge is and is not for

The comment read as though purging unsloth_zoo re-arms the retry, which it does
not: utils.hf_xet_fallback pins _shared_available = False on a failed
_load_shared() and nothing here clears it. That is deliberate, so say so.

Clearing it would be a bug, not a fix. DownloadStallError resolves through PEP
562 __getattr__ on every import, so flipping the cache mid-process hands the
real unsloth_zoo class to importers after the flip and the degraded stub to the
ones before it. Verified: raising the post-flip class is not caught by an
`except` bound to the pre-flip one, so a stall raised inside a download escapes
the handler that was meant to catch it.

The stickiness is also not new -- on main, core/inference/orchestrator.py
imports DownloadStallError at module scope, which runs the same _load_shared()
at startup and pins the same flag on the same failure. This change only moves
when that happens.

The purge still earns its place: it is for the next *direct* importer of
unsloth_zoo (model loading, export, the MLX paths), which would otherwise re-run
__init__ against the submodules the failure left behind.

Comments only: docstring-blanked AST and comment-stripped token stream are both
identical to the parent commit.

* Give the health budget more room under the probe timeout

Measured the 1.5s budget against a real cold start: the first /api/health, fired
the instant the socket bound, came back in 1.742s. Inside the 2s the desktop
launcher allows, but only just.

The overrun is the budget doing what it can rather than a bug. The wait polls on
the event loop, and a C-extension import holds the GIL for stretches in which
the loop does not run at all, so the reply lands late by however long the
current stretch is -- 0.24s here, and this host has a warm page cache. 1.0s buys
that margin back. The cost is one more provisional reply: the health poll shows
the very next sample, ~0.3s later, already carrying the measured chat_only.

Comment says so, and the headroom the cross-language guard demands goes from
0.4s to 0.9s.

* Do not let a provisional health reply look authoritative

The bounded wait publishes a provisional chat_only when detection has not
finished, flagged with hardware_detecting. The authenticated reply carried
device_type alongside it, and that is exactly the field the frontend uses to
decide the response is authoritative: config/env.ts sets
`fetched = data.device_type !== undefined`, and every later non-forced
fetchDeviceType() short-circuits on fetched. So one authenticated health request
inside the warm window pinned chat_only=true for the rest of the SPA session --
Train hidden, /studio redirected to /chat, on a GPU host, until a reload. The
sidebar's recovery poll does not rescue it: that only runs for
chat_only_reason === "mlx_unavailable", and a provisional reply has no reason.

Omit device_type and chat_only_reason while the reply is provisional. fetched
stays false, the next fetch re-reads, and the measured values land. Nothing the
launcher reads is affected -- version, studio_version and every capability bit
are still there, since none of them depend on detection.

tests/test_health_answers_within_probe_budget.py: with detection stubbed at 10s
the authed reply must not carry device_type or chat_only_reason (it did before
this change) and must still carry version; with detection at 0.3s both come back.

* Run the stack-dependent startup work after the warm

Two things still imported the ML stack ahead of the socket bind, so the login
screen waited on them.

start_mlx_autorepair_if_needed() ran inline on the lifespan thread, and its
mlx_stack_available() probe imports mlx.core, mlx_lm, mlx_lm.sample_utils and
mlx_vlm whenever the installed versions are acceptable. That is the healthy
Apple Silicon case, so a normal Mac got no benefit from deferring torch at all:
uvicorn binds only once the lifespan yields. The probe cannot be reduced to a
metadata check, because importing after the version gate is exactly what lets a
version-satisfying but broken install be detected and repaired.

_warm_rag_embedder started on a thread early enough to race the coordinated
warm. With RAG_EMBED_BACKEND=auto a GPU host selects sentence-transformers, so
embeddings.warm() pulls sentence-transformers, transformers and torch while the
lifespan is still working toward yield, contending for the GIL and the import
locks and bypassing the warm's hardware-first ordering and its purge-on-failure
handling.

Both now run on one post-warm thread that joins the coordinated warm first, so
the stack is imported once, in the intended order, and they run against a warm
module cache.

tests/test_startup_defers_stack_dependent_work.py: the lifespan must reference
neither entry point, the post-warm function must join before doing either piece
of work, and something must actually start the thread. All four fail if either
call is moved back.

* Offload the singleton build in every async handler that touches it

get_inference_backend() builds the orchestrator on first call, and that runs
get_default_models() -> hw.get_device(), so the first caller waits for the
background warm. Fourteen async handlers still called it inline, including
/v1/chat/completions, generate_stream, unload_model and the OpenAI model
routes, so any of them landing in the warm window held the event-loop thread
for the whole torch import and stalled login, liveness and the deadline-bound
desktop health probe. Only the three first-paint polling routes were covered.

The offload stays at the call site, handing the route module's own
get_inference_backend to a thread, matching what routes/inference.py:7072 and
routes/models.py:1750 already do. An async accessor in orchestrator.py was
tried first and reverted: it resolves that module's global, so callers and
tests that patch routes.inference.get_inference_backend were silently
bypassed, and test_orchestrator_unload_cancel hung on a load gate that never
opened. tests/test_async_singleton_access.py records that, so the accessor is
not reintroduced as an optimisation.

test_openai_tool_passthrough's cancel test replaces to_thread with a stub that
cancels every hop except one it names as running "before the row opens". The
singleton resolution is a second such hop, so it joins the same carve-out; the
assertions on the finalized monitor row are unchanged.

* Prove the deferred startup work still runs

The existing checks are lexical: they assert the lifespan no longer reaches for
the MLX probe or the RAG warm. They would all still pass if the deferred work
never ran at all, which would quietly end MLX self-healing and leave the RAG
embedder cold on every launch. Deferring must not become dropping.

Two runtime tests: the post-warm function joins the warm and then runs both
stages in that order, and a failing MLX probe does not strand the RAG warm
behind it. Removing the deferred _warm_rag_embedder() call fails both.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep remaining warm-window work off the event loop

* Keep the rest of the cold-start work off the event loop

Three paths still paid the cold build or a heavy import on the loop.

_loaded_satisfies calls get_inference_backend() inline and runs four lines
above the offload added for _reject_unservable_model, so on the
llama-not-loaded path the singleton was still built on the loop. It is a sync
def, and the guard sweep only walked async def bodies, so nothing caught it.
Offloaded at all three call sites, and the sweep now follows sync helpers too.

ModelConfig.from_identifier() runs in validate_model, _load_model_impl and
get_model_config. Its first call builds the vision/audio detection registry,
importing transformers or blocking on _DETECTION_SETS_LOCK while the warm
thread imports it. All three are offloaded; the _load_model_impl one takes the
_hf_offline_if_dns_dead guard to the worker with it, since that DNS probe is a
network round trip that does not belong on the loop either.

The health wait treated DEVICE going non-None as "detection finished". It is
not: the branches assign it partway through and keep probing -- the XPU branch
sets DEVICE and CHAT_ONLY=False before torch.xpu.get_device_name(0), and if
that raises, ensure_hardware_detected degrades the host to CPU/chat-only. A
waiter keyed on the assignment publishes training-enabled for that host, and
config/env.ts caches it for the SPA session. Added DETECTION_COMPLETE, set only
where a final value is guaranteed and deliberately not in a finally: a raise
leaves the partial assignment in place, which is exactly what must not be
published. The health test stubs replace ensure_hardware_detected, so they now
honour the same protocol rather than signalling completion by assigning DEVICE.

The sweep also found 19 more sites reaching the singleton through five sync
helpers across the OpenAI, Responses, monitor and unload paths. Rather than
offload all of them, the warm builds the orchestrator in its own stage after
hardware detection, so the getter is a plain dict read before any of them run.
Those five are a frozen baseline in the test: the set must not grow. This is
containment, not a cure -- a request arriving between the bind and that stage
still pays the build, correctly under the lock but not quickly.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Purge every failed warm stage, and stop misreporting a failed detection

purge_partial_import was called in one place, inside the unsloth_zoo stage.
_run_stage catches without purging, so a datasets, transformers or torch import
that died partway left its submodules in sys.modules with the parent evicted:
the retry re-runs __init__ against cache hits and returns a package that
imports but is missing attributes, broken until restart while warm_status()
reports nothing worse than a cold stage. Mapped stage to package and purge on
the failure path for all of them. inference_backend is excluded on purpose --
it builds an object and imports nothing. The purge still declines by itself
when a loaded C extension is among the leftovers, so this is safe for torch.

_resolve_gguf_gpu_ids_for_request is an async def that called get_device()
inline, reached from both _load_model_impl and validate_model, so a GGUF load
or validate carrying gpu_ids in the warm window held the event-loop thread for
the cold torch import. Offloaded, and added to _OFFLOAD_REQUIRED so it stays
that way.

export_capability() had no branch for a failed detection. ensure_hardware_detected
records CPU + "detection_failed" when the probe raises, so an accelerator host
with a broken CUDA/XPU probe was told to install PyTorch or that it has no GPU --
both point the remediation at something that may be fine. The new branch goes
ahead of the others, with a test that a genuinely CPU-only host still reports
no_accelerator so it cannot swallow the case it sits in front of.

* Refresh the curated defaults when hardware is re-detected

This one is a regression the warm stage introduced. InferenceOrchestrator
snapshots get_default_models() in __init__, and that reads detection state:
a chat-only host gets the GGUF-only list. Detection is not once-per-process --
the MLX self-heal re-detects after a successful repair and flips CHAT_ONLY --
and the warm builds the singleton before the post-warm thread starts that
repair, so on Apple Silicon with a stale MLX stack the snapshot is guaranteed
to be the pre-repair one. The list then stayed chat-only for the rest of the
process, including after the reload the repair log asks the user to do. Lazily
the build could still land after the repair; the warm stage made it certain.

Blocking the warm on the repair is not the answer, since the repair is a pip
install. Detection now carries DETECTION_GENERATION, bumped wherever it settles
on an answer, and the orchestrator records the generation its snapshot came
from and recomputes when that moves. That covers any re-detection, not only the
MLX path.

Both increment sites needed a global declaration or the first detection would
have raised UnboundLocalError; verified the counter actually advances across a
first detect and a forced redetect rather than trusting the edit.

tests/test_defaults_refresh_after_redetect.py: the list follows a redetect, two
reads without one do not recompute (the refresh is keyed on the generation, not
on every access), and the counter advances on every settled detection.

* Unpublish detection while a forced re-detect is in flight

_detect_hardware_locked() resets CHAT_ONLY to True and CHAT_ONLY_REASON to None
before re-probing, and a forced pass runs while a previous answer is already
published. With DETECTION_COMPLETE left set, /api/health reported that
intermediate state as settled: the sidebar's MLX recovery poll only continues
while chat_only_reason == "mlx_unavailable", so a reply carrying None during the
repair's re-detect stops the poll and leaves Train hidden on a host the repair
had just fixed.

Clear for the duration of the forced pass and republish once it settles.

The clear needs an exception path, and getting it wrong ships something worse
than the bug: leaving the event clear after a raising pass strands health
provisional for the life of the process, because start_background_detection()
declines once DEVICE is set, so nothing would republish. A failing pass restores
what was published before it, which puts the caller back where a failed forced
re-detect already left them.

Both halves are covered separately: dropping the clear fails the mid-pass test,
dropping the restore fails the strand test.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pin utf-8 when the startup guards read source files

The Windows staging leg failed three of these guards. Path.read_text() defaults
to the locale encoding, which is cp1252 on Windows, and routes/inference.py
carries 2442 non-ASCII bytes that cp1252 cannot decode -- it dies on 0x81.

So the guards were not failing honestly there: they raised UnicodeDecodeError,
which surfaces as "the offload is missing" when the code is fine. main.py,
routes/models.py and orchestrator.py happen to decode under cp1252, which is why
only the three tests that read inference.py went red.

Every read is pinned now, including the ones that got lucky. The existing
suite already does this (test_health_answers_within_probe_budget.py reads
main.py with an explicit encoding); these were the outliers.

* Re-detect and re-warm when a second lifespan starts in one process

Shutdown clears hardware.DEVICE, so after it the process holds no measured
device. Two pieces of bookkeeping were left behind:

/api/health takes DETECTION_COMPLETE as "detection finished, DEVICE is
authoritative", so a set event over a cleared DEVICE published a device that
was already torn down instead of kicking a fresh detection. Clear it with
DEVICE, guarded, so the injected hardware stubs in the shutdown tests keep
working and a hostile module cannot skip the compiled-cache clear.

torch_warmup held its one-warm-per-process latch on a finished thread, so the
second lifespan's start_background_warm() was a no-op and the stack stayed
cold. reset_background_warm() releases it, and declines while a warm is still
running so two warms can never share the same imports.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Expose the two detection entry points as wrappers, not re-exports

scripts/verify_import_hoist.py reads a newly added module-level import that
nothing in the file loads as a botched hoist, and `__all__` membership is not a
use as far as it is concerned. export_capability() and get_torch_device_str()
in this same file already delegate rather than re-export, so follow that.
Delegating also means callers always reach the live function in hardware.py.

* Baseline the floating-monitor geometry only once the live rows have landed

exercise_floating_monitor_geometry captures one baseline box and compares every
later assertion against it, but the panel's rows come from /api/system, which
the page fetches after mount: a freshly mounted monitor grows when RAM and VRAM
arrive. Baselining mid-arrival leaves the baseline at the pre-fetch size, and
whichever step the response lands in is the step that fails.

Instrumented on macOS. Against a warm backend the rows were already in at
baseline but the box had not re-laid-out (box 160, min-height 172), and the
shrink step failed with 172 against 160. Against a cold one the baseline was
the pre-fetch panel (113.5, 'RAM 0% 0.00 GiB / 0.00 GiB'), the rows arrived
during the shrink, and the blocked-resize step failed with 172 against 113.5.
Same stale baseline, different victim.

Wait for real totals and a box that agrees with the resolved min-height, twice
in a row, before baselining. Best effort: a host where /api/system never answers
proceeds rather than failing a geometry check on a data problem.

* Close the remaining warm-window edges from review

Five separate problems in the interval between the socket binding and the ML
stack being importable.

Generation counter: ensure_hardware_detected() is the cached path too, so every
get_device() on a warm process bumped it. The orchestrator reads a change as a
hardware re-detection, so any GPU or export helper made the next model list
rebuild its curated defaults and log a re-detect that never happened. Bump it
only where detection ran; keep setting the completion event unconditionally,
since a late waiter still has to find it set.

Failed forced re-detect: _detect_hardware_locked() resets CHAT_ONLY,
CHAT_ONLY_REASON and IS_ROCM on entry and assigns DEVICE partway through, so a
raise left half a verdict published -- and the MLX autorepair path catches that
exception. Losing a mlx_unavailable reason is the permanent case, because the
sidebar recovery poll only continues while it reads that reason. Snapshot the
whole published verdict and put it back.

Boot silence: building the orchestrator on the warm thread turned its ranking
fetch into an unprompted request to huggingface.co on every boot, before anyone
signs in, on hosts that may never serve one. Start it from the first reader of
the ranking instead, and skip it under HF_HUB_OFFLINE, which a raw httpx.get
does not honour on its own.

Two more sync helpers reaching the singleton from the loop thread:
_openai_model_objects() (called inline by both /v1/models handlers, ahead of the
offload further down the module) and the capability block in get_model_config,
where is_vision_model() reaches _detection_sets().

Kill switch: UNSLOTH_STUDIO_DISABLE_TORCH_WARM also skipped MLX autorepair,
which is not torch, has its own opt-out, and ran in the lifespan unconditionally
before the deferral -- so the switch left a broken-MLX Mac chat-only for good.
Gate only the RAG warm.

Also narrow the purge_partial_import race: a request retrying the same package
can republish the parent between the membership check and the pops, so re-check
before and during them rather than stripping submodules under a live import.

16 new tests, each shown to fail with its fix reverted.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tie the post-warm thread to the lifespan that started it

The thread was fire-and-forget, and it spends nearly all its life parked in
join_background_warm(), so a shutdown landing before the warm finishes could not
reach it: it woke up afterwards and went on to run MLX autorepair and the RAG
warm for an application that had stopped. The RAG warm is the sharp end -- it can
load an embedder and, when sentence-transformers fails, fall back to spawning a
llama-server.

Track the thread and signal it instead. _stop_post_warm_thread() sets an event
that the worker re-checks after its join, so shutdown returns immediately rather
than holding for the rest of the ML stack import, which is the stall this path
exists to avoid. Tracking also stops a second lifespan from stacking another
post-warm thread on a first that is still waiting -- reachable now that the warm
itself is restartable.

Five tests, each mutation-checked. One earlier test asserted the lifespan
referenced _post_warm_background_work directly, which the indirection breaks; it
now follows the helper and additionally checks the helper targets the real work,
so a thread that starts nothing cannot satisfy it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the health verdict atomic and hand post-warm work to the new lifespan

Three faults, all in the window a forced re-detect opens.

/api/health decided `detected` once, before the bearer await, then read
CHAT_ONLY_REASON after it. The MLX autorepair's forced pass clears the completion
event and resets CHAT_ONLY/CHAT_ONLY_REASON on the way in, so the reply could
carry chat_only=true with a null reason -- and config/env.ts caches the first
reply bearing device_type as authoritative while the sidebar poll only continues
while it reads mlx_unavailable, so one such reply hides Train for the rest of the
SPA session. Read the verdict through _hardware_snapshot(), a seqlock over the
completion event and the generation counter, and take it again after the await so
chat_only, its reason and device_type all come from one pass. Not _DETECT_LOCK:
that would park the endpoint for the whole torch import.

_await_hardware_detection() keyed off the completion event alone. Shutdown clears
DEVICE and then the event, and a detector racing that can set the event back, so
the process can sit at event-set-with-DEVICE-None -- which the fast path reported
as detected, so nothing kicked a new detection and the next lifespan served a
verdict that had been torn down. Require a device as well; that state now goes
down the start_background_detection() path, which runs precisely because DEVICE
is None.

The post-warm handoff was my own regression from the previous commit. Declining
to start while the retired worker was still parked meant a restart got no worker
at all: the old one was alive so the start returned early, then the old one read
the shutdown and exited, leaving the new lifespan with neither MLX autorepair nor
the RAG warm -- a broken-MLX Mac stayed chat-only for the whole restart. Replace
the stand-down event with a generation: every start and stop bumps it, a worker
captures its own and drops out when it no longer matches, so overlap is safe
instead of forbidden and a parked thread costs nothing.

Nine tests, each mutation-checked; one of them initially passed against a
reverted fix because the docstring named DEVICE, and now matches the comparison
rather than the prose.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep the warm-window test docstring in step with what it covers

It still said five edges after three more rounds added four.

* Offload the cached-model delete guard off the event loop

delete_cached_model_response() evaluated its load-state guard inline
before reaching its first to_thread hop, so _inference_backend_blocks_delete()
called get_inference_backend() on the event loop. During the warm window that
getter is the one that imports the inference backend, which parks every other
request on the loop for the duration.

Move both guards into a nested helper handed to asyncio.to_thread. The `or`
short-circuit is preserved inside the helper, so the inference-backend probe
still only runs when no GGUF variant already blocks the delete, and the
fail-closed try/except still wraps the offloaded call.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honour the torch-warm switch in health, and offload the vision probe

Three warm-window edges from the latest review, plus a regression the repo
suite caught.

/api/health kicked start_background_detection() even with
UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1. The desktop preflight and the frontend's
first fetch both probe health without being asked to, so the switch bought
nothing: torch was imported in the background anyway, on exactly the hosts
whose owner set it because that import is broken or expensive. Health now
answers provisionally and the first hardware-dependent operation detects, which
is what the switch has always promised. test_health_answers_within_probe_budget
set the switch as belt-and-braces; it never runs the lifespan, so no warm could
start there regardless, and setting it now suppressed the kick under test.

GET /api/models/check-vision called is_vision_model() inline. The registry sets
behind it are built lazily, so the first call either imports transformers or
waits on _DETECTION_SETS_LOCK while the warm holds it, parking the event loop
for the rest of that import. Offloaded like the /config capability block.

The top-models ranking guard compared HF_HUB_OFFLINE to the literal "1".
HF_HUB_OFFLINE=true/yes/on and TRANSFORMERS_OFFLINE are offline everywhere else
in this backend, so an offline boot still made a raw outbound httpx.get. It now
goes through hf_env_offline(), the shared helper.

_has_torch() imported utils.torch_warmup unconditionally on the failure path.
tests/python/test_e2e_no_torch_sandbox.py copies hardware.py into a torch-less
venv and execs it with no package around it, where a sibling import is
unresolvable: "no torch, take the CPU path" became ModuleNotFoundError. The
purge is now best-effort, which is right anyway, since that path only runs where
there is no torch to purge.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Re-arm the warm latch, offload the saved GPU pin, recheck retirement

reset_background_warm() declines while a warm is still alive, which is the
normal case for a shutdown that lands mid-warm. If that warm then finished, the
thread object stayed in place and the next lifespan's start_background_warm()
read it as "already started" and skipped the warm entirely, over the hardware
verdict the same shutdown had just cleared. A finished thread no longer counts
as a running one. test_lifespan_restart_rewarms asserted the old behaviour
outright, so it now covers both restart paths, with a live-warm control that
still refuses a second warm.

_override_gpu_ids_still_resolve() ran the device-dependent block on the event
loop. The auto-switch path reaches it before any of the explicit-gpu_ids
offloads, so the first OpenAI request to a model with a stored pin held the loop
for the whole torch import. Offloaded as one hop, since
resolve_requested_gpu_ids() reaches get_device() and get_physical_gpu_count()
itself.

The post-warm worker checked its generation once, right after the join. Shutdown
routinely lands there, but the MLX autorepair and the RAG warm each take their
own time too, and the RAG warm can spawn a llama-server, so a shutdown arriving
after the check still got both. The check is now a named helper called before
each action.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten comments in the deferred-startup paths

* Do not act on a provisional hardware verdict, and close three warm-window gaps

Review follow-ups for #7607, each reproduced before fixing.

The frontend acted on a health reply that says it is provisional. /api/health
answers before detection lands and marks that with hardware_detecting, but
nothing read the field: env.ts stored its chat_only and __root.tsx's beforeLoad
redirected on it. Measured on a 4 GPU host from a cold isolated install, the
first reply is chat_only true and settles to false about a second later, so the
first page load went to /chat with Train hidden. fetchDeviceType now re-reads for
up to 5s while the reply stays provisional, and never stores one that still is.
The decision lives in config/hardware-verdict.ts, which imports nothing, because
env.ts reaches import.meta.env through api-base.ts and no test runner outside
vite can load it.

Shutdown cleared the hardware globals while a detection could be inside the torch
import, and that detector then republished a settled looking verdict over the
reset. The next lifespan read a non-None DEVICE and skipped detection, serving
the retired run's answer. Taking _DETECT_LOCK in shutdown would park teardown
behind the whole import, so shutdown bumps a detection epoch instead and a pass
that finds it moved discards its result.

mcp_server's status tool called get_gpu_utilization() inline, the one
synchronous read left among awaited values, and _resolves_to_resident() read the
Transformers singleton on the event loop at the two call sites the llama.cpp
short-circuits reach first. Both are offloaded.

An authenticated health reply could carry hardware_detecting beside the measured
device_type and chat_only it qualifies. The marker is dropped once the second
snapshot succeeds.

Three tests were weaker than they claimed. The delete guard's fail-closed
assertion matched any try whose dump mentioned to_thread and any raise inside it,
so a decoy passed with the real guard deleted; it now binds to the try that
offloads _load_state_blocks_delete and requires the raise in a handler. The
offload count matched literal source text and broke on reformatting, which
pre-commit.ci does here; it counts AST nodes now. The frozen singleton baseline
listed _resolves_to_resident and a helper that is no longer an offender, and
justified itself with an ordering that does not hold: the warm runs its hardware
stage, the multi-second torch import, before the inference_backend one, so the
getter is cold during exactly the window this branch targets.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stop the warm and the monitor reads from outliving what they describe

Four review follow-ups for #7607.

With UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1 health never kicks detection, so its
provisional reply never settles and the frontend's re-read burned its whole 5s
budget on every load, including /login. Health now says which state it is in
with hardware_detection_deferred, and the client stops waiting when detection is
deferred rather than in flight. It still declines to store the provisional
verdict, so a GPU host is not sent to chat-only either way.

Retiring the hardware stage was not enough on its own. The warm carried on to
the inference_backend stage, whose constructor path reaches get_device(), so a
shutdown landing in the torch import got a fresh detection that republished
DEVICE after teardown had cleared it. The stage loop now re-reads the detection
epoch and stops once its lifespan is gone.

_monitor_context_length() and the remote-code-scan cleanup both described what
was loaded by calling the constructing getter. During the warm that builds the
orchestrator, which reaches get_device(), so answering "nothing is loaded" cost
the torch import on the event loop. Both peek instead. The peek honours a
patched routes.inference.get_inference_backend, since injecting a backend that
way is the module's seam and the monitor tests rely on it; reading the
orchestrator global directly broke test_non_streaming_safetensors_records_usage.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep the resumed research probes off the event loop during the warm

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Bind background workers to the lifespan that spawned them, and close four warm-window gaps

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Discard only what the pass produced, and take the deferred verdict conservatively

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments in the deferred startup path

* Carry the warm epoch into detection, and keep two more paths off the torch import

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let the public hardware wrapper carry the epoch, and retire post-warm work first

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep the warm latch held for the lifespan that owns it

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Retire the warm epoch at shutdown entry, not several awaits in

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* A broken torch wheel raises ImportError too, so key on absence not the class

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added since the last pass

* Hand the warm over when a retired one is still running

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Do not hold the login screen behind hardware detection

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Commit a refreshed default list only while it is still the newest

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Do not probe hardware for a lifespan that already ended

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let a deferred verdict recover, and never ship it beside a measurement

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added since the last pass

* Keep the provisional health verdict conservative for PR #7607

Every accelerator branch of _detect_hardware_locked sets CHAT_ONLY = False
before the probe that can still raise and degrade the host to CPU, and
DETECTION_COMPLETE stays clear across that window. /api/health fell back to a
bare CHAT_ONLY read whenever no settled snapshot existed, so a reply landing
mid-pass published chat_only: false. hardware-verdict.ts stores data.chat_only
verbatim for deferred replies, which flashes Train and Export on a host that
ends up chat-only.

Use the literal True instead. It matches what the surrounding comment already
claims the value is, and the shutdown reset becomes defence in depth rather
than the only guard.

* Bind nested detections to the warm epoch for PR #7607

The stage-boundary checks only catch a shutdown that lands between two stages.
_warm_inference_backend builds the orchestrator, whose constructor reaches
get_default_models() -> get_device(), and get_device() takes no epoch, so a
shutdown landing mid-construction let that nested pass adopt the epoch it was
being retired into and publish DEVICE over the teardown. The successor warm
then found a non-None DEVICE and skipped detection for the new lifespan.

Bind the read to the pass rather than thread an epoch through the constructor:
hardware.py gains a thread-local owning epoch and an owning_detection_epoch()
scope, ensure_hardware_detected consults it before falling back to the current
epoch, and _warm wraps its whole stage loop in it. Behaviour is unchanged when
no scope is held, and any stage reaching hardware is covered, not just this
one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Extend the epoch guard to the MLX self-heal for PR #7607

Three fixes from a fresh review pass over the branch.

detect_hardware() guards a shutdown landing mid-pass but read the current epoch
itself, so the MLX self-heal, whose repair is a pip install that can outlast the
lifespan, adopted the epoch shutdown moved to and published a verdict for a
lifespan that had ended. It now consults the owning epoch, and the repair thread
is bound to the epoch read before start() like the other background workers.

purge_partial_import() bailed correctly when another importer republished the
parent, then logged and returned the whole planned list, so the warm reported a
clean slate the next import would not get. It now reports what it removed.

health_check assigned _await_hardware_detection() to a local it never read,
which implied the boolean shaped the response when _hardware_snapshot() does.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep read-only paths from constructing the backend for PR #7607

The monitor overlay, the chat UI's status poll and a finetuned delete each built
the inference singleton just to report that nothing was loaded. Construction
reaches get_default_models() -> get_device(), so a read-only poll imported torch
even with UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1, which is the one thing that
switch promises not to do. All three peek now, and the status handler returns an
empty InferenceStatusResponse when no singleton exists.

env.ts treated any stored token as one the backend would accept. A rejected
token gets the unauthenticated body, which never carries device_type, so the
wait could only ever time out while beforeLoad held /login. The loop now stops
as soon as a provisional reply lacks the authed-only version field.

The desktop probe guard read the client timeout out of a builder call that main
has since replaced with loopback_http::client(), so it matched nothing once
merged. It now reads the whole-seconds Duration inside the probe, which both
shapes share.

Three offload baselines move with the peeks: the floor drops to 13, the
sync-helper allowance is empty now that both monitor helpers peek, and
get_status leaves the first-paint offload list. test_async_singleton_access
gains a guard that the read-only paths never construct.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Preserve the detection window for the first accepted token for PR #7607

Breaking out of the wait on a rejected token still marked the once-per-page-load
window as spent, so a user who signed in before detection settled had the first
authenticated read of that page load skip its poll and leave the route guard and
sidebar on provisional local defaults until a navigation or refresh.

* Reject a stale forced detection before it clears anything for PR #7607

Binding the MLX worker to its spawn epoch routes a late repair into
detect_hardware() with a stale epoch. That path snapshots, clears
DETECTION_COMPLETE, probes, then discards on the epoch mismatch. Run over a
restarted lifespan that has already published, the discard erases the new
verdict rather than merely failing to add its own, dropping the fresh lifespan
back to provisional.

ensure_hardware_detected() already rejected a stale epoch early; detect_hardware()
now does too, returning before it clears or probes. Unchanged when no owning
scope is held, so ordinary forced re-detects behave exactly as before.

* Reopen Train after a repair that outlived its lifespan for PR #7607

Declining the stale forced pass fixed the erasure but introduced a worse
outcome: a successful MLX install whose repair outlived its lifespan left the
running one holding a verdict measured before mlx existed. _attempted is
process-wide, so no later repair revisits it, and a Mac that is now perfectly
capable stayed chat-only until a restart. When the scoped pass declines but the
install succeeded, re-detect under the live epoch.

ensure_hardware_detected() also has to clear DETECTION_COMPLETE before it
produces a verdict, the same way detect_hardware() already does. Shutdown
clearing DEVICE while a cached waiter goes on to set the event leaves it set with
DEVICE None, a state _await_hardware_detection() treats as detect-again, so the
pass could publish the XPU candidate as settled before the probe that falls back
to CPU.

* Tighten the comments across the PR

---------

Co-authored-by: Daniel Han <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-02 01:11:46 -07:00

204 lines
9.2 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Async handlers must not build the inference singleton on the event loop.
Construction runs get_default_models() -> hw.get_device(), so the first caller waits
for the background warm. Inline, that holds the event-loop thread for the whole torch
import, stalling login, liveness and the deadline-bound desktop health probe.
The offload has to stay at the call site, passing the route module's own
`get_inference_backend` to a thread. A helper in orchestrator.py would resolve that
module's global instead, bypassing callers that patch `routes.inference.get_inference_backend`.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
_BACKEND = Path(__file__).resolve().parent.parent
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
# Every read below pins utf-8: Path.read_text() defaults to the locale encoding (cp1252
# on Windows), which cannot decode routes/inference.py, so these guards would raise
# instead of failing honestly.
_ROUTE_FILES = ("routes/inference.py", "routes/models.py")
def _async_call_sites(rel: str) -> list[str]:
"""Bare get_inference_backend() invocations inside an async def.
`asyncio.to_thread(get_inference_backend)` passes the function object, an ast.Name and
never an ast.Call, so only real on-loop invocations are reported."""
tree = ast.parse((_BACKEND / rel).read_text(encoding = "utf-8"))
found = []
for fn in ast.walk(tree):
if not isinstance(fn, ast.AsyncFunctionDef):
continue
for sub in ast.walk(fn):
if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
continue
if sub.func.id == "get_inference_backend":
found.append(f"{rel}:{sub.lineno} in async {fn.name}")
return found
def test_no_async_handler_builds_the_singleton_inline():
offenders = [s for rel in _ROUTE_FILES for s in _async_call_sites(rel)]
assert not offenders, "async handlers building the singleton inline:\n " + "\n ".join(
offenders
)
def test_the_offload_is_actually_present():
"""Guard against the sweep passing because the calls simply vanished. Counted off the
AST: a literal-string count would report the offload gone the moment a formatter wraps
one of these calls across lines."""
total = 0
for rel in _ROUTE_FILES:
tree = ast.parse((_BACKEND / rel).read_text(encoding = "utf-8"))
total += sum(
1
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "to_thread"
and any(isinstance(a, ast.Name) and a.id == "get_inference_backend" for a in node.args)
)
# 13, not 14: the status poll's site became a non-constructing peek, which needs no
# offload at all. Lower the floor only when a site is removed that way, never when
# one goes back on the loop.
assert total >= 13, f"expected the offloaded call sites to survive, found {total}"
def _sync_helpers_that_build_the_singleton(rel: str) -> set[str]:
"""Sync functions in this module that call get_inference_backend() inline."""
tree = ast.parse((_BACKEND / rel).read_text(encoding = "utf-8"))
names = set()
for fn in ast.walk(tree):
if not isinstance(fn, ast.FunctionDef): # sync only
continue
# The peek helper is the module's injection seam: it invokes the getter only
# when that global has been patched, which is a test double, and otherwise
# returns orchestrator.peek_inference_backend(). Reading it as a builder would
# report every caller that deliberately stopped constructing.
if fn.name == "_peek_inference_backend":
continue
for sub in ast.walk(fn):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Name)
and sub.func.id == "get_inference_backend"
):
names.add(fn.name)
return names
def test_no_async_handler_reaches_the_singleton_through_a_sync_helper():
"""The direct sweep is not enough: a sync helper hides the same stall. _loaded_satisfies
calls get_inference_backend() inline, so an async handler calling it on the loop pays
the cold build all the same, and walking only ast.AsyncFunctionDef misses that."""
offenders = []
for rel in _ROUTE_FILES:
helpers = _sync_helpers_that_build_the_singleton(rel)
if not helpers:
continue
tree = ast.parse((_BACKEND / rel).read_text(encoding = "utf-8"))
for fn in ast.walk(tree):
if not isinstance(fn, ast.AsyncFunctionDef):
continue
for sub in ast.walk(fn):
# A bare Call to the helper runs it on the loop; passing it to
# to_thread makes it an ast.Name argument, never a Call.
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Name)
and sub.func.id in helpers
):
offenders.append(f"{rel}:{sub.lineno} async {fn.name} -> {sub.func.id}()")
# Empty on purpose. Both monitor helpers used to sit here as a known gap: they
# reached the singleton through a sync helper and were not individually offloaded,
# so they blocked during exactly the window this path exists to fix. Both now peek
# instead. Do not add a name back without an offload or a justification here.
known: set[str] = set()
# _resolves_to_resident is offloaded at its two singleton-reading call sites. The
# third, in _openai_catalog_objects, passes llama_only = True, under which the
# helper never evaluates the getter. This sweep matches on callee name and cannot
# see that, so exempt by argument rather than blanket-exempting the helper.
def _is_llama_only(site: str) -> bool:
rel, rest = site.split(":", 1)
lineno = int(rest.split(" ", 1)[0])
tree = ast.parse((_BACKEND / rel).read_text(encoding = "utf-8"))
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and getattr(node.func, "id", None) == "_resolves_to_resident"
and node.lineno == lineno
):
return any(
kw.arg == "llama_only"
and isinstance(kw.value, ast.Constant)
and kw.value.value is True
for kw in node.keywords
)
return False
offenders = [o for o in offenders if not _is_llama_only(o)]
new = [o for o in offenders if o.rsplit("-> ", 1)[-1].rstrip("()") not in known]
assert not new, (
"new async handlers reaching the singleton through a sync helper; "
"offload at the call site rather than widening the baseline:\n " + "\n ".join(new)
)
def test_the_offload_stays_at_the_call_site():
"""No orchestrator-level async helper: it would bypass patched route globals.
tests/test_orchestrator_unload_cancel.py patches routes.inference.get_inference_backend.
An accessor defined in orchestrator.py resolves orchestrator's own global, so the patch
would not take and the test hangs on a load gate that never opens."""
orch = (_BACKEND / "core/inference/orchestrator.py").read_text(encoding = "utf-8")
assert "async def get_inference_backend_async" not in orch, (
"an async accessor in orchestrator.py bypasses callers that patch the "
"route module's get_inference_backend"
)
# The read-only surface: these answer "what is loaded" and must never be the reason a
# host imports torch. Each is polled from first paint or fired by a metadata-only
# action, so building the singleton here defeats UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1
# until a genuinely hardware-dependent operation runs.
_READ_ONLY_SITES = (
("routes/inference.py", "_monitor_active_model"),
("routes/inference.py", "get_status"),
("routes/models.py", "delete_finetuned_model"),
)
def test_read_only_endpoints_never_construct_the_singleton():
"""Peek, not build. A peek is a plain global read, so it needs no offload either."""
offenders = []
for rel, name in _READ_ONLY_SITES:
tree = ast.parse((_BACKEND / rel).read_text(encoding = "utf-8"))
fn = next(
(
node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
),
None,
)
assert fn is not None, f"{rel}:{name} moved; update this guard"
for sub in ast.walk(fn):
# Both shapes: a bare call on the loop, and the name handed to to_thread,
# which still constructs and still imports torch.
if isinstance(sub, ast.Name) and sub.id == "get_inference_backend":
offenders.append(f"{rel}:{sub.lineno} {name}")
assert not offenders, (
"read-only paths construct the inference singleton, so a status poll or a "
"metadata-only delete imports torch on a warm-disabled host:\n " + "\n ".join(offenders)
)