Fix a 500 on dictation Unload before any backend is resident by danielhanchen · Pull Request #9013 · unslothai/unsloth · GitHub
Skip to content

Fix a 500 on dictation Unload before any backend is resident - #9013

Merged
danielhanchen merged 4 commits into
mainfrom
fix-stt-unload-no-backend
Aug 17, 2026
Merged

Fix a 500 on dictation Unload before any backend is resident#9013
danielhanchen merged 4 commits into
mainfrom
fix-stt-unload-no-backend

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

Unload for dictation raises TypeError and the route answers 500 whenever no inference backend is resident, which is the state a freshly started process is in.

Reproduced outside pytest, on main:

peek_inference_backend() fresh process: None
stt_unload -> TypeError: unload() takes from 0 to 1 positional arguments but 2 were given

Cause

stt_unload passes expected_model positionally:

_, unload_stt = _stt_lifecycle()
failed = await asyncio.to_thread(unload_stt, engines, model)

_stt_lifecycle() returns two different callables:

def unload(engines = None, *, wait = True, expected_model = None)          # stt_registry
def unload_stt_model(self, engines = None, expected_model = None)          # orchestrator

Only the orchestrator's takes expected_model positionally. On the registry it sits behind a *. The registry branch is the one taken when peek_inference_backend() is None, so the call blows up exactly when nothing has loaded yet.

Introduced in #7984 (d20db3f1f), which added both signatures.

Fix

Pass it by keyword. Both callables accept that, so the call is correct on either branch.

Why the tests did not catch it

They were order dependent, and that is the interesting part.

The two existing unload tests in test_stt_ggml_sidecar.py fail standalone and fail in their own file. They only went green inside the full serial run, because an earlier test in another file had left a backend resident, which routed the call down the orchestrator branch where positional works.

before, file alone:  2 failed, 53 passed
after,  file alone:  56 passed

So the order dependence was not a nuisance, it was hiding a live bug.

The new test drives the no-backend path directly, stubbing peek_inference_backend to None and asserting the registry receives expected_model, so neither the signature nor the call site can drift back.

Verification

Mutation checked. Reverting the one-line fix while keeping the new test reproduces the production error:

E  TypeError: unload() takes from 0 to 1 positional arguments but 2 were given

Full backend suite on a CPU-only shape, same command the workflow runs:

result
before 40 failed, 26149 passed
after 38 failed, 26152 passed

The 2 that flip are exactly these unload tests. The remaining 38 are pre-existing and unrelated to this change (missing optional rag / video / diffusion extras and network-blocked probes in my local venv).

Robustification

The fix is one keyword, but the shape that produced it will recur: _stt_lifecycle hands back two different callables and the route has one call site, so which signature must be satisfied depends on whether a backend happens to be resident.

A second test binds both real signatures against the arguments the route actually passes. It does not demand they be identical, only that one call site can serve both. Checked against two mutations:

mutation result
registry grows a keyword-only param passes (benign, and it should)
registry drops expected_model failsgot an unexpected keyword argument 'expected_model'

Paired with the first test, which covers the call site on a cold process, the two cover both directions of the drift.

Related

This PR's root cause, #7984 (d20db3f1f), also accounts for two other order-masked defects I hit while profiling: the disconnect-cancellation test that never stubs the implicit registry load (#9031), and the two test_stt_ggml_sidecar.py unload tests noted above. Three defects in one area, each invisible because a serial run left the right state behind.

Found while profiling CI runtime, not by a failing check.

stt_unload passes expected_model positionally:

    _, unload_stt = _stt_lifecycle()
    failed = await asyncio.to_thread(unload_stt, engines, model)

_stt_lifecycle() returns the orchestrator's unload_stt_model when a backend is
resident and stt_registry.unload when one is not. Only the first takes
expected_model positionally; on the registry it sits behind a `*`:

    def unload(engines = None, *, wait = True, expected_model = None)
    def unload_stt_model(self, engines = None, expected_model = None)

So with nothing loaded yet, which is what a fresh process is, Unload raises
TypeError and the route answers 500. Reproduced outside pytest:

    peek_inference_backend() fresh process: None
    stt_unload -> TypeError: unload() takes from 0 to 1 positional arguments
                             but 2 were given

Pass it by keyword, which both callables accept.

Found while profiling CI, not by the tests, and the tests are why: this file's
two unload tests passed only because an earlier test in the full suite had left
a backend resident. Standalone they failed, and in their own file they failed;
they went green only inside the full serial run. That order dependence hid a
live bug. The new test drives the no-backend path directly, so neither the
signature nor the call site can drift back.

    before: 2 failed, 53 passed   (file alone)
    after:  56 passed
