* feat(studio): run chats in parallel in the Chat tab
New Chat used to cancel whatever the current conversation was generating.
It now leaves it running, like switching to the Train or Export tab: the
sidebar shows which chats are still going, and Stop is per conversation.
Plain `unsloth studio` launched llama-server with one decode slot, so the
admission queue serialised every chat regardless of what the UI did. Both
entry points now default to the same slot count as `unsloth studio run`.
A model swap still ends every running chat, since they all decode on one
llama-server. /load and /unload now refuse with 409 and name those chats
unless the caller passes force_cancel_active, and the UI asks first.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): scope the composer tool badge to its own conversation
The green "Running Python: ..." badge above the composer read a single
global store value, so one chat's tool call showed above every other
chat's composer, including a brand-new empty one. Its elapsed counter
also restarted at 0 on every thread switch, and a run ending anywhere
cleared the badge everywhere.
Key the status by thread and store the moment it started, so each
conversation shows only its own tool call and the counter resumes rather
than restarts. Also adds a test that every conversation gets its own
tool sandbox directory, which parallel tool calls depend on.
* Fix stalled tool calls while awaiting approval for PR #7455
Three problems, all from the approval prompt behaving as though only one
chat could ever run.
Arguments were not streamed for a gated call, so the chat stayed blank for
as long as the model took to write the payload, which for a large file is
minutes. Nothing runs before the decision either way, and the code is what
is being approved, so python and terminal now stream their card while
gated. render_html stays suppressed: its card renders the payload.
The status read "Running ..." with a climbing timer while the call had not
started. It now reports that it is waiting for approval, then switches to
running once allowed.
The admission lease was held across the wait, so four unanswered prompts
held all four decode slots and no other chat could start while llama-server
sat idle. A parked run keeps its lease but no longer counts against
capacity.
Measured with four prompts left open: every gated call streamed its code,
none reported running, and a fresh chat answered in 0.4s where it
previously waited 290s and never did.
* Fix duplicated and truncated tool cards for PR #7455
A gated tool call rendered two cards: the provisional one that streams the
arguments, plus a second one keyed by the approval id. Only the second ever
got its tool_end, so the first spun "Running" for the rest of the chat.
Reuse the open part when the approval prompt arrives.
The terminal card also showed nothing but a 60-char trigger label, so a long
heredoc read as no progress at all. It now renders the command the same way
the Python card renders its script, and neither is capped at 10k chars.
Both cells moved inside the collapsible, so one chevron hides the code with
the output and Copy / Download exist only while the card is open. A card
parked on the prompt says so instead of counting up "Running".
* Fix review findings on the parallel-chat gate for PR #7455
Backend:
- /unload rechecks active generations under the lifecycle gate, like /load,
and lets its 409 through the catch-all instead of rewriting it as a 500.
- /load gates only once _load_model_impl has decided this is a real reload,
so an Apply on the already-loaded model no longer refuses, and the retry it
asks for no longer cancels every chat before returning already_loaded.
- The direct /v1/responses stream registers in the cancel registry, so a
non-forced unload can no longer tear llama-server down under it.
- run_server defaults to the same slot count as the CLI. colab.py calls it
without the argument, so Colab was still serialising every chat.
Frontend:
- Cancelling a backgrounded chat aborts its own request rather than only
posting a cancel id, which is the only thing that ends an external-provider
or audio run.
- The model-swap dialog counts local runs only, and falls back to the backend
when this tab's map is empty, so a reload or a second tab still gets asked.
- Context usage and the diffusion canvas are scoped to the chat that produced
them; a compare row reads activity from its member threads.
Tests:
- The extracted-source cancel harnesses supply the active-generations module,
which the tracked-cancel class now depends on.
* Fix the swap confirmation scope and cancel timing for PR #7455
A forced load cancelled every chat before the model identifier, GPU selection,
training coexistence and download checks had run, so a load that then failed
those checks stopped the chats and replaced nothing. The refusal still happens
early, but the destructive cancel now sits immediately before the teardown it
is paying for, and rechecks under the gate like /unload does.
The swap dialog only reconciled with the backend when this tab looked idle, so
one local chat was enough to hide a second tab's runs. Confirming then sent
force_cancel_active, which cancels every backend run, including the ones the
dialog never mentioned. The backend snapshot is now merged in every time, so
the dialog names what will actually stop. External-provider runs are never
registered there, so the union stays local-only.
Also drops the active-generations docstring claim about restoring sidebar
spinners, which nothing consumes.
* Defer destructive cancels and track every local stream for PR #7455
/unload cancelled the running chats before it had resolved that it unloads
anything. A stale model_path, which a second tab produces routinely, killed
every chat and then no-opped, leaving the resident model up. It now refuses
early and cancels only at each teardown, matching /load.
The swap dialog also stopped every chat locally the moment the user confirmed,
which threw away the two-phase backend behaviour: a load that then failed
identifier resolution, GPU validation or the training guard had already
truncated the replies. The backend now owns the cancel.
Three local streams decoded on llama-server without registering, so a
non-forced unload counted zero generations and tore the server down mid
response: /v1/completions streaming, and the plain and server-tool Anthropic
streams, the first of which is the default /v1/messages path. Note this makes
a non-forced load return 409 during those runs rather than draining quietly,
the same trade the /v1/responses fix made.
The safetensors tool loop still announced a gated call as running while it
waited on a human; only the GGUF loop had been fixed. A source-level parity
test now pins both.
Also drops stopAllChatThreads, which has no callers left.
* Studio: close three load/unload gate races found in review
Re-check the in-flight load guard after the stop-running-chats confirm.
The confirm always GETs active-generations before its zero-running
early-out, so the guard no longer sits atomically ahead of the
reservation and two picks in that window both reached performLoad over
the same refs. ejectModel had the same shape and gets the same re-check.
Reject a sidecar swap immediately before the forced cancel in both load
branches. The previous check was back at the top of preflight, so an
install reserving during identifier resolution, the tier probe, the
training guard or the download check made the post-drain recheck 409 a
load whose chats had already been stopped.
Enter the Anthropic passthrough's cancel tracker inside its body
generator. It was entered eagerly and returned through
_sse_streaming_response, which sets no unstarted_cleanup, so a response
whose body never started left the run registered forever and 409'd every
later non-forced load and unload.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Trim comments across the files this PR touches
Tightens the comments and doc blocks in the backend, CLI, tests and frontend
files changed by this PR: collapses multi-line explanations to a single line
where they still read clearly, and drops the ones the code already says.
No code changes, verified by an AST comparison against the previous commit.
* Studio: defer the destructive cancel and close two gate gaps
Move the forced cancel behind every check that can still reject a swap.
The drain now runs first with the runs it is about to cancel discounted,
so it waits only for inference the cancel cannot end, then the sidecar
check decides, then the cancel fires, then a second drain lets those runs
unwind before teardown. A sidecar install reserving during the drain no
longer 409s a load whose chats have already been stopped.
Track the non-streaming /v1/completions proxy. It was the last local
decode path missing from active_generations, so an unload, which runs no
drain, tore llama-server down under it and force_cancel_active could not
signal it. It now uses the same tracked cancel event and dedicated client
as the OpenAI pass-through.
Skip the client's preliminary unload while chats are generating and let
/load evict at its own post-preflight point instead. Forwarding
force_cancel_active there truncated replies before identifier
resolution, the GPU and training guards and the download check had run.
Keep per-thread context usage so returning to a chat whose background run
finished restores its bar instead of leaving it blank until the next turn.
Make the running-flag clear run-specific. Every run without a resolved
thread id shares the "__default" key, so concurrent compare panes could
clear each other's flag and strand a live stop handle.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: register the embeddings proxy with the swap gate
/v1/embeddings proxied straight through the pooled client with no tracked
cancel event, so it never appeared in active_generations. /unload runs no
idle drain, so a concurrent non-forced unload counted zero generations and
killed llama-server mid-request, and force_cancel_active had no event to
signal. Mirrors the completions proxy: tracked event, dedicated unpooled
client closed by a cancel/disconnect watcher, unregister in a nested
finally so a close failure cannot leave a phantom generation behind.
* Trim comments on the newest changes in this PR
Comments only, no code changes: shorten the ones added by the load-gate
ordering, embeddings and per-thread usage work down to the same density as
the rest of the diff.
* Studio: register the legacy generate stream with the swap gate
/generate/stream built a cancel event but never entered the tracker, so it
was invisible to active_generations. Being in the keep-warm middleware's
inference suffixes only covers /load, which drains; /unload does not, so a
non-forced unload passed the 409 gate and then blocked on the standard
backend's generation lock, and a forced swap had no event to signal.
Registered inside the body generator under a nested finally so a teardown
failure cannot skip the unregister.
The AST contract test asserted the cleanup finally by overwriting its flag
per Try node, so a nested try made the last one win. Accumulate instead,
which is what the existence claim meant.
* Studio: three more swap-gate gaps found in review
Register /audio/generate with the gate. TTS holds the model for the whole
request and /unload runs no drain, so unregistered a non-forced swap counted
zero generations and tore the model down mid-generation; the orchestrator
path only waits 15s for the generation lock, which real TTS exceeds. No
cancel keys: no backend takes a cancel_event for audio, so the event has no
observer and a forced swap still cannot interrupt audio already in flight.
Thread the tracked cancel event into the /v1/responses admission wait. It
was the only admission caller passing None, so a queued run could not be
reached by cancel_all() and a plain /inference/cancel could not stop it at
all. Same omission fixed at the upstream send there and on /v1/completions.
Let an unforced unload of a stale model path reach the no-op check. Before
this PR that request returned 200 and did nothing; the new gate refused it
with 409 for a request that reaches no teardown branch. Gate both refusal
passes on the disjunction of the route's own teardown conditions, including
not is_loaded, so a mid-load GGUF still refuses.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: register the remaining non-streaming decode paths
stream defaults to false on all three of these, so they are the ordinary
shape of their routes, and each holds a local backend for the whole
request. /unload runs no idle drain, so with no registry entry a non-forced
swap counted zero generations and tore the backend down mid-request instead
of returning 409, and a forced one had no event to signal.
Non-streaming /v1/messages: all three helpers ran with an empty registry,
since only the streaming siblings were tracked. Registered at the call site
because the pass-through takes no cancel_event of its own, and with no
cancel keys, matching those siblings.
Non-streaming standard chat and audio-input chat: the trackers in this route
sit inside their `if payload.stream:` arms, so neither else branch was
covered. The GGUF sibling already registers its own non-streaming branch.
Each exit is in a finally on the branch's existing try, so the except arms
are covered too: a leaked entry 409s every later swap until restart.
* Studio: tighten the swap-gate comments
Comment-only pass over the newest swap-gate registrations: collapse the multi-line rationales in /unload, the legacy generate stream, audio generation and the non-streaming chat branches, and the matching test preambles, to the shortest form that still carries the reason. No code changes.
* Studio: stop the reselect dialog promising a stop that never happens
Picking an external provider leaves the local model resident and stops the
status poll mirroring it, so reselecting that model showed the stop-chats
dialog, and /load then answered already_loaded ahead of its cancel hook.
Confirmed with the live backend: the same pick with force_cancel_active set
still returned already_loaded and the chat kept streaming. Not stopping
those chats is right, since the load never interrupts them, so remove the
prompt rather than honour it. Blanket-skipping is unsafe, because the same
id and variant with one sampling setting changed is a real reload and 409s,
so the branch only fires when a status fetch confirms the resident
checkpoint and variant match, and then adopts it without calling /load.
Redact native model paths from the active-generations response. Registering
/generate/stream recorded backend.active_model_name verbatim, which is an
absolute path for a native local model, and this route is the only place
that serialises it. Redacting at the response covers every tracker rather
than the one that surfaced it.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: keep hydrated context usage in the per-thread map
The history loader restores a saved conversation's usage through
setContextUsage only, and it runs once per mount, so switching away and
back left the bar blank for a hydrated chat even after the per-thread map
landed. setContextUsage now writes the value through to the visible
thread's own entry and clears that entry when passed null, which covers
both hydration call sites and any future writer.
* Studio: unblock load cancellation and share unresolved thread keys
Run the two stop-loading fast paths ahead of the unload route's pre-gate
refusal. _unload_may_evict returns True for exactly the model being
cancelled, so the refusal was blocking the branch that cancels a load which
has replaced nothing and can interrupt no chat. The client made that
unrecoverable: cancelLoading sends the unload without force, drops the
result, and its abort never reaches /load, which takes no signal, so the
load ran on and could later cancel those chats and swap the model. Nothing
else is exempted; an unload that would tear down a serving model matches
neither fast path and still 409s. The comment claiming the client lets that
409 surface is corrected, since it discards it.
Hold every owner behind a shared thread key. Runs with no resolved thread id
share "__default" (concurrent compare panes, since startCompare clears
activeThreadId), so a single owner slot let a second run replace the first's
token and then delete the shared entry while it was still generating, and
the server-cancel map lost the older handle the same way. Both now hold a
list, the running and local flags survive until the last owner clears, and
stopChatThread stops every handle under the key.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: carry a confirmed swap into the sidecar install, key restored usage by thread
Picking a model that needs a newer transformers while chats generate raised the
"stop N chats" prompt, but the answer never reached the install that runs before
the load: /install-latest-transformers refused on those same chats and took no
force flag, so Retry hit the same 409 and nothing in the flow stopped them.
Carry force_cancel_active through the consent dialog into the installer. Only
the pre-gate fast path is skipped: the recheck under the lifecycle gate still
has to pass, so an unconfirmed caller is refused as before. The cancel runs last
inside the gate, after every check that can still reject the install, and the
drain behind it is bounded since it holds the gate and the sidecar reservation.
Also key restored context usage by the thread the loader read. history.load()
captures remoteId before two awaited round trips, so a switch inside that window
filed one thread's usage under another and setActiveThreadId kept re-applying it.
Preserve sibling owners when a run key is cleared without an owner: the image
rejection gate now uses its own token, and the reducer leaves owned runs alone.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: bound the post-cancel drains, and make cancellation reach the paths that ignored it
A forced swap cancels the chats it interrupts, then waits for them to unwind.
That wait had no deadline while holding the lifecycle gate, and TTS on the
subprocess backend observes no cancel event at all, so one audio generation
could pin every load, unload and new request for its whole duration. Bound both
post-cancel drains. Pre-cancel drains stay unbounded: the swap can still be
refused there, so shortening them would weaken what they protect.
/unload had the opposite problem and no drain at all, cancelling and tearing
down on the next line, which turned a clean stream end into a dropped
connection. Give it the same bounded wait, gated on the cancel having cancelled
something so an idle Eject pays nothing.
Make the cancel actually land where it can. GGUF TTS now takes a cancel_event
and a watcher closes its client to break the blocking POST. The Anthropic
non-streaming pass-through did the same thing the completions and embeddings
paths used to: register with the gate, then run both POSTs on the pooled client
that cannot be closed. It now uses a per-request client like they do.
Also: park and unpark the admission queue the reservation actually holds, since
queues are keyed by base_url and a reload mints a new port; key tool output by
remoteId on both sides, so the first turn of a New Chat stops writing under one
key and reading another; and give tool status a run owner, so a finishing run
cannot blank the badge a concurrent one is still showing.
Clamp --parallel to 1 on a llama-server without --kv-unified. The new default of
4 would otherwise split -c four ways on such a build, quartering the context
window for a feature it cannot serve.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: scope a chat's Stop to its own generation, and clear the way before a confirmed install
Safetensors generation is serialized on _gen_lock and the worker has a single
cancel event, so a chat still queued on that lock owns no generation. Its Stop
handler called reset_generation_state() anyway, which set the shared event and
ended whichever conversation was actually running. Parallel chats is what makes
that reachable.
_generate_inner now records its cancel_event as the current holder once it takes
the lock, and reset_generation_state drops a reset from anyone else. Every route
call site passes its own request event. A reset with no event stays global, so
unload and model switch cannot leave a generation alive, and a reset while
nothing runs still resets, so an error path before generation is not a no-op.
The other two backends take the argument too, or the standard one raises
TypeError on every cancel.
The sidecar install had the mirror of the /load ordering problem: it cancelled
the chats first and drained second, so an unrelated counted request the cancel
cannot reach (a count_tokens, say) was still there for the recheck, which then
refused an install that had already stopped every chat for nothing. Drain the
unreachable remainder first, discounting the registered chats, then cancel.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: close the windows the previous round's fixes left open
Three follow-ups, two of them holes in the fixes just before them.
The worker claim went in after _send_cmd, so the command was already running
unclaimed and a queued chat's Stop in that window still reset it. Claim first,
with the send inside the same try, so a failed send releases it too.
Tool status kept one entry per key with an owner. That stops a foreign clear but
not an overwrite: under the shared unresolved-thread key the second run replaced
the first's entry, and its own clear then removed the only one while the first
tool was still running. Keep per-run entries and render the newest.
/unload gated its drain on having cancelled something, so a request that passed
the keep-warm middleware but had not reached its tracker yet was invisible to it
and the teardown landed on an already-admitted request. Drain on the middleware
count instead, which covers that window as well as the cancelled runs, then
re-cancel whatever registered while waiting. Bounded, not a refusal: an unload is
deliberate, and on expiry it proceeds exactly as before.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Trim the parallel-chats comments to their reasons
Compress the multi-line rationales added by this branch into shorter forms and drop
restatements of the code below them. The reasons behind the drain bounds, the deferred
cancel, the per-request generation ownership and the thread-scoped tool and usage keys
are kept, just said in fewer lines.
* Studio: own the worker per generation, and make a resumed chat requeue for its slot
Ownership was a single lock holder, so dispatched runs (compare mode bypasses
_gen_lock by design) never claimed it and the guard fell straight through to the
global reset: a Stop on one of them ended its siblings. Track the generations
actually running instead, claimed before the send and released in the same
finally on both paths. A reset still proceeds when nothing is running, so an
error path ahead of generation is not swallowed.
park() hands the freed slot to a waiter, so a chat resuming from a tool approval
could take it back while that waiter was still decoding, putting two holders on
a one-slot server and sending the resumed tool loop past the admission limit.
unpark_async waits for room; the plain unpark stays for a holder tearing down,
which will not decode again.
Audio only observed its cancel event on a forced swap. An explicit Stop just
aborts the fetch, and this route has no cancel id, so llama-server ran on to the
request timeout after the chat reported it stopped. Watch the disconnect.
Also read tool status by remoteId, matching the key the adapter writes and the
fix already made for tool output, and stop an unresolved run from writing its
usage into whichever conversation the user moved to.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: let only the generation the worker is running speak for it, and hold a slot for a resuming chat
The ownership list recorded admission, but the subprocess runs generations one
at a time, so a dispatched request queued behind another counted as an owner and
its Stop signalled the shared cancel event, ending the request that was actually
running. Keep admission for release bookkeeping and gate ownership on execution
instead, promoted when the worker first answers that request. Nothing executing
still permits a reset, so an error path ahead of generation is not swallowed.
The worker has one cancel event and no per-request cancellation, so this decides
who may pull the lever rather than making the lever per-request.
A resuming chat also polled for a slot it could never see: release() grants to
the next waiter under the same lock, so later arrivals overtook an approved chat
indefinitely. A pending unpark now reserves the next slot and they queue behind
it.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: cover the prefill window, and keep a first turn's tool output readable
Gating worker ownership on execution left the interval between the send and the
first response uncovered: nothing is executing then, and the empty case admitted
anyone, so a queued chat's Stop still ended the one in prefill. Split the empty
case. Nothing claimed at all still permits a reset, so an error path ahead of
generation is not swallowed; claimed but unanswered resolves to the oldest
claim, which is what a FIFO command queue is working on.
Putting both sides of the tool-output scope on remoteId left the first turn of a
New Chat writing under the unresolved scope for its whole life while the readers
recomputed the moment the autosave assigned an id, so the card blanked mid-run.
The readers now fall back to the unresolved scope, which only an unpersisted
first turn can occupy.
* Studio: order the parked approvals, and tie a worker claim to its enqueue
The reservation added for admission fairness was a bare count, so every approved
holder counted against every other: park two chats, approve both, and once the
last decoder released, nothing could ever satisfy the check again. That is a
deadlock where the problem it fixed was only unfairness. Make it a FIFO ticket
so a pending unpark blocks the ones behind it and no others.
_owns_worker reads claim order to decide which request the worker is prefilling,
which only holds if claiming and enqueuing cannot interleave. Hold one lock
across both on the dispatched and the locked path.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: follow a first turn's run when its thread gets an id, and key the denoising canvas per chat
A run started before its thread existed filed every handle under "__default". Nothing
moved them once autosave assigned the real id, so the sidebar row showed no spinner and
Stop could not reach the generation, which kept holding a slot.
adoptDefaultThreadRun re-keys the run maps onto the real id from the thread adapter's
initialize(), where the id first exists; anything already filed under that id wins, since
that is a later run. The adapter captures its key once at run start, so it now resolves
the live key per use through runKeyForOwner, looking its own serverCancel up in the owner
map. Without that the migrated entries are stranded and the spinner never clears.
The denoising canvas was one global slot, so two diffusion chats overwrote each other and
the ownership tag then hid the visible preview until that thread emitted again. It is now
activeDiffusionCanvasByThreadId, written and cleared per thread, and the frame no longer
carries a threadId of its own. The bubble reads threadListItem.remoteId, dropping the dead
threadListItem.id arm: the writer tags unstable_threadId, which is exactly remoteId.
Two existing backend tests needed the same treatment. _bare_orchestrator skips __init__, so
it now sets the claim bookkeeping the worker ownership check reads. The Anthropic
passthrough gate test anchored on comment prose that a rewrap had broken; it anchors on the
code instead.
* Studio: hand the worker over cleanly between generations, and stop unresolved runs sharing each other's state
Worker ownership moved off the consumer and onto the dispatcher. Consumers read their
mailbox whenever they get around to it, so a request whose gen_done had been routed still
owned the worker while the next one ran, and a late Stop for it cancelled that one. The
dispatcher is the only place responses arrive in the order the worker produced them: it
now retires a request at its terminal response and promotes the next one, and answering a
request makes it the sole executor, since the subprocess runs one generation at a time.
reserve()'s immediate path ignored the unpark tickets that _grant_waiters_locked already
honours, so a request arriving between a slot freeing and an approved chat's next poll
took it, repeatedly. It applies the same reservation now.
Three places let concurrent first turns share state through the "__default" key. Nothing
links a run filed there to the id its thread later receives, so rather than guess, each
now declines when the key is ambiguous: adoption only re-keys a lone run, the composer
badge only claims a lone status, and the tool-output fallback only applies to a thread
that is still running. That leaves two concurrent first turns where they were before
adoption existed instead of handing one thread the other's handles.
A first turn's usage was never filed, because its key stayed null for the whole run while
autosave moved activeThreadId to the real id, so the context bar went blank after the
first reply. It resolves the adopted key like the cleanup handles do.
Cancelling a forced load left the UI with no model: the previous one stays resident until
/load's teardown, and the cancel path cleared the checkpoint without rolling back. It now
resyncs from the backend, which is right whether or not the load got that far.
The sidecar install drain is weighted 1:4 rather than halved, total unchanged. Only the
second half benefits from patience, and cutting it short refused installs whose chats had
already been stopped for nothing.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: give a first turn its real thread id before the run starts
A first turn filed every run handle under a shared unresolved key because
assistant-ui binds unstable_threadId before the thread is persisted. Two of them
overlapping there is unresolvable afterwards, and the last round's migration could
only decline rather than guess, which left neither sidebar row showing its run.
The id is available earlier than I claimed. append() already tracks
threadListItem.initialize() by the user message id, and createPersistedRunAdapter
already awaits that promise before invoking the adapter, so the thread is persisted
by the time the run begins. It was only being discarded: the tracked promise resolved
to void. It now resolves to the assigned id, and the wrapper hands it to the adapter
when assistant-ui had none. An id that is already set is never replaced, since that
would move a running chat's handles out from under the row watching them. The
existing unresolved-key guards stay as a safety net but should no longer carry weight.
The sidebar counted running thread ids rather than rows, so one compare conversation
read as two chats. It folds ids into rows through the same threadIds the row spinner
uses, and still counts a running id that matches no row.
_TrackedCancel always registered kind="chat", so an embeddings or raw completions
request appeared in the model-swap prompt as an unnamed conversation and confirming
cancelled it while calling it a chat. The non-conversation routes now pass their own
kind, and the prompt says "requests" whenever the snapshot is not all chats.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: withhold the shared worker cancel from a request the worker has left
Moving ownership to the dispatcher fixed reset_generation_state, but the token loop
signals the shared worker event directly and did not carry the same rule. A dispatched
consumer runs with mark_started off and can still be draining tokens buffered before
its gen_done was routed, so stopping it there ended whichever request the worker had
started next.
It now signals only when _owns_worker agrees, the same predicate reset_generation_state
uses. The local drain and return are unconditional, since those touch nothing but this
stream. The remaining _cancel_generation callers are deliberately global: subprocess
shutdown, the pre-load kill and unload_model.
* Studio: add the AGPL-3.0 header to the first-turn identity test
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: stop the dispatcher and a _gen_lock stream fighting over the response queue
Nothing stopped the dispatcher starting under a _gen_lock generation, so once compare
was opened while an ordinary chat was still streaming, both consumed _resp_queue and
whichever response the dispatcher took without a mailbox was dropped, gen_done included.
That chat truncated or hung. This PR is what makes it reachable, since navigating into
compare no longer ends the chat behind it.
Delaying the dispatcher would serialise compare behind whatever chat happens to be
streaming, so the direct readers get a mailbox instead. _direct_reader returns a reader,
a cancel drain and a release, and files the mailbox under _direct_mailboxes rather than
_mailboxes, which means "compare requests are in flight" to the unload and distributed
paths and must not count an ordinary chat.
Both directions close. The dispatcher finds the direct reader's mailbox instead of
dropping. And this reader can already be blocked on the queue when a compare request's
dispatcher starts, so a response that is not ours goes to its own mailbox rather than
being consumed, which would have corrupted the chat and hung the pane. All three
_gen_lock readers use it, and the cancel drain goes through it too.
The sidebar's return target still picked a raw pane id while the count grouped by row,
and /chat addresses compare with `compare`, not `thread`. It resolves through the same
items now, so a running compare row returns to its pair.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: keep worker ownership honest across audio, API traffic and a replaced worker
The audio-input send got a mailbox last round but stayed unclaimed, so a compare request
queued behind it looked like the oldest owner and stopping that queued request signalled
the shared event into the audio chat. It claims under the send lock and releases in the
finally, like _generate_inner.
Ownership is keyed on cancel-event identity with nothing tying it to a worker generation,
so a consumer still blocked on its mailbox when the process was replaced stayed recorded
as the executor, and a generation on the fresh worker could not be stopped.
_shutdown_subprocess clears that state once the process is confirmed dead, mailboxes
included: nothing routes to them again, and a stale one reads as compare activity to the
unload path. Not on the survived-SIGKILL path, which keeps its handle on purpose.
The four public /v1/messages trackers were registering as chats. The distinction is a
Studio thread, not the protocol, and those branches already say "No thread_id: public API
surface" while the Studio path passes payload.thread_id separately. They carry their own
kind now, so the swap prompt stops calling an external request a chat.
The swap confirmation still counted raw pane ids, so a compare conversation asked to stop
two chats and listed its title twice. It folds panes onto pairId and lowers the count by
what it collapsed, leaving a first turn the backend can count but not name.
Deep Research set runningByThreadId but registered no server-cancel handle, and that map
is how Stop, archive and delete reach a thread that is no longer active. Leaving the
outgoing thread running is this PR's doing, so the run was left unreachable while its
supervisor kept working against a conversation the user could delete.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: tighten the parallel-chats comments
* Studio: replay a Deep Research stop that arrived before the run existed
The handle is registered before createResearchRun resolves because the thread can be
stopped while that request is in flight, but it had no id to act on and dropped the stop.
The supervisor then followed a run the user had already stopped, archived or deleted.
It latches instead: a stop with no id yet sets a flag, and the adapter replays it against
the id the moment creation returns rather than starting to follow.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: fix worker ownership on a raced reroute, and the stop-chats prompt
Four review findings on the parallel-chats work, all reproduced first.
- _direct_reader hands a foreign response to its own mailbox, but skipped the
ownership move the dispatcher makes. A _gen_lock reader already blocked on
resp_queue can beat the compare dispatcher to that request's first response,
and the compare consumer opts out of marking, so nothing promoted it: the
direct request stayed the recorded executor, its late reset cancelled the
compare generation, and the compare chat's own Stop was ignored.
- A chat stopped while queued on _gen_lock was still claimed and sent once the
lock freed. Cancellation is only checked on a token, so a long prefill, or a
generation reaching gen_done without one, occupied the worker after Stop.
Same hole in the audio-input path, which shares the lock.
- The stop-chats prompt counted generation handles, not conversations. One chat
holds several while a tool continuation registers its next leg before the
previous unwinds, so it offered to stop two chats and listed one title.
- Ejecting a model confirms through that dialog, which told the user
"Unloading the model reloads the model" and offered "Stop and reload".
Confirming calls /unload and leaves nothing loaded.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: name the TTS run's thread so the stop prompt counts it once
The audio branch registers its run locally under the thread key but sent no
thread_id, so the backend tracker filed the same generation under no thread.
The stop-chats prompt then had a named local run and an unnamed backend one and,
since e8e7594 started adding unnamed entries to the named ones, counted a single
TTS chat as two requests. The backend already reads payload.thread_id, so
sending it lines both registries up on the same run.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2035 lines
81 KiB
Python
2035 lines
81 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._send_order_lock = threading.Lock()
|
|
o._active_cancel_lock = threading.Lock()
|
|
o._active_cancel_events = []
|
|
o._executing_cancel_events = []
|
|
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_adapter_control_raises_stream_errors(monkeypatch):
|
|
o = _bare_orchestrator()
|
|
monkeypatch.setattr(
|
|
o,
|
|
"_generate_dispatched",
|
|
lambda **_kwargs: iter([orch_mod.GenStreamError("Error: adapter failed")]),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match = "adapter failed"):
|
|
list(o.generate_with_adapter_control(use_adapter = False))
|
|
|
|
closed = []
|
|
|
|
def _stream(**_kwargs):
|
|
try:
|
|
yield "token"
|
|
yield "late token"
|
|
finally:
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(o, "_generate_dispatched", _stream)
|
|
generator = o.generate_with_adapter_control(use_adapter = False)
|
|
assert next(generator) == "token"
|
|
generator.close()
|
|
assert closed == [True]
|
|
|
|
|
|
def test_worker_closes_cancelled_generator_before_gen_done():
|
|
from core.inference.worker import _handle_generate
|
|
|
|
events = []
|
|
|
|
class _Backend:
|
|
last_generation_stats = None
|
|
|
|
def generate_with_adapter_control(self, **_kwargs):
|
|
try:
|
|
yield "token"
|
|
yield "late token"
|
|
finally:
|
|
events.append("closed")
|
|
|
|
class _Responses:
|
|
def __init__(self):
|
|
self.items = []
|
|
|
|
def put(self, item):
|
|
if item["type"] == "gen_done":
|
|
assert events == ["closed"]
|
|
self.items.append(item)
|
|
|
|
responses = _Responses()
|
|
cancel = threading.Event()
|
|
cancel.set()
|
|
_handle_generate(
|
|
_Backend(),
|
|
{"request_id": "r1", "messages": [], "use_adapter": False},
|
|
responses,
|
|
cancel,
|
|
)
|
|
|
|
assert [item["type"] for item in responses.items] == ["gen_done"]
|
|
|
|
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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._request_cancel_events = {}
|
|
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"
|
|
|
|
|
|
def _dispatch(o, resps):
|
|
"""Run the dispatcher over a fixed response list and stop it."""
|
|
import queue as _queue
|
|
|
|
o._resp_queue = _queue.Queue()
|
|
for r in resps:
|
|
o._resp_queue.put(r)
|
|
o._dispatcher_stop = threading.Event()
|
|
t = threading.Thread(target = o._dispatcher_loop, daemon = True)
|
|
t.start()
|
|
deadline = time.monotonic() + 5.0
|
|
while not o._resp_queue.empty() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
o._dispatcher_stop.set()
|
|
t.join(timeout = 5.0)
|
|
|
|
|
|
def test_worker_ownership_follows_the_worker_not_the_consumer():
|
|
# The subprocess runs one generation at a time and can start B while A's consumer has yet to
|
|
# drain its mailbox. A must stop owning the worker the moment its gen_done is routed, else
|
|
# a late Stop for A cancels B.
|
|
import queue as _queue
|
|
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
a_cancel, b_cancel = threading.Event(), threading.Event()
|
|
o._mailboxes = {"a": _queue.Queue(), "b": _queue.Queue()}
|
|
o._request_cancel_events = {"a": a_cancel, "b": b_cancel}
|
|
o._claim_worker(a_cancel)
|
|
o._claim_worker(b_cancel)
|
|
|
|
_dispatch(o, [{"type": "token", "request_id": "a", "token": "hi"}])
|
|
assert o._owns_worker(a_cancel), "the request the worker is answering owns it"
|
|
assert not o._owns_worker(b_cancel), "a queued request does not"
|
|
|
|
# A finishes. B has been sent but has not answered yet (it is prefilling), so the gap
|
|
# between the two is the window a late Stop for A used to fire into.
|
|
_dispatch(o, [{"type": "gen_done", "request_id": "a"}])
|
|
assert not o._owns_worker(a_cancel), "a finished request stops owning the worker"
|
|
assert o._owns_worker(b_cancel), "the next queued request is the one prefilling"
|
|
|
|
# Worker moves on to B, still before A's consumer reads anything.
|
|
_dispatch(o, [{"type": "token", "request_id": "b", "token": "yo"}])
|
|
assert not o._owns_worker(a_cancel), "a finished request must not cancel its successor"
|
|
assert o._owns_worker(b_cancel), "the worker moved on to B, so B owns it"
|
|
|
|
# A's own stream unwinding afterwards must not disturb B.
|
|
o._release_worker(a_cancel)
|
|
assert o._owns_worker(b_cancel)
|
|
|
|
|
|
def test_status_responses_do_not_transfer_worker_ownership():
|
|
# Status lines are not an answer to any request; the dispatcher drops them before routing.
|
|
import queue as _queue
|
|
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
a_cancel, b_cancel = threading.Event(), threading.Event()
|
|
o._mailboxes = {"a": _queue.Queue(), "b": _queue.Queue()}
|
|
o._request_cancel_events = {"a": a_cancel, "b": b_cancel}
|
|
o._claim_worker(a_cancel)
|
|
o._claim_worker(b_cancel)
|
|
|
|
_dispatch(o, [{"type": "status", "request_id": "b", "message": "loading"}])
|
|
# Nothing has answered, so the oldest claim is still the one prefilling.
|
|
assert o._owns_worker(a_cancel)
|
|
assert not o._owns_worker(b_cancel)
|
|
|
|
|
|
def test_only_the_latest_responder_executes():
|
|
# The subprocess runs one generation at a time, so answering B means it has left A.
|
|
# _generate_inner promotes from its own consumer and can share the worker with a
|
|
# dispatched request, so the two must not both count as executing.
|
|
o = _bare_orchestrator()
|
|
a_cancel, b_cancel = threading.Event(), threading.Event()
|
|
o._claim_worker(a_cancel)
|
|
o._claim_worker(b_cancel)
|
|
|
|
o._mark_worker_started(a_cancel)
|
|
assert o._owns_worker(a_cancel)
|
|
o._mark_worker_started(b_cancel)
|
|
assert o._owns_worker(b_cancel), "the latest responder is the one executing"
|
|
assert not o._owns_worker(a_cancel), "and it is the only one"
|
|
# Idempotent: more of B's own tokens must not disturb it.
|
|
o._mark_worker_started(b_cancel)
|
|
assert o._owns_worker(b_cancel)
|
|
|
|
|
|
def test_a_stale_mailbox_read_does_not_cancel_the_running_generation():
|
|
# A dispatched consumer can still be draining tokens after the dispatcher retired its request
|
|
# and started the next one. Stopping it then must tear down only its own stream: signalling
|
|
# the shared worker event would end its successor.
|
|
import queue as _queue
|
|
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
a_cancel, b_cancel = threading.Event(), threading.Event()
|
|
o._mailboxes = {"a": _queue.Queue(), "b": _queue.Queue()}
|
|
o._request_cancel_events = {"a": a_cancel, "b": b_cancel}
|
|
o._claim_worker(a_cancel)
|
|
o._claim_worker(b_cancel)
|
|
# Worker finished A and moved on to B.
|
|
_dispatch(
|
|
o,
|
|
[
|
|
{"type": "gen_done", "request_id": "a"},
|
|
{"type": "token", "request_id": "b", "token": "yo"},
|
|
],
|
|
)
|
|
assert o._owns_worker(b_cancel) and not o._owns_worker(a_cancel)
|
|
|
|
# A's consumer now reads a token buffered before that, with A stopped.
|
|
a_cancel.set()
|
|
stale = [{"type": "token", "request_id": "a", "text": "late"}]
|
|
drained = []
|
|
list(
|
|
o._consume_token_stream(
|
|
lambda timeout: stale.pop(0) if stale else None,
|
|
lambda: drained.append(True),
|
|
crash_context = "generation",
|
|
cancel_event = a_cancel,
|
|
mark_started = False,
|
|
)
|
|
)
|
|
assert drained, "the stopped stream still tears itself down"
|
|
assert not o._cancel_event.is_set(), "a retired request must not signal the shared worker event"
|
|
|
|
# The generation that does own the worker still can.
|
|
b_cancel.set()
|
|
stale_b = [{"type": "token", "request_id": "b", "text": "live"}]
|
|
list(
|
|
o._consume_token_stream(
|
|
lambda timeout: stale_b.pop(0) if stale_b else None,
|
|
lambda: None,
|
|
crash_context = "generation",
|
|
cancel_event = b_cancel,
|
|
mark_started = False,
|
|
)
|
|
)
|
|
assert o._cancel_event.is_set(), "the running generation's own Stop must reach the worker"
|
|
|
|
|
|
def test_a_dispatcher_started_mid_stream_still_reaches_the_direct_reader():
|
|
# A compare request can start the dispatcher while an ordinary chat is streaming. The
|
|
# dispatcher then owns resp_queue, and without a mailbox for the direct reader it dropped
|
|
# that chat's tokens and its gen_done as unaddressed, hanging it.
|
|
import queue as _queue
|
|
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
o._mailboxes = {}
|
|
o._direct_mailboxes = {}
|
|
o._request_cancel_events = {}
|
|
|
|
read_one, _drain, release = o._direct_reader("direct-1")
|
|
try:
|
|
_dispatch(
|
|
o,
|
|
[
|
|
{"type": "token", "request_id": "direct-1", "text": "hi"},
|
|
{"type": "gen_done", "request_id": "direct-1"},
|
|
],
|
|
)
|
|
assert read_one(timeout = 0.1) == {
|
|
"type": "token",
|
|
"request_id": "direct-1",
|
|
"text": "hi",
|
|
}, "the dispatcher must route to the direct reader, not drop"
|
|
assert read_one(timeout = 0.1)["type"] == "gen_done"
|
|
finally:
|
|
release()
|
|
assert o._direct_mailboxes == {}, "the mailbox is dropped when the stream ends"
|
|
|
|
|
|
def test_the_direct_reader_hands_back_a_compare_response_it_took():
|
|
# The mirror race: this reader is already blocked on resp_queue when a compare request's
|
|
# dispatcher starts, so it can take that request's response first. Consuming it would
|
|
# corrupt this chat and hang the compare pane.
|
|
import queue as _queue
|
|
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
compare_box: _queue.Queue = _queue.Queue()
|
|
o._mailboxes = {"compare-1": compare_box}
|
|
o._direct_mailboxes = {}
|
|
o._request_cancel_events = {}
|
|
o._resp_queue = _queue.Queue()
|
|
o._dispatcher_thread = None # no dispatcher yet: this reader owns the queue
|
|
|
|
read_one, _drain, release = o._direct_reader("direct-1")
|
|
try:
|
|
o._resp_queue.put({"type": "token", "request_id": "compare-1", "text": "theirs"})
|
|
o._resp_queue.put({"type": "token", "request_id": "direct-1", "text": "mine"})
|
|
assert read_one(timeout = 0.1) is None, "a foreign response is not ours to yield"
|
|
assert compare_box.get_nowait()["text"] == "theirs", "it goes to its own mailbox"
|
|
assert read_one(timeout = 0.1)["text"] == "mine"
|
|
finally:
|
|
release()
|
|
|
|
|
|
def test_a_direct_mailbox_is_not_mistaken_for_compare_activity():
|
|
# _mailboxes means "compare requests are in flight" to the unload and distributed paths,
|
|
# so an ordinary chat's mailbox must live somewhere else.
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
o._mailboxes = {}
|
|
o._direct_mailboxes = {}
|
|
_read_one, _drain, release = o._direct_reader("direct-1")
|
|
try:
|
|
assert o._mailboxes == {}
|
|
assert "direct-1" in o._direct_mailboxes
|
|
finally:
|
|
release()
|
|
|
|
|
|
def test_replacing_the_subprocess_clears_worker_scoped_state():
|
|
# Ownership is keyed only by cancel-event identity, so a consumer still blocked on its
|
|
# mailbox when the worker was replaced stayed recorded as the executor. A generation on
|
|
# the fresh worker then failed _owns_worker and could not be stopped.
|
|
import queue as _queue
|
|
|
|
o = _bare_orchestrator()
|
|
o._mailbox_lock = threading.Lock()
|
|
dead = threading.Event()
|
|
o._mailboxes = {"compare-1": _queue.Queue()}
|
|
o._direct_mailboxes = {"direct-1": _queue.Queue()}
|
|
o._request_cancel_events = {"compare-1": dead}
|
|
o._claim_worker(dead)
|
|
o._mark_worker_started(dead)
|
|
assert o._owns_worker(dead)
|
|
|
|
o._reset_worker_scoped_state()
|
|
|
|
assert o._mailboxes == {} and o._direct_mailboxes == {}
|
|
assert o._request_cancel_events == {}
|
|
assert o._active_cancel_events == [] and o._executing_cancel_events == []
|
|
# A generation on the fresh worker owns it rather than being refused by a ghost.
|
|
fresh = threading.Event()
|
|
o._claim_worker(fresh)
|
|
assert o._owns_worker(fresh), "the dead worker's request must not outrank a live one"
|
|
|
|
|
|
def test_audio_input_claims_the_worker_before_sending():
|
|
# Unclaimed, a compare request queued behind an audio-input generation looked like the
|
|
# oldest owner, so stopping that queued request signalled the worker and killed this.
|
|
import ast
|
|
import pathlib
|
|
|
|
src = pathlib.Path(orch_mod.__file__).read_text(encoding = "utf-8")
|
|
tree = ast.parse(src)
|
|
fn = next(
|
|
n
|
|
for n in ast.walk(tree)
|
|
if isinstance(n, ast.FunctionDef) and n.name == "_generate_audio_input_inner"
|
|
)
|
|
body = ast.get_source_segment(src, fn) or ""
|
|
claim = body.find("self._claim_worker(cancel_event)")
|
|
send = body.find("self._send_cmd(cmd)")
|
|
assert claim != -1, "_generate_audio_input_inner must claim the worker"
|
|
assert send != -1
|
|
assert claim < send, "the claim has to happen before the command is enqueued"
|
|
assert "with self._send_order_lock:" in body, "claim and send must be one critical section"
|
|
assert "self._release_worker(cancel_event)" in body
|
|
|
|
|
|
def test_generation_stopped_while_queued_is_never_sent(monkeypatch):
|
|
# Two chats on the serialized backend: the second blocks on _gen_lock, and Stop sets its
|
|
# event while it waits. Sending anyway occupied the worker with a run the user ended --
|
|
# the cancel is only checked on a token, so a long prefill (or a generation that reaches
|
|
# gen_done without one) still held up its siblings.
|
|
o = _bare_orchestrator()
|
|
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
|
|
monkeypatch.setattr(o, "_wait_dispatcher_idle", lambda *a, **k: None)
|
|
monkeypatch.setattr(
|
|
o, "_send_cmd", lambda cmd: pytest.fail("must not send a generation already stopped")
|
|
)
|
|
stopped = threading.Event()
|
|
stopped.set()
|
|
|
|
out = list(
|
|
o._generate_inner(messages = [{"role": "user", "content": "hi"}], cancel_event = stopped)
|
|
)
|
|
|
|
assert out == [], "a stopped request yields nothing rather than an error banner"
|
|
assert o._active_cancel_events == [], "it must not claim the worker either"
|
|
assert o._gen_lock.acquire(blocking = False)
|
|
o._gen_lock.release()
|
|
|
|
|
|
def test_audio_input_stopped_while_queued_is_never_sent(monkeypatch):
|
|
# Same lock, same hole.
|
|
o = _bare_orchestrator()
|
|
monkeypatch.setattr(o, "_ensure_subprocess_alive", lambda: True)
|
|
monkeypatch.setattr(
|
|
o, "_send_cmd", lambda cmd: pytest.fail("must not send a generation already stopped")
|
|
)
|
|
stopped = threading.Event()
|
|
stopped.set()
|
|
|
|
out = list(o._generate_audio_input_inner(audio_array = [0.0, 0.1], cancel_event = stopped))
|
|
|
|
assert out == []
|
|
assert o._active_cancel_events == []
|
|
assert o._gen_lock.acquire(blocking = False)
|
|
o._gen_lock.release()
|