Studio: confirm an Audio model swap instead of dead-ending on the backend 409 by shimmyshimmer · Pull Request #9092 · unslothai/unsloth · GitHub
Skip to content

Studio: confirm an Audio model swap instead of dead-ending on the backend 409 - #9092

Merged
danielhanchen merged 5 commits into
mainfrom
studio-audio-swap-confirm
Aug 19, 2026
Merged

Studio: confirm an Audio model swap instead of dead-ending on the backend 409#9092
danielhanchen merged 5 commits into
mainfrom
studio-audio-swap-confirm

Conversation

@shimmyshimmer

Copy link
Copy Markdown
Member

Summary

Loading or ejecting a model from the Audio page could fail with an error toast the user had no way to act on:

Loading a model would stop 4 chats that are still generating. Stop them first, or retry with force_cancel_active.

Unloading the model would stop 5 chats that are still generating. Stop them first, or retry with force_cancel_active.

force_cancel_active is a request field, not something anyone can click, so the toast was a dead end: the only way forward was to find every generating chat by hand and stop it.

Cause

Audio calls /load and /unload directly. Chat routes both through confirmStopRunningChatsIfNeeded, which counts what is running, asks about it, and sets force_cancel_active only once the user has said yes. Audio never picked that up, so it saw the raw 409 from _raise_or_cancel_active_generations instead.

Read aloud makes this easy to hit. A studio TTS preview holds the model for the whole request and registers as an active generation, so a couple of previews are enough for the next load to refuse. The existing dialog already handles that: hasNonChat words audio and embedding requests as requests rather than chats, so the count stops claiming there are conversations that do not exist.

Changes

  • Audio's TTS load asks before it swaps, and passes force_cancel_active from the answer.
  • Eject asks the same way, worded as an unload rather than a reload, since it leaves nothing loaded.
  • Both stop local prompt queues and accepted-but-not-yet-running sends alongside the runs the backend cancels, so a queued prompt does not immediately start on the model that just went away.
  • The load claims its in-flight slot before awaiting the dialog. A routed pick that arrives while the prompt is open queues behind the attempt instead of walking past the guard and starting a second load.
  • Declining leaves the page untouched. The staged download is invalidated and the model torn down only past the confirmation, and a refused load drops its queued pick rather than reopening the prompt for a swap that was just turned down.
  • confirmStopRunningChatsIfNeeded and cancelPreStreamRunReservations are exported from the chat barrel for this.

The backend is unchanged. The 409 still guards every other caller, including a second tab and curl.

Testing

  • studio/frontend/tests/audio-model-eject.test.ts: the eject assertion that pinned the unforced unloadModel({ model_path: activeModel }) now pins the confirm-then-force flow, plus new cases for the confirmation ordering on eject, the load prompt, the slot claimed before the await, and the declined-load cleanup.
  • Full frontend suite: 3589 passing.
  • npm run typecheck clean, npm run build clean. biome check and eslint on the touched files are unchanged from the pre-change baseline.

To reproduce the original behaviour: start a chat generating on a local model, open Audio, and pick a TTS model. Before this change that returns the 409 toast above. After it, the same prompt Chat raises appears, confirming goes through, and declining leaves the loaded model and any staged download alone.

…kend 409

Audio called /load and /unload directly, so the 409 the backend raises when chats
are still generating arrived as an error toast with nothing to act on. Route both
through the same confirmation Chat uses and force from the answer.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…irmed after Audio hides

Without the gate a queue can materialize while the dialog is open, outside the
snapshot the answer was given for. pendingTtsLoad is also still null while the
dialog is open, so the deactivation effect could not abort a load confirmed from
a page that had gone away; queue it for the activation replay instead.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea04e27e99

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block Audio generation while taking the stop snapshot

When a direct pick or staged-download completion enters this preflight, only ttsLoadInFlight and Chat's modelLoading are set; Audio's Generate button and handleGenerate check busy/busyRef instead. Because confirmStopRunningChatsIfNeeded() awaits the backend snapshot before opening any dialog, the user can start a TTS request during that window. That request is absent from stopDecision, so a decision with forceCancelActive: false recreates the /load 409, while a forced decision cancels audio the user never agreed to stop. Claim Audio's busy gate before the confirmation await, or make generation reject while ttsLoadInFlight is set.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mechanism is real and I reproduced it, but the only unguarded window is the pre-dialog snapshot fetch, tens of milliseconds. AlertDialog takes no modal prop and is unconditionally modal: it traps focus and sets pointer-events: none outside, so Generate is unreachable for the dialog's whole life. In the count === 0 branch no dialog opens at all and the outcome is the unforced 409, identical to pre-PR, so there is no regression to fix. Sources: Radix AlertDialog docs and radix-ui/primitives#2122.

@danielhanchen

Copy link
Copy Markdown
Member

Review summary