danielhanchen added a commit that referenced this pull request Aug 17, 2026
test_disconnected_raw_transcription_cancels_its_sidecar passes in its file and
fails on its own, on any machine that has not downloaded an STT model:

  assert raised.value.status_code == 499
  E assert 409 == 499
  E  + where 409 = HTTPException(status_code=409, detail="STT model 'small' is
       not downloaded. Download it in Settings, then Voice, before loading it.")

Introduced by #7984 (d20db3f, 2026-08-11), which added this test AND the
implicit registry load in _transcribe_audio_result it fails on. The test was
incomplete from birth: it stubs _stt_sidecar_for and _resolve_serving_stt_engine
but not the load, so the disconnect it exists to check is only reached when an
earlier test left an engine resident or the host has the model cached.

The sibling test directly above it, added by the same PR, does stub
_stt_lifecycle. This does the same. Cancellation, not download state, is the
subject.

Worth noting that #7984 is the same PR behind the stt_unload 500 (#9013) and the
two test_stt_ggml_sidecar unload tests that fail standalone: three defects in one
area, each invisible because a serial run happened to leave the right state
behind.

Robustification: the stub now records what it was asked to load, and the test
asserts it. Otherwise, if the route ever stops loading through the registry, the
stub becomes dead code and the test stays green for a path it no longer covers.
Checked by mutating the route to skip the load:

  the transcribe path did not load through the registry: []

Verified by forcing the no-snapshot condition:

  main, test alone:  1 failed (409 == 499)
  main, whole file:  9 passed   <- the order dependency
  fixed, no snapshot: 9 passed
  fixed, cached host: 9 passed
The bug fixed in the previous commit exists because _stt_lifecycle returns two
different callables and the route has a single call site:

  def unload(engines = None, *, wait = True, expected_model = None)   # stt_registry
  def unload_stt_model(self, engines = None, expected_model = None)   # orchestrator

A call that suits one is a TypeError on the other, and which one runs depends on
whether a backend happens to be resident, so the broken half only appears on a
fresh process. That will recur the next time either signature is edited.

This binds BOTH real signatures against the arguments the route actually passes.
It does not demand they be identical, only that one call site can serve both.

Checked against two mutations:

  registry grows a keyword-only param  -> passes (benign, and it should)
  registry drops expected_model        -> fails, "got an unexpected keyword
                                          argument 'expected_model'"

Paired with the test beside it: that one covers the call site (route -> registry
on a cold process), this one covers the two callees staying compatible.

57 passed.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 976870171c

ℹ️ 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".

@danielhanchen

Copy link
Copy Markdown
Member Author

@danielhanchen
danielhanchen merged commit b191e7c into main Aug 17, 2026
47 of 50 checks passed
@danielhanchen
danielhanchen deleted the fix-stt-unload-no-backend branch August 17, 2026 04:43
@danielhanchen
danielhanchen restored the fix-stt-unload-no-backend branch August 17, 2026 04:43
@danielhanchen
danielhanchen deleted the fix-stt-unload-no-backend branch August 17, 2026 04:44
danielhanchen added a commit that referenced this pull request Aug 17, 2026
test_disconnected_raw_transcription_cancels_its_sidecar passes in its file and
fails on its own, on any machine that has not downloaded an STT model:

  assert raised.value.status_code == 499
  E assert 409 == 499
  E  + where 409 = HTTPException(status_code=409, detail="STT model 'small' is
       not downloaded. Download it in Settings, then Voice, before loading it.")

Introduced by #7984 (d20db3f, 2026-08-11), which added this test AND the
implicit registry load in _transcribe_audio_result it fails on. The test was
incomplete from birth: it stubs _stt_sidecar_for and _resolve_serving_stt_engine
but not the load, so the disconnect it exists to check is only reached when an
earlier test left an engine resident or the host has the model cached.

The sibling test directly above it, added by the same PR, does stub
_stt_lifecycle. This does the same. Cancellation, not download state, is the
subject.

Worth noting that #7984 is the same PR behind the stt_unload 500 (#9013) and the
two test_stt_ggml_sidecar unload tests that fail standalone: three defects in one
area, each invisible because a serial run happened to leave the right state
behind.

Robustification: the stub now records what it was asked to load, and the test
asserts it. Otherwise, if the route ever stops loading through the registry, the
stub becomes dead code and the test stays green for a path it no longer covers.
Checked by mutating the route to skip the load:

  the transcribe path did not load through the registry: []

Verified by forcing the no-snapshot condition:

  main, test alone:  1 failed (409 == 499)
  main, whole file:  9 passed   <- the order dependency
  fixed, no snapshot: 9 passed
  fixed, cached host: 9 passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant