unsloth/studio/backend/tests/test_orchestrator_unload_cancel.py
Daniel Han 815f242970
Studio: offer the latest transformers release for brand-new architectures (#7056)
* Studio: offer the latest transformers release for brand-new architectures

When a model's config.json model_type is absent from every installed
transformers overlay (base 4.57.x and the .venv_t5_530/550/510 sidecars),
Studio now checks, unauthenticated and cached, whether the newest
transformers ships it:

- utils/transformers_latest.py fetches the latest release version from
  https://pypi.org/pypi/transformers/json and the CONFIG_MAPPING_NAMES
  sources for that tag and for main from raw.githubusercontent.com
  (never api.github.com), parsing them with the same AST extractor the
  static router uses (no code execution, no trust_remote_code). Results
  are cached in memory and in a JSON snapshot under studio_root()/cache
  with a one day ttl; fetches are bounded to 5s with one retry and a
  failure backoff, and offline mode or the new kill switch
  UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS=1 short-circuits to None.

- POST /api/inference/validate gains requires_transformers_upgrade plus
  a transformers_upgrade payload (model_type, pypi_version,
  supported_in_pypi, supported_in_main) so the frontend can raise the
  install consent dialog before /load, mirroring the existing
  remote-code consent flow. The check fires only when the model_type is
  unknown to all installed overlays and the hardcoded tier tables.

- POST /api/inference/install-latest-transformers provisions a new
  persistent .venv_t5_latest sidecar after user consent, pinned to the
  exact PyPI version (re-verified server-side) with the same
  --target/--no-deps recipe as the fixed sidecars. A JSON pin marker
  inside the dir records the installed package set, so restarts
  revalidate it and routing resolves the new highest-ranked tier
  automatically. A dependency preflight (compat_plan) compares the
  release's requires_dist against the running env: unsatisfied
  tokenizers/safetensors floors are shadow-installed as exact pins into
  the sidecar, anything else unsatisfied blocks the install with a
  clear message.

Routing for every already-supported model_type is unchanged: the
hardcoded lists and the 530/550/510 static resolver run first, the new
tier only participates once its venv exists, and the probe order gains
the latest sidecar only when provisioned. Verified against live PyPI
and GitHub (transformers 5.13.0: 674 model_types, 26 absent from all
installed overlays, e.g. cosmos3_omni; 4 dev-only on main) and with a
real sidecar install plus restart persistence. 64 new tests; the
existing 200-test transformers_version suite passes unchanged.

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

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

* Latest-transformers check: fetch outside the lock, serialize installs

Release the module lock during the network refresh so a slow fetch cannot
stall other threads in the ASGI pool; concurrent callers during a fetch get
None (the graceful fallthrough) via an in-flight flag instead of stacking
fetches. Serialize install_latest_transformers with an in-progress flag so
concurrent consents cannot race the sidecar delete and recreate; the loser
gets a structured already-in-progress refusal.

* Latest-transformers check: LoRA bases, pin-gated mapping, live reverify

Run the upgrade check over the [adapter, base] target set so a LoRA whose
base model is a brand-new architecture surfaces the prompt (the worker
activates transformers for the base, not the adapter).

Gate the latest overlay's mapping lookup on a valid pin marker, matching
activation and the probe order, so a partial or manual .venv_t5_latest dir
cannot be routed to and then refused at activation.

Re-verify the requested version against a live PyPI snapshot at install
time, falling back to the cached one on fetch failure, so a release
published inside the cache TTL is not silently missed.

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

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

* Latest-transformers check: nested config types and latest-tier vision probe

Collect every model_type in the config (top level plus each nested
sub-config) and signal on the first one missing from all installed
overlays, so a supported wrapper carrying a brand-new backbone still
surfaces the upgrade prompt; wrappers instantiate sub-configs through
CONFIG_MAPPING and would fail on the nested type.

Route the vision capability subprocess through the pinned latest sidecar
when the model resolves to the latest tier, so latest-only VLMs are not
misclassified as text-only; every other tier keeps the 5.5 sidecar used
today.

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

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

* Latest tier: nested routing, vision probe after raw miss, safe upgrades

Route by every model_type in the config: a nested sub-config type can raise
the tier (wrappers instantiate sub-configs through CONFIG_MAPPING), so a
supported wrapper with a latest-only backbone routes to latest once
installed instead of staying on default. An unknown nested type never
vetoes; the primary type keeps its previous semantics. The collector is
shared with the upgrade checker.

Vision detection: when the raw heuristics say False for a model that routes
to the latest tier, run the AutoConfig subprocess under the pinned latest
sidecar instead of trusting heuristics built from older transformers.

Provisioning: stage-and-swap. Build the new sidecar in .venv_t5_latest.staging
and swap it in only when the install and pin marker are complete, so a failed
upgrade never destroys a previously working sidecar; restore the old dir if
the final swap fails.

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

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

* Latest-transformers checker, vision subprocess, and cache fixes

Require the latest release to support every missing model_type (the
primary included) before prompting; a nested-only match cannot make the
model loadable, so no install is offered for it.

The vision-check subprocess now unions the active sidecar's own
registry mappings into the inlined parent-process detection sets, so
architectures only the sidecar knows classify correctly.

A successful sidecar install clears the tier probe cache, the latest
tier's model_type mapping, and the vision-detection cache so the new
venv takes effect without a restart. Tests for all three.

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

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

* Aggregate upgrade support flags and keep install off /v1

The upgrade signal now reports supported_in_pypi only when the latest
release covers every missing model_type; a mix with a main-only nested
type surfaces as dev-only so no PyPI install is offered that would
still fail at load. The consented install endpoint moves to
studio_router so it is not reachable through the OpenAI-compatible /v1
mount. Tests for both.

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

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

* Honor the latest-transformers kill switch in routing

With UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS set after the sidecar was
provisioned, the latest tier still joined mapping and probe routing
because only the pin was checked. Both admission points now also check
the kill switch, so operators can roll back a problematic sidecar
without deleting files.

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

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

* Repair the latest sidecar through stage-and-swap

The lazy repair path installed into the live .venv_t5_latest, which
_ensure_venv_dir wipes first, so a failed repair deleted the pinned
sidecar and its marker. Both the consented install and the repair now
share one stage-and-swap helper: the incomplete-but-pinned dir survives
any failure and a later attempt can still repair it.

* Tighten comments

* Remove the staging dir when a latest-sidecar install fails

A pip failure inside _ensure_venv_dir returns False without raising, so
the except cleanup never ran and the partial .venv_t5_latest.staging
leaked until a later attempt. Also note on the validate response fields
that frontend consumption ships in the follow-up PR.

* Add the transformers-upgrade consent dialog to the frontend

When /validate reports requires_transformers_upgrade, every explicit load
path (chat runtime and the compare composer) now pauses on a consent
dialog modeled on the remote-code one: it names the model_type and the
latest PyPI transformers version, and on Accept calls
/api/inference/install-latest-transformers itself, shows an installing
state, and resumes the original load automatically on success. Errors
surface in the dialog with a retry; Cancel aborts the load like the
trust dialog's deny path. Architectures shipped only on transformers
main get a dev-only notice with no install button. Background auto-load
skips upgrade-requiring candidates instead of prompting, mirroring the
trust_remote_code rule. The dialog mounts once in the root layout and
runs before the security dialogs, since no load can proceed without the
runtime.

* Route a non-installable new architecture to the custom-code consent as a last resort

When the upgrade dialog has no installable PyPI release (the architecture
is only on transformers main, which Studio never installs), the dialog now
says so explicitly, and when the model also declares custom (auto_map)
code it offers Continue with custom code: resolving the paused load into
the existing trust_remote_code consent gate instead of hard-aborting.
Models with no custom code keep the Cancel-only notice. The backend
returns no upgrade signal at all for architectures unknown to both PyPI
and main, so those still route straight to the unchanged security gate.

* Force a 16-bit load for models on the latest-transformers sidecar

Live validation with Zyphra/ZAYA1-8B (model_type zaya, shipped by
transformers 5.13.1 but unknown to every installed tier) surfaced a
generation crash when the consented sidecar load kept the default bnb
4-bit quantization: transformers' grouped-MoE kernels feed the packed
uint8 expert weights straight into torch._grouped_mm, and generation
dies (plain 16-bit works). New latest_tier_active_for() mirrors the
sidecar activation's tier resolution and never raises; the inference
worker flips load_in_4bit off when it reports true, and the load route
applies the same flip so the pre-load VRAM guard and the worker command
agree. Fixed tiers are untouched. With the guard, ZAYA1-8B loads and
generates correctly in Studio chat.

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

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

* Offer the custom-code fallback when a latest-sidecar install fails

* Fail remote mapping fetches wholesale and mirror the 16-bit flip in validate

A transient fetch or parse failure of one auto-mapping file no longer caches
a partial latest-release map for the TTL (a real 404 on pre-5.10 tags is
still tolerated), and validate_model now applies the same latest-sidecar
16-bit sizing flip as /load before the training guard so the two agree.

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

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

* Tighten comments in the latest-transformers changes

* Resolve remote LoRA bases, fold nested tiers, and guard the sidecar swap

latest_tier_active_for now resolves a remote adapter's base model the same
way worker pre-activation does (and returns early without a sidecar pin), a
hardcoded fast-path tier is raised when a nested sub-config's model_type
needs a higher sidecar, and the install route refuses to swap .venv_t5_latest
while training runs on it and unloads a latest-tier chat model first.

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

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

* Gate the sidecar install on worker liveness and size installable upgrades 16-bit

The install route now refuses while any training or export runs (tier
re-resolution without the load token is unreliable for gated repos), holds
the inference lifecycle gate across the unload and the swap so no load can
interleave, and passes the model name to unload_model. validate_model runs
the upgrade check before the training guard and sizes an installable
upgrade as 16-bit, matching what /load and the worker will force after the
consented install.

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

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

* Close the sidecar install races and honor the kill switch over cached mappings

Training starts and mutating export routes now refuse while a transformers
install is in progress (shared is_install_in_progress flag), the chat unload
and idle export-worker teardown moved into a before_swap hook that runs only
once the staged install succeeded, and _config_model_types checks the kill
switch before returning a cached latest mapping.

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

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

* Reserve the sidecar swap before the gate wait and abort it on failed teardown

The install-in-progress flag moved into a shared sidecar swap reservation in
transformers_version, taken by the install route before awaiting the
inference lifecycle gate (so training and export starts see it for the whole
window) and by the lazy .venv_t5_latest repair path. The before_swap hook
now raises when the chat unload or export teardown reports failure, leaving
the previous sidecar untouched.

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

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

* Back the sidecar swap reservation with a cross-process lock file

The lazy repair runs inside worker subprocesses, where a module-level flag
is invisible to the parent's route checks. The reservation now also creates
a lock file next to .venv_t5_latest (O_EXCL, owner-only removal, stale after
two hours for crashed owners), so is_install_in_progress sees a repair from
any Studio process.

* Hand the swap reservation to the installer thread and harden pre-swap teardown

A cancelled install request no longer releases the reservation while the
installer thread is still staging (the thread owns and releases it, shielded
from cancellation). The route refuses while another inference request is
generating, export teardown runs before the chat unload and is judged by
worker liveness rather than the cleanup return value, and a live inference
worker with no active model (failed load residue) is shut down before the
swap.

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

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

* Keep the lifecycle gate with the installer and recheck the swap at spawn time

The gate moved into the shielded install task so a cancelled POST cannot
release the guard /load honors while the installer still runs, cached latest
probe results are ignored while the kill switch is set, and the training and
export subprocess spawns recheck the sidecar swap reservation right before
spawning (the route-level guards are one-shot and validation can outlast an
install's start).

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

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

* Close the spawn-registration windows against the sidecar install

Training marks the spawn in progress before its reservation recheck and
is_training_active honors the flag, so the install route sees a start that
has passed proc.start() but not yet recorded _proc. Export load-checkpoint
rechecks the reservation after setting _export_active and before tearing
down the old worker, so losing the race keeps the loaded checkpoint instead
of surfacing a 500.

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

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

* Refine the install-window interleavings around worker teardown

The inference busy count is rechecked under the lifecycle gate (streams
start by taking that gate, so nothing slips past a held gate), the training
handshake moved ahead of the VRAM-freeing before_spawn hook so a lost race
leaves chat/export intact, the export spawn-time check is op-aware (inside
an active op the install is the side that aborts), and the Xet-stall respawn
waits out a transient reservation instead of stranding the run.

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

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

* Track the install's server-side unload and guard export ops against the swap

The upgrade dialog store records when its install actually ran (the server
unloads the active chat model before swapping), and the load flow then marks
the previous model as unloaded so a later cancelled gate still triggers
rollback; the custom-code fallback leaves the flag unset. _run_export gained
the same reservation handshake as load_checkpoint so an install cannot block
behind an hours-long export op instead of returning 409.

* Tighten comments in the install-guard and upgrade-consent changes

* Surface install-race refusals cleanly and roll back after a failed swap unload

/load refuses while the sidecar swap is reserved so a load cannot succeed
and immediately be unloaded by the pre-swap teardown, worker starts that
lose the install race raise a typed SidecarSwapInProgress mapped to 409
instead of a 500, the install response reports model_unloaded even on a
structured failure so the client can restore its state, and the compare
flow tracks the server-side unload like the primary load path and clears a
stale checkpoint on abort.

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

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

* Type the export install races, scope the lock release, and keep the unload signal

Export load-checkpoint and export ops raise SidecarSwapInProgress (mapped to
409 in every export route) instead of a 400-shaped failure, the export spawn
check distinguishes repair reservations (always refused) from install ones
(op-aware), the swap lock release only unlinks a lock this process wrote so
a stale-superseded owner cannot drop the new owner's live lock, and the
frontend unload signal survives a superseding consent via read-and-clear
consumption instead of a reset.

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

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

* Finalize a stalled run when the respawn loses the install race and latch the unload signal

The Xet-stall respawn timeout now finalizes the run as a failure instead of
raising into the pump's broad finalization catch (which stranded it in a
training state with no worker), and a successful install retry ORs the
model_unloaded signal with the latched value so a failed-after-unload first
attempt still triggers rollback.

* Recheck the swap under the load gate and latch the unload before resolver checks

/load rechecks the sidecar reservation after acquiring the lifecycle gate
(an install can reserve while the load queues on it), and the dialog store
latches model_unloaded as soon as the install response arrives, before any
resolver-identity guard, so a superseded consent's unload still reaches
whichever load consumes the signal next.

* Report cleared-state unload failures, guard queued installs, and fold name tiers

A failed chat unload that still cleared the orchestrator's model state now
reports model_unloaded so the client rolls back, the installer aborts with
a 409 when a model load completed while it waited on the lifecycle gate,
and the fixed-tier name fast path consults the config mapping when a latest
sidecar is pinned so an accepted upgrade routes to the sidecar it installed
(no I/O added to the unpinned path).

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

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

* Report cleared-state unload failures and harden the spawn handshake flag

The failed-unload branch in before_swap now detects that the orchestrator
cleared its model state and reports model_unloaded before aborting (the
earlier commit claimed this fix but a scripting error dropped the edit),
the installer's queued-load check compares a load generation counter so a
same-model reload is caught, and both training spawn sites wrap everything
after the handshake in a guard that resets _spawn_in_progress on any
exception so a failed start cannot wedge is_training_active.

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

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

* Bump the load generation when the load is published, not at load start

A start-time bump is already visible when the installer snapshots mid-load,
so a same-model reload completing after the snapshot looked unchanged and
could be unloaded by the swap. The counter now increments alongside the
active_model_name publish.

* Self-heal a broken pinned sidecar, guard lazy repairs, and refresh stale retries

A valid pin whose transformers source dir vanished now triggers the repair
from the routing path (with a five minute backoff after failures) instead of
silently routing latest-only models to older tiers, the lazy repair refuses
while parent-visible chat/training/export workers are active since it has no
teardown of its own, and a version-mismatch install failure carries the
superseding release so the dialog's Retry re-requests a version that can
succeed.

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

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

* Flip latest-tier loads to 16-bit outside chat and protect export state

Training and export workers now apply the same latest-sidecar 16-bit flip
as the chat worker so a brand-new grouped-MoE architecture cannot reach bnb
4-bit through those paths, the latest-tier vision override returns None on
an inconclusive probe so a transient failure is not cached as not-vision,
and the install route refuses while an idle export checkpoint is loaded
rather than discard it with no rollback signal on a failed swap.

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

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

* Address parallel-review findings on the sidecar guards and install checks

The training route sizes latest-tier jobs 16-bit before GPU selection, the
inference subprocess spawn rechecks the swap reservation like training and
export (covering the OpenAI auto-switch path) with the typed error mapped
to a retryable 409, compat_plan blocks the install when dependency metadata
cannot be fetched instead of proceeding unverified, snapshot model-type
lists must contain only strings, and pin-marker package specs are validated
against the sidecar's own package set before ever reaching pip.

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

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

* Parent-only repairs, live-owner locks, remote-base activation, pre-teardown recheck

Lazy sidecar repairs now refuse inside worker children (whose empty backend
singletons cannot see live siblings) and run only in the parent where the
active-worker guard is real, swap-lock staleness requires the owner pid to
be dead so a slow live install is never superseded, both activation entry
points resolve a remote adapter's base model like the inference worker and
latest_tier_active_for already do, and load_model rechecks the reservation
before tearing down the old worker so losing the race keeps the current
model loaded.

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

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

* Check workers under the repair reservation and keep state on refused swaps

The lazy repair now reserves first and checks workers under the reservation
(worker starts set their active markers before rechecking, so every
interleaving aborts one side), with export ops and in-flight inference loads
counted as active. The inference pre-teardown and spawn guards refuse only
repair reservations since an install shares the load's lifecycle gate and
aborts via its queued-load snapshot, a SidecarSwapInProgress raised before
teardown no longer clears the live model mirrors, and an export spawn abort
after teardown clears current_checkpoint so the page cannot claim a loaded
checkpoint with no worker.

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

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

* Repair a present-but-incomplete latest sidecar from routing

The routing self-heal only fired when the pinned sidecar's transformers/
dir was missing. A sidecar that kept transformers/ but lost another pinned
package still routed models to the latest tier, and workers refuse
parent-only repairs, so every load failed until a manual reinstall. Routing
now validates the full pin (via _venv_dir_is_valid) and repairs any
incomplete sidecar under the same swap reservation and 5-minute backoff.

* Treat an unrepaired latest sidecar as unavailable in routing

When the pinned sidecar is incomplete and the lazy repair fails (offline,
pip failure, workers active) or is inside the backoff window, routing
returned the source dir anyway, sending models to a tier whose worker
activation is known to fail. Return None instead so models an older tier
supports keep loading there until a repair succeeds, matching the behavior
when the sidecar dir is missing entirely.

* Harden sidecar swap and repair against crash, survivor, and 16-bit paths

Reclaim a swap lock as soon as its recorded owner PID is dead instead of
waiting out the two-hour cutoff, so a crash mid-install no longer wedges
/load, training, export, and repair for hours. A lock whose PID cannot be
read yet still uses the long cutoff so the create-before-write window is
never mistaken for dead.

Probe process liveness with OpenProcess on Windows: os.kill(pid, 0) there
is CTRL_C_EVENT (a real Ctrl+C via GenerateConsoleCtrlEvent), not a
harmless check, and psutil is not always present.

Return whether _shutdown_subprocess actually killed the worker and keep the
live handle when it survives terminate/kill (an uninterruptible CUDA
syscall can outlive SIGKILL). The pre-swap liveness guard now trusts that
result, so the destructive .venv_t5_latest rename cannot proceed while a
live worker still holds sidecar modules.

Recover a sidecar stranded at .old when a swap's activation rename and its
rollback both fail: reading the pin restores it when no swap holds the
reservation, so latest-tier models are not permanently broken.

Resolve the latest tier in the parent for export loads and for explicitly
16-bit training runs, not only 4-bit ones: tier resolution self-heals an
incomplete sidecar, and repairs are parent-only, so those paths could not
recover before. Sidecar integrity and quantization are independent.

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

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

* Revert the parent-side latest-tier repair probe on training and export loads

The probe ran before the route freed VRAM, so a resident chat or export worker
made _workers_active_for_repair() refuse the parent-only repair; the route then
tore that worker down and spawned a child that also cannot repair, so an
incomplete sidecar still failed to load. Repairing correctly requires running the
repair between the worker teardown and the child spawn, decoupled from VRAM
sizing, which is a larger change tracked separately. Restore the prior behavior
so these paths match the reviewed form and do not partially attempt a repair that
cannot complete while workers are resident.

* Honor failed worker shutdowns on load and revalidate the cached latest mapping

The fresh-load paths spawned a new worker straight after _shutdown_subprocess
without checking its result, so a worker that outlived terminate/kill (a wedged
CUDA syscall) had its handle overwritten by the replacement while it still held
GPU memory, and is_worker_alive/the pre-swap guard could no longer see it. Both
the inference load and the export checkpoint load now abort when the old worker
did not exit, so the load can be retried once it does.

_config_model_types returned a cached latest mapping without re-checking the
sidecar, so a sidecar deleted or broken in-process after its first parse was
never re-validated: routing kept sending latest-only models to the stale latest
tier while activation failed. The cached latest mapping is now dropped and
re-resolved (self-healing) when the sidecar is no longer intact.

* Drop cached latest mapping when the pin is gone; keep 4-bit for custom-code fallback

_latest_sidecar_intact now returns False when the pin marker itself is gone, not
just when a pinned package is missing. Otherwise a cached latest mapping outlived
a deleted pin: _config_model_types kept returning it, so routing sent latest-only
models to a tier whose worker activation then failed (no pinned version) until
restart. It now drops the cache and re-resolves to no latest tier. The
_overlay_transformers_dir caller already gates on a present pin, so it is
unaffected.

validate_model forced 16-bit sizing whenever a PyPI upgrade was merely offered,
even for a model that can fall back to its own auto_map code. /load loads such a
model 4-bit without the install, and the install route refuses while training is
active, so 16-bit sizing here returned a VRAM 409 for the only viable 4-bit path.
The offered-upgrade flip is now gated on the absence of a custom-code fallback;
an already-active latest sidecar still always sizes 16-bit.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-15 05:25:26 -07:00

1651 lines
66 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
"""unload_model cancels an in-flight generation instead of waiting it out.
The sequential subprocess used to queue ``unload`` behind a running ``generate``,
hanging the UI. ``unload_model`` now cancels first (the mp.Event the worker checks
each token) and takes ``_gen_lock`` before the unload round-trip.
"""
import threading
import time
import pytest
from core.inference import orchestrator as orch_mod
from core.inference.orchestrator import InferenceOrchestrator
def _bare_orchestrator():
"""An orchestrator without the real __init__ subprocess/network."""
o = InferenceOrchestrator.__new__(InferenceOrchestrator)
o._gen_lock = threading.Lock()
o._cancel_event = threading.Event() # stands in for the mp.Event
o._drain_event = threading.Event() # stands in for the unload-drain mp.Event
o._proc = object() # truthy so _ensure_subprocess_alive reports alive
o._cmd_queue = object()
o._resp_queue = object()
o._dispatcher_thread = None
o._dispatcher_stop = threading.Event()
o._dispatcher_lifecycle_lock = threading.Lock()
o._unload_pending = False
o.active_model_name = "m"
o.models = {"m": {}}
o.loading_models = set()
return o
def test_unload_cancels_inflight_generation_then_unloads(monkeypatch):
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
sent = []
monkeypatch.setattr(o, "_send_cmd", lambda cmd: sent.append(cmd))
monkeypatch.setattr(o, "_wait_response", lambda t, timeout = 300.0: {"type": "unloaded"})
monkeypatch.setattr(o, "_drain_queue", lambda: [])
# A generation holds _gen_lock and releases it only once cancelled.
o._gen_lock.acquire()
def releaser():
o._cancel_event.wait(timeout = 5) # released only after the cancel fires
o._gen_lock.release()
t = threading.Thread(target = releaser)
t.start()
start = time.monotonic()
ok = o.unload_model("m")
elapsed = time.monotonic() - start
t.join(timeout = 5)
assert ok is True
assert o._cancel_event.is_set(), "generation must be cancelled before the unload"
assert {"type": "unload", "model_name": "m"} in sent
assert o.active_model_name is None
assert "m" not in o.models
# Waited on the released-after-cancel lock, not a full generation.
assert elapsed < 2.0
def test_unload_no_active_generation_unloads_normally(monkeypatch):
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
sent = []
monkeypatch.setattr(o, "_send_cmd", lambda cmd: sent.append(cmd))
monkeypatch.setattr(o, "_wait_response", lambda t, timeout = 300.0: {"type": "unloaded"})
monkeypatch.setattr(o, "_drain_queue", lambda: [])
ok = o.unload_model("m")
assert ok is True
assert {"type": "unload", "model_name": "m"} in sent
assert o.active_model_name is None
# Lock released for the next caller.
assert o._gen_lock.acquire(blocking = False)
o._gen_lock.release()
def test_unload_falls_back_to_shutdown_when_generation_wont_yield(monkeypatch):
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(orch_mod, "_UNLOAD_GEN_LOCK_TIMEOUT", 0.2)
shutdown = []
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: shutdown.append(timeout))
monkeypatch.setattr(o, "_send_cmd", lambda cmd: pytest.fail("must not send unload when wedged"))
# A wedged worker never releases _gen_lock, even after the cancel.
o._gen_lock.acquire()
ok = o.unload_model("m")
assert ok is True
assert shutdown, "should tear the subprocess down to free the GPU"
assert o.active_model_name is None
def test_unload_tears_down_when_compare_dispatcher_wedged(monkeypatch):
# A wedged compare-mode generation bypasses _gen_lock, so the acquire guard
# misses it and _send_cmd/_wait_response would hang on resp_queue. Unload must
# instead tear the subprocess down, like the wedged locked-generation path.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(orch_mod, "_DISPATCH_IDLE_TIMEOUT", 0.2)
# A live dispatcher whose mailbox never drains == a wedged compare-mode gen.
o._mailbox_lock = threading.Lock()
o._mailboxes = {"req-1": object()}
class _AliveThread:
def is_alive(self):
return True
o._dispatcher_thread = _AliveThread()
shutdown = []
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: shutdown.append(timeout))
monkeypatch.setattr(o, "_drain_queue", lambda: [])
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send unload with a wedged dispatcher")
)
monkeypatch.setattr(
o,
"_wait_response",
lambda t, timeout = 300.0: pytest.fail(
"must not wait on resp_queue with a wedged dispatcher"
),
)
# _gen_lock is free (compare mode never took it), so the acquire guard passes.
ok = o.unload_model("m")
assert ok is True
assert shutdown, "should tear the subprocess down to free the GPU"
assert o.active_model_name is None
assert "m" not in o.models
def test_consume_token_stream_bails_when_subprocess_swapped(monkeypatch):
# After a wedged-worker teardown a fresh load swaps _proc/_resp_queue; the
# still-live generation thread must detect the swap and bail, not re-block on
# the new queue while holding _gen_lock.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_subprocess_crash_message", lambda ctx: "inference subprocess restarted"
)
def read_one(timeout):
o._proc = object() # simulate the reload swapping the subprocess
return None
gen = o._consume_token_stream(read_one, lambda: None, crash_context = "generation")
msg = next(gen)
assert "restarted" in msg
with pytest.raises(StopIteration):
next(gen)
def test_unload_pending_clears_after_unload(monkeypatch):
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_send_cmd", lambda cmd: None)
monkeypatch.setattr(o, "_wait_response", lambda t, timeout = 300.0: {"type": "unloaded"})
monkeypatch.setattr(o, "_drain_queue", lambda: [])
o.unload_model("m")
# The flag must not leak past the unload, else every later generation bails.
assert o._unload_pending is False
def test_generation_bails_when_unload_pending(monkeypatch):
# Winning the _gen_lock handoff mid-switch must not start on the outgoing model.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
o._unload_pending = True
out = list(o._generate_inner(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
# It released (or never held) the lock, so the pending unload can proceed.
assert o._gen_lock.acquire(blocking = False)
o._gen_lock.release()
def test_dispatched_generation_bails_when_unload_pending(monkeypatch):
# Compare-mode bypasses _gen_lock, so it must early-out on a pending switch or
# it enqueues a generate on the outgoing model and delays the unload.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_start_dispatcher", lambda: pytest.fail("must not start a generation mid-switch")
)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send generate mid-switch")
)
o._unload_pending = True
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
def test_audio_input_generation_bails_when_unload_pending(monkeypatch):
# The audio path takes _gen_lock but must also skip the outgoing model mid-switch.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send generate mid-switch")
)
o._unload_pending = True
out = list(o._generate_audio_input_inner(audio_array = [0.0, 0.1]))
assert any("unloaded" in chunk.lower() for chunk in out)
# Lock released so the pending unload can proceed.
assert o._gen_lock.acquire(blocking = False)
o._gen_lock.release()
def test_audio_response_bails_when_unload_pending(monkeypatch):
# TTS (generate_audio_response) is blocking, so it RAISES rather than starting on the
# outgoing model mid-switch; it takes _gen_lock and must release it either way.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send audio generate mid-switch")
)
o._unload_pending = True
with pytest.raises(RuntimeError, match = "unload"):
o.generate_audio_response("hello")
# Lock released so the pending unload can proceed.
assert o._gen_lock.acquire(blocking = False)
o._gen_lock.release()
# ----------------------------------------------------------------------------
# Preserve unload cancels across the queue handoff (drain_event) — items #1/#4.
# ----------------------------------------------------------------------------
def test_worker_drain_skip_emits_cancelled_gen_done_when_draining():
# The worker clears cancel_event at the start of every generate, so a cancel set
# while a generate is still queued would be lost when it is dequeued. drain_event
# is the durable signal: while it is set the worker skips the generate (emitting an
# immediate gen_done so the stream/mailbox drains) instead of running it.
import queue as _queue
from core.inference.worker import _drain_skip_generate
drain = threading.Event()
rq: _queue.Queue = _queue.Queue()
cmd = {"type": "generate", "request_id": "r1"}
# Not draining -> run normally (do not skip, emit nothing).
assert _drain_skip_generate(cmd, rq, drain) is False
assert rq.empty()
# Missing event (older worker) -> also runs normally.
assert _drain_skip_generate(cmd, rq, None) is False
assert rq.empty()
# Draining -> skip and emit a cancelled gen_done for this request_id.
drain.set()
assert _drain_skip_generate(cmd, rq, drain) is True
resp = rq.get_nowait()
assert resp["type"] == "gen_done"
assert resp["request_id"] == "r1"
assert resp["cancelled"] is True
def test_worker_generate_branches_check_drain_before_clearing_cancel():
# Both worker command loops (MLX fast-path + GPU) must consult the drain skip
# before clearing cancel_event and running, so a queued generate can't clear an
# unload-initiated cancel and run the outgoing model to completion. Each loop
# checks the drain twice -- once before the clear and once after -- so a
# drain+cancel pair that lands in the window between them is still caught.
import inspect
from core.inference import worker
src = inspect.getsource(worker.run_inference_process)
assert src.count("_drain_skip_generate(cmd, resp_queue, drain_event)") == 4
def test_worker_generate_rechecks_drain_after_clearing_cancel():
# The exact interleaving item #3 describes: the drain check reads unset, then the
# parent sets drain+cancel for an unload, then the worker clears cancel_event
# (erasing that cancel). A second drain check *after* the clear catches it and
# skips the generate instead of running the outgoing model to completion.
import queue as _queue
from core.inference.worker import _drain_skip_generate
drain = threading.Event()
cancel = threading.Event()
rq: _queue.Queue = _queue.Queue()
cmd = {"type": "generate", "request_id": "r1"}
# 1. Pre-clear drain check: not draining yet -> run (no skip, no emit).
assert _drain_skip_generate(cmd, rq, drain) is False
assert rq.empty()
# 2. Parent starts an unload: sets drain, then cancel (orchestrator order).
drain.set()
cancel.set()
# 3. Worker clears cancel at the start of the generate -- erasing the cancel.
cancel.clear()
assert not cancel.is_set()
# 4. Post-clear drain re-check catches the erased cancel and skips.
assert _drain_skip_generate(cmd, rq, drain) is True
resp = rq.get_nowait()
assert resp["type"] == "gen_done" and resp["cancelled"] is True
def test_unload_sets_drain_event_during_switch_and_clears_after(monkeypatch):
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_drain_queue", lambda: [])
monkeypatch.setattr(o, "_wait_response", lambda t, timeout = 300.0: {"type": "unloaded"})
seen = {}
def record_send(cmd):
# drain_event must be set for the whole unload round-trip so any generate the
# worker dequeues in this window is skipped, not run.
seen["drain_set"] = o._drain_event.is_set()
monkeypatch.setattr(o, "_send_cmd", record_send)
assert o.unload_model("m") is True
assert seen.get("drain_set") is True
# Cleared on exit so a later generation (e.g. unloading a non-active model, or a
# reused subprocess) is not wrongly skipped.
assert o._drain_event.is_set() is False
def test_unload_clears_drain_event_even_on_wedged_teardown(monkeypatch):
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(orch_mod, "_UNLOAD_GEN_LOCK_TIMEOUT", 0.2)
monkeypatch.setattr(o, "_send_cmd", lambda cmd: pytest.fail("must not send when wedged"))
# A wedged worker never releases _gen_lock; unload tears the subprocess down. The
# real teardown nulls _drain_event, so emulate that so the finally exercises its guard.
def fake_shutdown(timeout = 5):
o._drain_event = None
monkeypatch.setattr(o, "_shutdown_subprocess", fake_shutdown)
o._gen_lock.acquire()
assert o.unload_model("m") is True # must not raise in the drain_event clear
# ----------------------------------------------------------------------------
# Recheck the active model after the lock wait — items #2/#3.
# ----------------------------------------------------------------------------
def test_generation_rechecks_model_after_lock_wait(monkeypatch):
# A request passes the pre-lock active-model check, then blocks on _gen_lock while
# an unload clears/swaps the model. Even if _unload_pending was already reset (the
# unload's finally runs after the lock release), the under-lock active-model recheck
# must make it bail instead of sending a generate to the wrong/unloaded backend.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not generate on a swapped/unloaded model")
)
reached_lock = threading.Event()
# _wait_dispatcher_idle runs after the pre-lock check and before acquiring the lock;
# signalling here means the generator captured the model and is about to block.
monkeypatch.setattr(o, "_wait_dispatcher_idle", lambda: (reached_lock.set(), True)[1])
o.active_model_name = "m"
o._unload_pending = False
o._gen_lock.acquire() # stand in for an in-flight unload holding the lock
out: list = []
def run():
out.extend(o._generate_inner(messages = [{"role": "user", "content": "hi"}]))
t = threading.Thread(target = run)
t.start()
assert reached_lock.wait(timeout = 5)
# Unload finished: model swapped, pending already cleared. Release the lock.
o.active_model_name = "other"
o._gen_lock.release()
t.join(timeout = 5)
assert out and any("unloaded" in chunk.lower() for chunk in out)
def test_generation_rechecks_model_when_unloaded_to_none(monkeypatch):
# Same race, but the unload left no active model (a plain unload, not a switch).
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not generate after the model was unloaded")
)
reached_lock = threading.Event()
monkeypatch.setattr(o, "_wait_dispatcher_idle", lambda: (reached_lock.set(), True)[1])
o.active_model_name = "m"
o._unload_pending = False
o._gen_lock.acquire()
out: list = []
t = threading.Thread(
target = lambda: out.extend(o._generate_inner(messages = [{"role": "user", "content": "hi"}]))
)
t.start()
assert reached_lock.wait(timeout = 5)
o.active_model_name = None
o._gen_lock.release()
t.join(timeout = 5)
assert out and any("unloaded" in chunk.lower() for chunk in out)
# ----------------------------------------------------------------------------
# Don't unload a stale model name (worker's active-model fallback) — item #5.
# ----------------------------------------------------------------------------
def test_unload_of_stale_name_does_not_touch_active_model(monkeypatch):
# If the named model isn't loaded (e.g. a concurrent load already swapped in a
# different one), unload must not send a command the worker would satisfy by
# unloading its *active* model.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send an unload for a stale model name")
)
o.active_model_name = "current"
o.models = {"current": {}}
assert o.unload_model("stale") is True
# The active model is left intact.
assert o.active_model_name == "current"
assert "current" in o.models
def test_unload_matches_active_model_case_insensitively(monkeypatch):
# active_model_name can differ in case from the raw model_path a client sends
# to /unload (the load path canonicalizes casing). The stale-name guard must
# match case-insensitively too; otherwise it no-ops the unload and leaves the
# model resident while reporting success.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
sent = []
monkeypatch.setattr(o, "_send_cmd", lambda cmd: sent.append(cmd))
monkeypatch.setattr(o, "_wait_response", lambda t, timeout = 300.0: {"type": "unloaded"})
monkeypatch.setattr(o, "_drain_queue", lambda: [])
o.active_model_name = "unsloth/Qwen3-4B"
o.models = {"unsloth/Qwen3-4B": {}}
# Client unloads with the casing it originally typed, before canonicalization.
assert o.unload_model("unsloth/qwen3-4b") is True
# The guard did not no-op: an unload for the canonical active model reached
# the worker (not the raw lowercase name, so the worker matches it directly).
assert {"type": "unload", "model_name": "unsloth/Qwen3-4B"} in sent
# Local state is cleared for the canonical name, not left stale.
assert o.active_model_name is None
assert o.models == {}
def test_unload_of_stale_name_still_no_ops_after_case_insensitive_match(monkeypatch):
# The case-insensitive match must only rescue the active model; a genuinely
# different model name (case-insensitively too) must still no-op so the
# worker's absent-name fallback can't tear down the active model.
o = _bare_orchestrator()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send an unload for a stale model name")
)
o.active_model_name = "unsloth/Qwen3-4B"
o.models = {"unsloth/Qwen3-4B": {}}
assert o.unload_model("unsloth/Llama-3.1-8B") is True
assert o.active_model_name == "unsloth/Qwen3-4B"
assert "unsloth/Qwen3-4B" in o.models
def test_load_does_not_accumulate_stale_models_defeating_the_unload_guard(monkeypatch):
# A load always spawns a fresh subprocess holding only the new model, so
# self.models must mirror that instead of accumulating the previous model's name.
# Otherwise switching A -> B leaves 'A' in self.models, so a later unload('A')
# passes the "not in self.models" guard and the worker's absent-name fallback
# unloads the *active* model B.
import types
from utils import transformers_version as _tv
o = _bare_orchestrator()
o.active_model_name = None
o.models = {}
monkeypatch.setattr(_tv, "needs_transformers_5", lambda name: False)
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", lambda *a, **k: ([], {}))
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_shutdown_subprocess", lambda *a, **k: None)
monkeypatch.setattr(o, "_spawn_subprocess", lambda cfg: None)
monkeypatch.setattr(orch_mod.time, "sleep", lambda *_a, **_k: None)
def _load(name):
monkeypatch.setattr(
o,
"_wait_response",
lambda expected, timeout = 300.0: {
"type": "loaded",
"success": True,
"model_info": {"identifier": name, "display_name": name},
},
)
assert o.load_model(types.SimpleNamespace(identifier = name, gguf_variant = None)) is True
_load("modelA")
_load("modelB") # switch to B without unloading A first
# self.models mirrors the single live model; the swapped-out name is gone.
assert o.active_model_name == "modelB"
assert set(o.models) == {"modelB"}
# A stale unload of the swapped-out model must not reach the worker (whose
# absent-name fallback would unload the active model B).
monkeypatch.setattr(o, "_send_cmd", lambda cmd: pytest.fail("stale unload reached the worker"))
assert o.unload_model("modelA") is True
assert o.active_model_name == "modelB"
assert "modelB" in o.models
def test_unload_route_serializes_with_loads_via_lifecycle_gate(monkeypatch):
# Item #5: /unload must hold the same lifecycle gate as /load so a concurrent load
# can't swap the backend subprocess/queues mid-unload.
import asyncio
import routes.inference as inference_route
from core.inference import llama_keepwarm as kw
from models.inference import UnloadRequest
class _Llama:
is_active = False
is_loaded = False
model_identifier = None
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: _Llama())
monkeypatch.setattr(inference_route, "is_registered_native_path_label", lambda *a: False)
unloaded: list = []
class _Backend:
active_model_name = "m"
models = {"m": {}}
def unload_model(self, name):
unloaded.append(name)
return True
monkeypatch.setattr(inference_route, "get_inference_backend", lambda: _Backend())
async def scenario():
# Hold the real gate, exactly as an in-flight /load would.
assert kw._lifecycle_lock.acquire(blocking = False)
try:
task = asyncio.ensure_future(
inference_route.unload_model(UnloadRequest(model_path = "m"), "tester")
)
# Yield to the loop repeatedly: the route must stay blocked on the gate.
for _ in range(10):
await asyncio.sleep(0.01)
assert unloaded == [], "unload ran while the lifecycle gate was held"
assert not task.done()
finally:
kw._lifecycle_lock.release()
resp = await task
assert resp.status == "unloaded"
assert unloaded == ["m"]
asyncio.run(scenario())
# ----------------------------------------------------------------------------
# Cancel an in-flight load OFF the lifecycle gate (Stop-loading regression).
# /load holds the gate for the whole load, so a gated /unload could never
# interrupt it; cancel_load only tears the loading subprocess down.
# ----------------------------------------------------------------------------
def test_cancel_load_terminates_loading_subprocess_and_sends_no_command(monkeypatch):
o = _bare_orchestrator()
o.loading_models = {"m"}
o.active_model_name = None
o.models = {}
shutdown = []
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: shutdown.append(timeout))
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("cancel_load must not send a worker command")
)
assert o.cancel_load("m") is True
assert shutdown, "must tear the loading subprocess down"
assert "m" not in o.loading_models
assert o.active_model_name is None
# A name that is not loading -> no-op, returns False so the caller takes the gate.
assert o.cancel_load("other") is False
def test_cancel_load_matches_loading_model_case_insensitively(monkeypatch):
o = _bare_orchestrator()
o.loading_models = {"unsloth/Qwen3-4B"}
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: None)
assert o.cancel_load("unsloth/qwen3-4b") is True
assert o.loading_models == set()
def test_unload_model_cancels_a_loading_model_via_cancel_load(monkeypatch):
# unload_model still cancels an in-flight load (shared logic with cancel_load).
o = _bare_orchestrator()
o.loading_models = {"m"}
o.active_model_name = None
shutdown = []
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: shutdown.append(timeout))
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send a command to cancel a load")
)
assert o.unload_model("m") is True
assert shutdown
assert "m" not in o.loading_models
def test_unload_route_cancels_in_flight_load_without_waiting_on_gate(monkeypatch):
# The regression: /unload wrapped its whole body in the lifecycle gate, so the
# Stop-loading button (cancelLoading -> /unload) could not interrupt a safetensors
# load that holds the gate for its full duration. The cancel must run off-gate.
import asyncio
import routes.inference as inference_route
from core.inference import llama_keepwarm as kw
from models.inference import UnloadRequest
class _Llama:
is_active = False
is_loaded = False
model_identifier = None
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: _Llama())
monkeypatch.setattr(inference_route, "is_registered_native_path_label", lambda *a: False)
cancelled: list = []
class _Backend:
active_model_name = None
models: dict = {}
def get_loading_model(self):
return "m"
def cancel_load(self, name):
cancelled.append(name)
return True
def unload_model(self, name):
pytest.fail("must not take the gated unload path for a still-loading model")
monkeypatch.setattr(inference_route, "get_inference_backend", lambda: _Backend())
async def scenario():
# Hold the real gate, exactly as an in-flight /load would.
assert kw._lifecycle_lock.acquire(blocking = False)
try:
# Even with the gate held, the loading-cancel must go through.
resp = await inference_route.unload_model(UnloadRequest(model_path = "m"), "tester")
assert resp.status == "unloaded"
assert cancelled == ["m"]
finally:
kw._lifecycle_lock.release()
asyncio.run(scenario())
# ----------------------------------------------------------------------------
# A dispatched (compare-mode) request that races an unload must not orphan its
# mailbox after _wait_dispatcher_idle stops the dispatcher.
# ----------------------------------------------------------------------------
def test_dispatched_bails_when_unload_flips_before_mailbox_registration(monkeypatch):
# The request passes the pre-work _unload_pending check, then an unload sets
# _unload_pending and _wait_dispatcher_idle stops the dispatcher (mailboxes empty)
# before this request registers its mailbox. The recheck under _mailbox_lock must
# make it bail, or the worker's skipped-generate reply has nothing to route it and
# the compare stream hangs on an orphaned mailbox.
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_start_dispatcher", lambda: None)
# Flip the unload flag after the pre-work check (626) but before mailbox
# registration -- exactly the window _wait_dispatcher_idle exploits.
def flip(*a, **k):
o._unload_pending = True
return {"type": "generate", "request_id": "r1"}
monkeypatch.setattr(o, "_build_generate_cmd", flip)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send generate after the unload flipped")
)
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
assert o._mailboxes == {}, "must not leave an orphaned mailbox"
# ----------------------------------------------------------------------------
# Dispatched path: bail when a cleared-pending unload swapped the model or
# tore the dispatcher down during the pre-registration window -- item #2.
# ----------------------------------------------------------------------------
class _AliveDispatcher:
"""Stand-in dispatcher thread that reports itself alive."""
def is_alive(self):
return True
def test_dispatched_bails_when_model_swapped_before_mailbox_registration(monkeypatch):
# The request passes the pre-work checks, then a full unload+reload completes
# (clearing _unload_pending) before this request registers its mailbox. The
# under-lock recheck must notice active_model_name changed and bail, instead of
# sending a generate that lands on the swapped-in model.
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
o._dispatcher_thread = _AliveDispatcher()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_start_dispatcher", lambda: None)
# Swap the active model after the pre-work check but before registration,
# with _unload_pending already back to False (the unload finally ran).
def swap(*a, **k):
o.active_model_name = "other"
return {"type": "generate", "request_id": "r1"}
monkeypatch.setattr(o, "_build_generate_cmd", swap)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not generate on the swapped-in model")
)
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
assert o._mailboxes == {}, "must not leave an orphaned mailbox"
def test_dispatched_bails_when_dispatcher_stopped_before_mailbox_registration(monkeypatch):
# Same window, but the unload was a same-model reload so active_model_name is
# unchanged; the give-away is that the dispatcher was stopped. Registering a
# mailbox with no dispatcher to route the reply would hang the compare stream.
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
o._dispatcher_thread = _AliveDispatcher()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_start_dispatcher", lambda: None)
def stop_dispatcher(*a, **k):
o._dispatcher_thread = None # unload's _stop_dispatcher cleared it
return {"type": "generate", "request_id": "r1"}
monkeypatch.setattr(o, "_build_generate_cmd", stop_dispatcher)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not generate with the dispatcher stopped")
)
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
assert o._mailboxes == {}, "must not leave an orphaned mailbox"
def test_dispatched_happy_path_registers_and_sends(monkeypatch):
# Guard against a false bail: with the model unchanged and the dispatcher alive,
# the recheck must let the generate through (register a mailbox and send).
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
o._dispatcher_thread = _AliveDispatcher()
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_start_dispatcher", lambda: None)
monkeypatch.setattr(
o, "_build_generate_cmd", lambda *a, **k: {"type": "generate", "request_id": "r1"}
)
sent = []
monkeypatch.setattr(o, "_send_cmd", lambda cmd: sent.append(cmd))
# Feed one gen_done so the consumer returns promptly.
def fake_consume(read_mailbox, drainer, **k):
mbox = o._mailboxes.get("r1")
if mbox is not None:
mbox.put({"type": "gen_done", "request_id": "r1"})
yield ""
monkeypatch.setattr(o, "_consume_token_stream", fake_consume)
list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert sent, "happy path must send the generate command"
assert o._mailboxes == {}, "mailbox popped in finally"
# ----------------------------------------------------------------------------
# load_model observes a cancel that discarded its loading marker -- item #4.
# ----------------------------------------------------------------------------
def test_load_model_aborts_when_cancelled_before_spawn(monkeypatch):
# Stop-loading during GPU placement discards the loading marker (cancel_load) with
# no child yet to kill. load_model must observe the removal and not spawn a worker
# that loads the model after /unload already reported it unloaded.
o = _bare_orchestrator()
o.active_model_name = None
o.models = {}
o.loading_models = set()
o._proc = None
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: False)
monkeypatch.setattr(o, "_shutdown_subprocess", lambda *a, **k: None)
monkeypatch.setattr(
o, "_spawn_subprocess", lambda cfg: pytest.fail("must not spawn a worker after a cancel")
)
import utils.transformers_version as tv
monkeypatch.setattr(tv, "needs_transformers_5", lambda name: False)
# cancel_load discards the marker while we resolve GPU placement.
def cancel_during_gpu(gpu_ids, **k):
o.loading_models.discard("m")
return ([0], "sel")
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", cancel_during_gpu)
class _Cfg:
identifier = "m"
ok = o.load_model(_Cfg())
assert ok is False
assert o.active_model_name is None
assert o.models == {}
def test_load_model_aborts_when_old_worker_survives_shutdown(monkeypatch):
# A wedged worker that outlives terminate/kill makes _shutdown_subprocess return
# False. load_model must not spawn a second worker over it (double GPU allocation +
# the survivor's handle is lost); it aborts so the load can retry once it exits.
import types
from utils import transformers_version as tv
o = _bare_orchestrator()
o.active_model_name = "old"
o.models = {"old": {}}
o.loading_models = set()
monkeypatch.setattr(tv, "needs_transformers_5", lambda name: False)
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", lambda *a, **k: ([0], "sel"))
monkeypatch.setattr(orch_mod.time, "sleep", lambda *_a, **_k: None)
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_cancel_generation", lambda: None)
monkeypatch.setattr(o, "_shutdown_subprocess", lambda *a, **k: False) # survivor
monkeypatch.setattr(
o, "_spawn_subprocess", lambda cfg: pytest.fail("must not spawn over a live survivor")
)
with pytest.raises(RuntimeError, match = "did not exit"):
o.load_model(types.SimpleNamespace(identifier = "new", gguf_variant = None))
# The except path cleared the loading marker and mirrors.
assert "new" not in o.loading_models
assert o.active_model_name is None
def test_load_model_proceeds_when_not_cancelled(monkeypatch):
# Guard against a false abort: an uncancelled load keeps its marker and spawns.
o = _bare_orchestrator()
o.active_model_name = None
o.models = {}
o.loading_models = set()
o._proc = None
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: False)
monkeypatch.setattr(o, "_shutdown_subprocess", lambda *a, **k: None)
spawned = []
monkeypatch.setattr(o, "_spawn_subprocess", lambda cfg: spawned.append(cfg))
monkeypatch.setattr(
o,
"_wait_response",
lambda t, timeout = 300.0: {"success": True, "model_info": {"identifier": "m"}},
)
import utils.transformers_version as tv
monkeypatch.setattr(tv, "needs_transformers_5", lambda name: False)
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", lambda gpu_ids, **k: ([0], "sel"))
class _Cfg:
identifier = "m"
ok = o.load_model(_Cfg())
assert ok is True
assert spawned, "uncancelled load must spawn a worker"
assert o.active_model_name == "m"
def test_load_model_aborts_when_cancelled_during_spawn(monkeypatch):
# Stop-loading can land AFTER the pre-spawn marker recheck but while
# _spawn_subprocess is still creating the queues/process, so cancel_load's
# _shutdown_subprocess finds _proc not yet alive and no-ops. load_model must
# recheck the marker once the child exists and tear the orphaned worker down,
# instead of waiting for "loaded" and publishing a model /unload already
# reported as unloaded (a live subprocess nothing later reaps).
import types
from utils import transformers_version as tv
o = _bare_orchestrator()
o.active_model_name = None
o.models = {}
o.loading_models = {"m"}
o._proc = None
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: False)
monkeypatch.setattr(tv, "needs_transformers_5", lambda name: False)
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", lambda gpu_ids, **k: ([0], "sel"))
# The cancel lands during the spawn window: cancel_load already discarded the
# marker, but its teardown no-oped because _proc was not alive yet.
def spawn_then_cancel(cfg):
o.loading_models.discard("m")
monkeypatch.setattr(o, "_spawn_subprocess", spawn_then_cancel)
shutdown = []
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: shutdown.append(timeout))
monkeypatch.setattr(
o,
"_wait_response",
lambda t, timeout = 300.0: pytest.fail(
"must not wait for 'loaded' after a cancel during spawn"
),
)
ok = o.load_model(types.SimpleNamespace(identifier = "m", gguf_variant = None))
assert ok is False
assert shutdown, "must tear the orphaned worker down"
assert o.active_model_name is None
assert o.models == {}
assert "m" not in o.loading_models
# ----------------------------------------------------------------------------
# /unload cancels a still-loading GGUF off the lifecycle gate -- item #1.
# ----------------------------------------------------------------------------
def test_unload_cancels_loading_gguf_off_gate(monkeypatch):
# A still-loading GGUF (is_active, not is_loaded) must be cancelled off the gate:
# /load holds the lifecycle gate for the whole load, so a gated unload would wait
# it out. Assert the gate is never entered and unload_model() runs.
import asyncio as _asyncio
import routes.inference as ri
from core.inference import llama_keepwarm
gate_entered = {"v": False}
class _Gate:
async def __aenter__(self):
gate_entered["v"] = True
return self
async def __aexit__(self, *a):
return False
class _LlamaBackend:
is_active = True
is_loaded = False
model_identifier = "gguf-model"
def __init__(self):
self.unloaded = False
def unload_model(self):
self.unloaded = True
llama = _LlamaBackend()
class _Unsloth:
def get_loading_model(self):
return None # no Unsloth load in flight -> Unsloth fast path skipped
monkeypatch.setattr(ri, "get_llama_cpp_backend", lambda: llama)
monkeypatch.setattr(ri, "get_inference_backend", lambda: _Unsloth())
monkeypatch.setattr(llama_keepwarm, "inference_lifecycle_gate", lambda: _Gate())
monkeypatch.setattr(llama_keepwarm, "note_model_unloaded", lambda: None)
req = ri.UnloadRequest(model_path = "gguf-model")
resp = _asyncio.run(ri.unload_model(req, current_subject = "s"))
assert getattr(resp, "status", None) == "unloaded"
assert llama.unloaded is True, "must cancel the loading GGUF via unload_model()"
assert gate_entered["v"] is False, "must handle the loading GGUF off the lifecycle gate"
def test_unload_loaded_gguf_still_uses_gate(monkeypatch):
# Guard: an already-loaded GGUF (is_loaded True) is NOT caught by the off-gate
# fast path; it goes through the gate as before.
import asyncio as _asyncio
import routes.inference as ri
from core.inference import llama_keepwarm
gate_entered = {"v": False}
class _Gate:
async def __aenter__(self):
gate_entered["v"] = True
return self
async def __aexit__(self, *a):
return False
class _LlamaBackend:
is_active = True
is_loaded = True
model_identifier = "gguf-model"
def __init__(self):
self.unloaded = False
def unload_model(self):
self.unloaded = True
llama = _LlamaBackend()
class _Unsloth:
def get_loading_model(self):
return None
monkeypatch.setattr(ri, "get_llama_cpp_backend", lambda: llama)
monkeypatch.setattr(ri, "get_inference_backend", lambda: _Unsloth())
monkeypatch.setattr(ri, "is_registered_native_path_label", lambda a, b: False)
monkeypatch.setattr(llama_keepwarm, "inference_lifecycle_gate", lambda: _Gate())
monkeypatch.setattr(llama_keepwarm, "note_model_unloaded", lambda: None)
req = ri.UnloadRequest(model_path = "gguf-model")
resp = _asyncio.run(ri.unload_model(req, current_subject = "s"))
assert getattr(resp, "status", None) == "unloaded"
assert llama.unloaded is True
assert gate_entered["v"] is True, "loaded GGUF unload must still take the gate"
def test_unload_of_mismatched_loading_gguf_skips_off_gate_fast_path(monkeypatch):
# A still-loading GGUF X (is_active, not is_loaded) must NOT be torn down by the
# off-gate fast path when /unload names a DIFFERENT model Y. The single llama-server
# can only load one GGUF at a time, so this fast path is "stop loading THIS model";
# without a target check it fires for any in-flight GGUF and would abort an unrelated
# load (e.g. a second tab unloading Y kills the load of X). A mismatched target must
# fall through to the lifecycle gate (where, in production, it waits out X's /load and
# then no-ops) instead of taking the off-gate teardown.
import asyncio as _asyncio
import routes.inference as ri
from core.inference import llama_keepwarm
gate_entered = {"v": False}
class _Gate:
async def __aenter__(self):
gate_entered["v"] = True
return self
async def __aexit__(self, *a):
return False
class _LlamaBackend:
is_active = True
is_loaded = False
model_identifier = "gguf-X"
def __init__(self):
self.unloaded = False
def unload_model(self):
self.unloaded = True
llama = _LlamaBackend()
class _Unsloth:
def get_loading_model(self):
return None # no Unsloth load in flight -> Unsloth fast path skipped
monkeypatch.setattr(ri, "get_llama_cpp_backend", lambda: llama)
monkeypatch.setattr(ri, "get_inference_backend", lambda: _Unsloth())
monkeypatch.setattr(ri, "is_registered_native_path_label", lambda a, b: False)
monkeypatch.setattr(llama_keepwarm, "inference_lifecycle_gate", lambda: _Gate())
monkeypatch.setattr(llama_keepwarm, "note_model_unloaded", lambda: None)
req = ri.UnloadRequest(model_path = "gguf-Y") # different from the loading model X
_asyncio.run(ri.unload_model(req, current_subject = "s"))
assert gate_entered["v"] is True, (
"a mismatched-target unload must not use the off-gate GGUF fast path; "
"it would cancel the wrong in-flight load"
)
# ----------------------------------------------------------------------------
# cancel_load clears its loading marker BEFORE tearing the subprocess down, so a
# racing off-gate load_model observes the cancel during the shutdown window.
# ----------------------------------------------------------------------------
def test_cancel_load_clears_marker_before_shutdown(monkeypatch):
# cancel_load runs off the lifecycle gate, concurrently with a load_model that
# rechecks the loading marker before each spawn to observe the cancel.
# _shutdown_subprocess can block (tearing a live child down / joining the compare
# dispatcher), so discarding the marker only AFTER it leaves a long window in which
# that load_model reads the marker still set, passes its pre-spawn recheck, and
# spawns + loads the model after /unload already reported it cancelled. The marker
# (and local state) must be cleared before the teardown.
o = _bare_orchestrator()
o.loading_models = {"m"}
o.active_model_name = "m"
o.models = {"m": {}}
at_shutdown = {}
def record_shutdown(timeout = 5):
at_shutdown["marker_present"] = "m" in o.loading_models
at_shutdown["active"] = o.active_model_name
at_shutdown["models"] = dict(o.models)
monkeypatch.setattr(o, "_shutdown_subprocess", record_shutdown)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("cancel_load must not send a worker command")
)
assert o.cancel_load("m") is True
assert at_shutdown.get("marker_present") is False, (
"the loading marker must be cleared before _shutdown_subprocess so a concurrent "
"load_model pre-spawn recheck observes the cancel during the shutdown window"
)
assert at_shutdown.get("active") is None
assert at_shutdown.get("models") == {}
assert "m" not in o.loading_models
assert o.active_model_name is None
assert o.models == {}
def test_cancel_load_reclears_state_when_racing_load_repopulates_during_teardown(monkeypatch):
# cancel_load (off the lifecycle gate) can race a load_model whose worker already
# queued its successful "loaded" reply. cancel_load discards the loading marker and
# clears the local mirrors, then tears the subprocess down; but the still-running
# load_model thread can consume that "loaded" DURING the teardown window and repopulate
# active_model_name/models. _shutdown_subprocess nulls the queues but never touches those
# mirrors, so without a second clear /unload reports success while the backend keeps
# advertising a model whose worker was just killed. cancel_load must re-clear after the
# teardown so no phantom loaded model survives.
import types
from utils import transformers_version as _tv
o = _bare_orchestrator()
o.loading_models = {"m"}
o.active_model_name = None
o.models = {}
o._proc = None # no prior subprocess -> load_model goes straight to the spawn loop
monkeypatch.setattr(_tv, "needs_transformers_5", lambda name: False)
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", lambda *a, **k: ([], {}))
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: False)
monkeypatch.setattr(o, "_spawn_subprocess", lambda cfg: None)
parked = threading.Event() # load_model is parked in _wait_response("loaded")
release_loaded = threading.Event() # cancel_load lets the load consume "loaded"
load_done = threading.Event()
def blocking_wait_response(expected, timeout = 300.0):
parked.set()
assert release_loaded.wait(timeout = 5)
return {
"type": "loaded",
"success": True,
"model_info": {"identifier": "m", "display_name": "m"},
}
monkeypatch.setattr(o, "_wait_response", blocking_wait_response)
load_result: dict = {}
def run_load():
try:
load_result["ok"] = o.load_model(
types.SimpleNamespace(identifier = "m", gguf_variant = None)
)
except Exception as exc: # noqa: BLE001
load_result["exc"] = exc
finally:
load_done.set()
loader = threading.Thread(target = run_load)
loader.start()
assert parked.wait(timeout = 5), "load_model must reach _wait_response"
# The teardown IS the window in which the racing load repopulates the mirrors: the
# marker is already discarded here, so release the load and wait for it to finish
# repopulating, mirroring the 0.5s cancel-settle inside the real _shutdown_subprocess.
def racing_shutdown(timeout = 0.5):
release_loaded.set()
assert load_done.wait(timeout = 5), "the racing load must repopulate during teardown"
monkeypatch.setattr(o, "_shutdown_subprocess", racing_shutdown)
assert o.cancel_load("m") is True
loader.join(timeout = 5)
# Fail-without: load_model set active_model_name/models during racing_shutdown and
# cancel_load left them set, so the backend advertises a model whose worker was killed.
assert o.active_model_name is None, "cancel_load must not leave a repopulated active model"
assert o.models == {}, "cancel_load must not leave a repopulated models mirror"
assert "m" not in o.loading_models
# ----------------------------------------------------------------------------
# A dispatched (compare-mode) request that starts the dispatcher and then bails on
# a racing unload must stop the dispatcher it started, or that orphaned dispatcher
# steals the worker's "unloaded" reply and hangs unload_model on its 300s timeout.
# ----------------------------------------------------------------------------
def test_dispatched_bail_stops_orphan_dispatcher_it_started(monkeypatch):
# The request passes the pre-work _unload_pending check and starts the dispatcher
# (none was running), then an unload sets _unload_pending so the under-lock recheck
# bails. The just-started dispatcher, left running with no mailboxes, competes with
# unload_model()'s _wait_response for the worker's "unloaded" reply off the shared
# resp_queue and drops it as unroutable, hanging the unload until its 300s timeout.
# The bail must stop the dispatcher it started.
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
o._dispatcher_thread = None # none running -> this call starts it
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
started = {"v": False}
stopped = {"v": False}
def fake_start():
started["v"] = True
o._dispatcher_thread = _AliveDispatcher()
return True # _start_dispatcher returns True for the caller that spawned it
def fake_stop():
stopped["v"] = True
o._dispatcher_thread = None
monkeypatch.setattr(o, "_start_dispatcher", fake_start)
monkeypatch.setattr(o, "_stop_dispatcher", fake_stop)
# An unload flips _unload_pending after the pre-work check but before registration.
def flip(*a, **k):
o._unload_pending = True
return {"type": "generate", "request_id": "r1"}
monkeypatch.setattr(o, "_build_generate_cmd", flip)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send generate after the unload flipped")
)
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
assert started["v"], "this call started the dispatcher"
assert stopped["v"], "the bail must stop the dispatcher it started (no other mailboxes)"
assert o._mailboxes == {}
def test_dispatched_bail_keeps_dispatcher_with_other_active_mailbox(monkeypatch):
# Guard against over-stopping: if another compare request registered a mailbox on the
# dispatcher this call started, the bail must NOT stop it, or that request's token
# routing dies mid-stream.
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
o._dispatcher_thread = None
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(
o, "_start_dispatcher", lambda: setattr(o, "_dispatcher_thread", _AliveDispatcher())
)
monkeypatch.setattr(
o,
"_stop_dispatcher",
lambda: pytest.fail("must not stop a dispatcher another compare request is using"),
)
# A concurrent compare request registers its mailbox, then an unload flips the flag.
def flip(*a, **k):
o._mailboxes["other"] = object()
o._unload_pending = True
return {"type": "generate", "request_id": "r1"}
monkeypatch.setattr(o, "_build_generate_cmd", flip)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send generate after the unload flipped")
)
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
assert set(o._mailboxes) == {"other"}, "the other request's mailbox is untouched"
def test_dispatched_bail_keeps_preexisting_dispatcher(monkeypatch):
# Guard: if the dispatcher was already running before this request (an earlier compare
# request started it), a bail must not stop it even with no mailboxes now -- this
# request did not start it and another may re-use it. Only the call that starts an
# otherwise-idle dispatcher during the race is responsible for stopping it.
o = _bare_orchestrator()
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._unload_pending = False
o._dispatcher_thread = _AliveDispatcher() # already running
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
monkeypatch.setattr(o, "_start_dispatcher", lambda: None)
monkeypatch.setattr(
o, "_stop_dispatcher", lambda: pytest.fail("must not stop a pre-existing dispatcher")
)
def flip(*a, **k):
o._unload_pending = True
return {"type": "generate", "request_id": "r1"}
monkeypatch.setattr(o, "_build_generate_cmd", flip)
monkeypatch.setattr(
o, "_send_cmd", lambda cmd: pytest.fail("must not send generate after the unload flipped")
)
out = list(o._generate_dispatched(messages = [{"role": "user", "content": "hi"}]))
assert any("unloaded" in chunk.lower() for chunk in out)
# ----------------------------------------------------------------------------
# load_model rechecks the loading marker AFTER _wait_response("loaded") and
# BEFORE publishing -- item #6. cancel_load's post-teardown re-clear only wipes a
# repopulation that lands during its shutdown; a publish that lands after
# cancel_load returns survives it, so the recheck must abort the publish itself.
# ----------------------------------------------------------------------------
def test_load_model_aborts_publish_when_cancelled_after_wait_response(monkeypatch):
# cancel_load (off the lifecycle gate) discards the loading marker BEFORE its teardown
# and re-clears the mirrors AFTER it. A racing load_model can consume its worker's
# already-queued "loaded" reply and reach the publish block only AFTER cancel_load has
# fully returned -- so cancel_load's post-teardown re-clear cannot undo that publish.
# Without a marker recheck between _wait_response("loaded") and the publish, load_model
# advertises active_model_name/models for a model /unload already reported cancelled,
# over a subprocess cancel_load just killed. The recheck must observe the discarded
# marker and abort the publish.
import types
from utils import transformers_version as _tv
o = _bare_orchestrator()
o.loading_models = {"m"}
o.active_model_name = None
o.models = {}
o._proc = None # no prior subprocess -> load_model goes straight to the spawn loop
monkeypatch.setattr(_tv, "needs_transformers_5", lambda name: False)
monkeypatch.setattr(orch_mod, "prepare_gpu_selection", lambda *a, **k: ([], {}))
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: False)
monkeypatch.setattr(o, "_spawn_subprocess", lambda cfg: None)
# cancel_load tears the worker down; a no-op keeps the test off real subprocesses.
monkeypatch.setattr(o, "_shutdown_subprocess", lambda timeout = 5: None)
parked = threading.Event() # load_model reached _wait_response("loaded")
cancel_done = threading.Event() # cancel_load fully returned (marker discarded + re-clear)
load_done = threading.Event()
def blocking_wait_response(expected, timeout = 300.0):
parked.set()
# Do not consume "loaded" until cancel_load has fully returned, so the publish
# would land AFTER cancel_load's post-teardown re-clear -- the window the
# re-clear alone cannot cover.
assert cancel_done.wait(timeout = 5)
return {
"type": "loaded",
"success": True,
"model_info": {"identifier": "m", "display_name": "m"},
}
monkeypatch.setattr(o, "_wait_response", blocking_wait_response)
load_result: dict = {}
def run_load():
try:
load_result["ok"] = o.load_model(
types.SimpleNamespace(identifier = "m", gguf_variant = None)
)
except Exception as exc: # noqa: BLE001
load_result["exc"] = exc
finally:
load_done.set()
loader = threading.Thread(target = run_load)
loader.start()
assert parked.wait(timeout = 5), "load_model must reach _wait_response"
# cancel_load runs to completion while the load is parked: it discards the marker and
# re-clears the mirrors (post-teardown), then returns. Only then let the load consume
# "loaded" and attempt to publish.
assert o.cancel_load("m") is True
cancel_done.set()
loader.join(timeout = 5)
assert load_done.is_set()
# Fail-without: load_model published active_model_name/models for 'm' AFTER cancel_load
# returned, advertising a cancelled model over a killed subprocess.
assert load_result.get("ok") is False, "the cancelled load must not report success"
assert o.active_model_name is None, "must not publish a cancelled model's active name"
assert o.models == {}, "must not publish a cancelled model's mirror"
assert "m" not in o.loading_models
# ----------------------------------------------------------------------------
# Concurrent compare-mode requests must not each spawn a dispatcher. Compare mode
# (_generate_dispatched) deliberately bypasses _gen_lock, so two requests can reach
# _start_dispatcher at once. Without _dispatcher_lifecycle_lock the check-then-spawn
# races: both observe no live dispatcher and each start one. The extra dispatcher is
# orphaned (self._dispatcher_thread tracks only the last) and later consumes the
# "unloaded" reply off the shared resp_queue before unload_model's _wait_response,
# hanging the unload on its 300s timeout. The lifecycle lock must serialize the
# check-then-spawn so exactly one dispatcher thread is ever created.
# ----------------------------------------------------------------------------
def test_concurrent_start_dispatcher_spawns_exactly_one():
import queue as _queue
o = _bare_orchestrator()
o._resp_queue = _queue.Queue() # real queue so the dispatcher loop blocks and stays alive
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._dispatcher_thread = None
o._dispatcher_stop = threading.Event()
o._dispatcher_lifecycle_lock = threading.Lock()
n = 32
# A barrier aligns every thread on the check-then-spawn window: without the lifecycle
# lock several would clear the "is a dispatcher alive?" check together and each spawn one.
barrier = threading.Barrier(n)
results: list = []
results_lock = threading.Lock()
def racer():
barrier.wait()
started = o._start_dispatcher()
with results_lock:
results.append(started)
threads = [threading.Thread(target = racer, name = f"racer-{i}") for i in range(n)]
for t in threads:
t.start()
for t in threads:
t.join(timeout = 5)
try:
# _start_dispatcher returns True only for the caller that actually spawned a thread.
# Exactly one caller may win; every other must observe the dispatcher alive and bail.
assert results.count(True) == 1, f"expected exactly one spawn, got {results.count(True)}"
assert results.count(False) == n - 1
# And exactly one live dispatcher thread exists -- no orphan racing resp_queue.
live = [
t for t in threading.enumerate() if t.name == "inference-dispatcher" and t.is_alive()
]
assert len(live) == 1, f"expected one live dispatcher, found {len(live)}"
assert o._dispatcher_thread is live[0]
finally:
o._stop_dispatcher()
# Stop joins and clears it; no dispatcher thread must survive.
assert o._dispatcher_thread is None
remaining = [
t for t in threading.enumerate() if t.name == "inference-dispatcher" and t.is_alive()
]
assert remaining == [], "dispatcher must be stopped and joined"
# ----------------------------------------------------------------------------
# A compare request whose _start_dispatcher is queued behind an unload's
# _stop_dispatcher must NOT spawn a fresh dispatcher. The idle-dispatcher stop
# and the queued start both serialize on _dispatcher_lifecycle_lock; if the
# queued start spawned a new dispatcher after the stop, it would become the
# resp_queue reader and consume unload_model's "unloaded" reply (unroutable, so
# dropped) before _wait_response saw it -- hanging the unload on its 300s
# timeout. unload_model sets _unload_pending under the SAME lifecycle lock ahead
# of the stop, so _start_dispatcher observes it and refuses.
# ----------------------------------------------------------------------------
def test_start_dispatcher_refuses_while_unload_pending():
# Direct unit guard: with an unload in progress (_unload_pending set under the
# lifecycle lock by unload_model), _start_dispatcher must refuse and spawn nothing,
# even though no dispatcher is currently running.
import queue as _queue
o = _bare_orchestrator()
o._resp_queue = _queue.Queue() # a spawned dispatcher would block-read here and stay alive
o._dispatcher_thread = None
o._dispatcher_stop = threading.Event()
o._dispatcher_lifecycle_lock = threading.Lock()
o._unload_pending = True
started = o._start_dispatcher()
assert started is False, "must not start a dispatcher while an unload is pending"
assert o._dispatcher_thread is None, "no dispatcher thread may be created"
live = [t for t in threading.enumerate() if t.name == "inference-dispatcher" and t.is_alive()]
assert live == [], "no dispatcher may exist to consume the unloaded reply"
def test_start_dispatcher_resumes_after_unload_clears():
# Guard the other direction: once the unload finishes and clears _unload_pending, a
# later compare request must be able to start the dispatcher again (the gate must not
# wedge). Proves the refusal above is scoped to the unload, not permanent.
import queue as _queue
o = _bare_orchestrator()
o._resp_queue = _queue.Queue()
o._dispatcher_thread = None
o._dispatcher_stop = threading.Event()
o._dispatcher_lifecycle_lock = threading.Lock()
o._unload_pending = False
try:
assert (
o._start_dispatcher() is True
), "a fresh dispatcher must start once no unload is pending"
assert o._dispatcher_thread is not None and o._dispatcher_thread.is_alive()
finally:
o._stop_dispatcher()
assert o._dispatcher_thread is None
def test_queued_start_behind_unload_stop_spawns_no_dispatcher():
# Codex's exact ordering, forced deterministically: an unload holds
# _dispatcher_lifecycle_lock across its _stop_dispatcher (the idle dispatcher's join
# is gated by an event), while a compare request's _start_dispatcher is queued behind
# it on the same lock. When the stop releases the lock the queued start must observe
# _unload_pending (set under the lock ahead of the stop) and refuse: no fresh
# dispatcher may be left running to steal the "unloaded" reply.
import queue as _queue
o = _bare_orchestrator()
o._resp_queue = _queue.Queue() # a spawned dispatcher would block-read here and stay alive
o._mailbox_lock = threading.Lock()
o._mailboxes = {}
o._dispatcher_stop = threading.Event()
o._dispatcher_lifecycle_lock = threading.Lock()
o._unload_pending = False
start_queued = threading.Event() # release the stop's join once the start is queued behind it
join_may_finish = threading.Event()
class _IdleDispatcher:
# Stand-in for the idle compare-mode dispatcher the unload stops. Its join blocks
# until we confirm the compare _start_dispatcher is queued behind the stop, so the
# stop provably holds _dispatcher_lifecycle_lock across that window.
def is_alive(self):
return True
def join(self, timeout = None):
assert start_queued.wait(timeout = 5), "compare start must queue behind the stop"
assert join_may_finish.wait(timeout = 5)
o._dispatcher_thread = _IdleDispatcher()
def unload_side():
# unload_model's sequence: set _unload_pending under the lifecycle lock, then stop
# the idle dispatcher (also under the lock, via _wait_dispatcher_idle).
with o._dispatcher_lifecycle_lock:
o._unload_pending = True
o._stop_dispatcher()
started_result = {}
def compare_side():
started_result["v"] = o._start_dispatcher()
u = threading.Thread(target = unload_side, name = "unload-side")
u.start()
# Let the unload set _unload_pending, enter _stop_dispatcher, and block in the gated join
# while holding the lifecycle lock.
time.sleep(0.2)
c = threading.Thread(target = compare_side, name = "compare-side")
c.start()
# Let the compare _start_dispatcher block on the lifecycle lock (queued behind the stop).
time.sleep(0.2)
start_queued.set() # the start is now queued behind the stop
join_may_finish.set() # let the stop's join complete and release the lock
u.join(timeout = 5)
c.join(timeout = 5)
assert started_result.get("v") is False, "the queued start must refuse while unloading"
assert o._dispatcher_thread is None, "the stop cleared it and the queued start spawned nothing"
live = [t for t in threading.enumerate() if t.name == "inference-dispatcher" and t.is_alive()]
assert live == [], "no fresh dispatcher may be left to consume the unloaded reply"