* 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>
1651 lines
66 KiB
Python
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"
|