e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
1623 lines
65 KiB
Python
1623 lines
65 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_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"
|