Verdict: useful, no fix needed. The tree is clean - the two review items with real, non-adversarial paths were already absorbed by head, and the third does not clear the bar.

Before / after

Before, Audio's TTS load sent /load with no force flag, and Audio's Eject ran invalidatePendingStagedTts(); stageTtsDownload([]) first and then sent an unforced /unload. If any local chat was generating, _raise_or_cancel_active_generations refused with a 409 and the page printed the raw backend string as a red toast. So Eject destroyed the staged download and then failed, leaving no way forward but hunting down every chat by hand.

After, both paths call confirmStopRunningChatsIfNeeded() before touching the backend, thread the answer through as force_cancel_active, cancel the captured pre-stream reservations and prompt queues, and hold Chat's lifecycle lease across the question. Declining leaves the page exactly as it was, staged download included.

Evidence

Both installs built from their own SHAs (BEFORE 66188b8d5, AFTER ea04e27e9), with unsloth/Qwen3-0.6B-GGUF resident and exactly one chat genuinely streaming, re-read per side in the second before the click (active_generation_count_at_click: 1).

confirm dialog

BEFORE is the plain Audio page. AFTER is the modal "Stop 1 running chat?", naming the actual conversation "Lighthouse keeper story", with Keep generating / Stop and unload.

raw 409 toast

This is the half that shows why the issue is real rather than cosmetic. BEFORE, the user is shown the raw backend text: "Unloading the model would stop 1 chat that is still generating. Stop them first, or retry with force_cancel_active." - instructing them to set a flag no UI exposes. AFTER there is no toast at all, because nothing is sent until the question is answered.

confirm_dialog_shown false -> true, raw_409_toast true -> false. The model is still loaded on both sides afterwards, so neither frame was taken post-teardown.

Worth noting the first attempt correctly failed the identical-output guard with the generation ended before the eject was attempted (count 0): llama-server runs --parallel 4, so a slot holds about 2048 tokens and the run finished while the AFTER browser was still booting. That was diagnosed and fixed rather than waved through with --allow-identical.

On the three items

Items 1 and 2 were filed at 11:40 UTC against 1a55f03c1; head landed at 12:19. Both were genuinely right when written and are fixed at head - GitHub simply never marked them outdated, so they read as live. Details are in the threads.

Item 3 is rejected, and the reason is structural rather than a judgement call: AlertDialog takes no modal prop and is unconditionally modal, so it traps focus and sets pointer-events: none outside. Generate is unreachable for the dialog's whole life. The only unguarded window is the pre-dialog snapshot fetch, tens of milliseconds, and in the count === 0 branch no dialog opens at all and the outcome is the unforced 409 - identical to pre-PR, so there is nothing to regress.

Does it break anything

force_cancel_active is not new - it landed on 2026-07-28 in #7455 on both LoadRequest and UnloadRequest with False defaults. No new route, no schema change, no persisted-state change. Neither request model uses extra="forbid", so even an older backend ignores the field and degrades to the pre-PR unforced behaviour rather than 422-ing. If /api/chat/active is missing entirely, confirmStopRunningChatsIfNeeded catches and falls back to the tab's own run map.

Simulation

52 of 52 pass, driving the real confirm-stop-running-chats.ts, stop-running-chats-dialog-store.ts, pre-stream-run-reservation.ts, prompt-queue-boundary.ts, prompt-queue-ui-store.ts and ModelLifecycleGate - only the network, IndexedDB and the 3.5k-line runtime store are stubbed, and the fake server mirrors _raise_or_cancel_active_generations including cancel_all().

Covered: the pre-PR baseline (409 dead end, staged download destroyed); load path with confirm, decline, Escape and outside-click; a chat finishing mid-dialog (stale count only, nothing extra stopped); a chat or queue starting mid-dialog (the lease aborts it); two Audio swaps racing; the page hidden mid-dialog and the pick replayed against a fresh snapshot; gate contention; backend unreachable; the eject path including decline preserving the staged download; and a leak sweep over 13 named exit paths plus 200 randomised sequences. No case leaks the lifecycle gate, which was the deadlock I most expected to find.

tests/audio-model-eject.test.ts 12/12, full frontend suite 3591/3591, tsc -b clean.

Browsers: nothing here is engine-dependent, so I am not claiming a matrix. The diff adds no browser API - zustand store reads, refs, and one Radix AlertDialog already mounted globally and already used by Chat. crypto.randomUUID is pre-existing and polyfilled at src/main.tsx:16.

Below the bar, noted

pendingRoutedTtsPick is single-slot, so the !activeRef.current deferral overwrites a pick queued behind it, and the lifecycleLease === null and decline paths both null it. Both need a routed ?model= navigation arriving through the modal, and the one shortcut that gets through navigates to /chat with no ?model=. Unreachable in a real session.

CI

Both failures are infrastructure. Rust unit tests (windows) died in Set up job with 429 Too Many Requests fetching dtolnay/rust-toolchain, and main hit the same failure the same afternoon; this PR touches no Rust. Source lint died on a gh: Not Found (HTTP 404) from its own gh api .../compare call under bash -e, with four unrelated branches failing the same hour and the call succeeding today; this PR changes zero Python, so that step is a no-op here regardless.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea04e27e99

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +710 to +712
if (lifecycleLease === null) {
ttsLoadInFlight.current = false;
pendingRoutedTtsPick.current = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the TTS pick when the lifecycle gate is busy

When a deferred pick is replayed as Audio becomes active while another surface is still loading a model, replayQueuedTtsPick() clears the queued value before calling this function, and this branch then discards the request permanently. The same loss occurs when a staged download completes during a Chat model load because its pending entry is cleared before this call. Preserve the current { repoId, ggufFilename, loadId } and retry it after the lifecycle lease becomes available; otherwise the requested model never loads and the user must select it again.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking this one, though it is a fair reading of the flow. The branch is reached with the lifecycle lease held by another surface, and it is a refusal, not a silent drop: the user gets "Wait for the current model to finish loading." The file already treats every refusal the same way, and says so at the decline branch a few lines below, where the comment is that declining refuses the swap so a queued pick must not reopen the dialog.

Preserving the pick is also not the small change it looks like. Nothing calls replayQueuedTtsPick when the blocking load belongs to Chat, so a preserved pick would not retry when the lease frees; it would sit until Audio is next activated. That is worse than losing it: the user, having been told to wait, picks a different model and loads it, and then navigating away and back replays the stale pick and swaps the model underneath them. A correct retry has to be tied to the lease becoming free and guarded against a newer pick superseding it, which is a design change to the lifecycle path this PR just hardened, not a fix to fold into it.

If you want it, the shape I would take is a single-slot retry armed on lease release and dropped whenever any later pick, decline or mode change occurs. Happy to do it as its own PR rather than widening this one.

@danielhanchen

Copy link
Copy Markdown
Member

Merged current main in (60 commits of drift) to get a fresh verdict on Source lint (Python + shell + YAML + JSON + safety nets), which was stuck red with no way to re-run it.

Why a merge rather than a code change: that check cannot have been failing on this PR's content. The diff is exactly three frontend files (audio-page.tsx, chat/index.ts, audio-model-eject.test.ts) and no Python at all, and the repo-wide scripts/run_ruff_format.py --check passes on the branch. The failing run is from yesterday (2026-08-17 12:19Z to 14:56Z), its logs are 410-expired, the job API reports no failing step - so the failure was at the job level rather than in a lint rule - and gh run rerun refuses it with "its workflow file may be broken". Earlier in this batch the same check failed on #9093, #9095 and #9097 simultaneously with gh: Not Found (HTTP 404) while computing a merge base inside the verify_import_hoist safety net, which is the shape this fits.

So the push is the remedy: it dispatches a current run and clears the drift at the same time. A merge, not a rebase, so nobody's local copy breaks. No conflicts.

After the merge: typecheck clean, frontend suite 3781 passed with one failure, tests/queued-model-capabilities.test.ts, which is red on main today for an unrelated extensionless import that #9192 fixes.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91071e1c73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1387 to +1390
if (!stopDecision.proceed) {
setBusy(null);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-check Audio activity before ejecting

If the user invokes Speak eject, navigates away with the root-level confirmation still open (for example via Cmd/Ctrl+Shift+O), and then confirms, the hidden Audio page proceeds to invalidate its staged load and unload the shared model. The deactivation effect cannot cancel this path because it only handles pendingTtsLoad, so the newly visible page unexpectedly loses its model; mirror the load path's activeRef.current check after the confirmation before performing teardown.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a bug here. The user clicked Eject and then confirmed stopping the running chats, so the unload is exactly the action that was asked for, and the lifecycle lease is held across the dialog, which means no other surface can swap active_model in that window and the eject cannot hit the wrong model. Cmd/Ctrl+Shift+O opens an empty new chat with nothing generating, so nothing is lost by the confirmed unload landing after the navigation.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

loadModel prepares the stored Hugging Face token before it sends /load, and
returns without sending when that token is invalid and the user picks replace
or dismisses the warning. Cancelling the accepted sends and queued prompts
before that call discarded them for a swap that never happened, leaving the
previous model resident and the work gone. Move both cancellations into the
existing onRequestStart callback, which runs only once the request is issued.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from shimmyshimmer Aug 19, 2026
@unslothai unslothai deleted a comment from shimmyshimmer Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

@danielhanchen
danielhanchen merged commit 489caae into main Aug 19, 2026
34 of 35 checks passed
@danielhanchen
danielhanchen deleted the studio-audio-swap-confirm branch August 19, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants