Skip to content

fix(tts): stop stranding the TTS model on CPU after a dub abort (#1191) - #1204

Merged
debpalash merged 5 commits into
mainfrom
fix/1191-tts-stranded-on-cpu
Jul 20, 2026
Merged

fix(tts): stop stranding the TTS model on CPU after a dub abort (#1191)#1204
debpalash merged 5 commits into
mainfrom
fix/1191-tts-stranded-on-cpu

Conversation

@debpalash

@debpalash debpalash commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Fixes #1191 (mis-framed as "speed varies by time of day" — the bug is deterministic).

offload_tts_for_asr() moves the TTS model to CPU for ASR headroom, but restore_tts_after_asr() was only reachable on the dub-transcribe success path — so any abort/error/disconnect left it CPU-resident and every later /generate ran on CPU until the ~15-min idle unload fired.

  • Balanced pair: gen()'s finally pays the restore debt on every exit path, chained off the ASR unload so the two never contend for VRAM.
  • Class fix: get_model() self-heals placement (one parameter probe on the hot path; unified memory exempt), so a future unbalanced offload can't strand it either.
  • Tests: 12 new (11 fail before, all pass after). Full suite 3394 passed + backend/tests 188 passed. Docs + CHANGELOG updated.

🤖 Generated with Claude Code

Ensures the TTS model is returned to the correct accelerator after dub generation/transcribe streaming on every exit path (success, abort/error, or client disconnect) by paying “restore debt” in a finally block, and further prevents reoccurrence by having get_model() verify/repair TTS device placement before returning a loaded model (while keeping unified-memory exempt). This fixes the #1191 slowdown where the TTS could be stranded on CPU after an interrupted workflow, causing persistent 10–50x slower generation until restart, and adds regression coverage (12 tests) plus documentation/changelog updates. Human review is most important around the async restore ordering vs ASR unload and ensuring the locking/device-move logic can’t race or deadlock under concurrent model/device mutations (including GPU-pool-thread scenarios).

`offload_tts_for_asr()` moves the TTS model to CPU to make VRAM room for
WhisperX, but its partner `restore_tts_after_asr()` was only reachable on
the dub-transcribe success path. Any abort, terminal error, or client
disconnect skipped it, and `get_model()` never re-checked placement — so
EVERY subsequent /generate ran on CPU (10-50x slower, CPU pegged) until
the ~15-minute idle unload happened to fire. Reported as "speed varies by
time of day"; it is fully deterministic.

Two independent guarantees:

- Balance the pair at the call site: gen()'s `finally` now pays the
  restore debt on every exit path, chained off the ASR unload so the two
  never contend for VRAM (and fire-and-forget, since the finally also
  runs under GeneratorExit where awaiting is illegal).
- Self-heal placement (the class fix): `get_model()` verifies the model
  is on the resolved target device and moves it back if not, so a future
  unbalanced offload path cannot strand it either. Cheapest-first probe —
  one parameter check on the hot path; unified memory is exempt (its
  offload releases the model rather than moving it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread backend/api/routers/dub_core.py Fixed
Comment thread backend/api/routers/dub_core.py Fixed
@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the deterministic post-abort TTS CPU-stranding bug (#1191): offload_tts_for_asr moved the TTS model to CPU for ASR headroom, but restore_tts_after_asr was only reachable on the success path, leaving the model CPU-resident (10–50x slower) after any abort, error, or disconnect until the idle unload fired.

  • Balanced pair in finally: gen()'s finally now tracks a _tts_offloaded sentinel and chains the restore off the ASR unload's done-callback, preserving VRAM ordering on every exit path including GeneratorExit.
  • Self-heal in get_model(): _heal_tts_placement() probes the first model parameter on each hot-path return and moves the model back to the accelerator if stranded; GPU-pool-thread re-entrancy is short-circuited with an inline move to avoid deadlock.
  • Tests: 12 new regression tests covering abort/crash/success SSE paths, the GPU-pool-thread deadlock case, OOM-during-heal graceful degradation, and unified-memory exemption.

Important Files Changed

Filename Overview
backend/api/routers/dub_core.py Adds _tts_offloaded sentinel and _restore_tts_bg helper; gen()'s finally now pays the TTS-restore debt on every exit path (abort, error, disconnect) by chaining the restore off the ASR unload callback to preserve VRAM ordering.
backend/services/model_manager.py Adds placement self-heal: _first_param_device, _stranded_tts_target, ensure_tts_on_device, and _heal_tts_placement are new; get_model() calls _heal_tts_placement() on every hot-path return, with GPU-pool-thread deadlock prevention and correct 1-worker mutual exclusion reasoning.
tests/test_tts_placement_self_heal.py 12 new regression tests covering: probe hot/cold/unified-memory paths, self-heal success/noop/OOM-failure, get_model() heal-before-return, GPU-pool-thread deadlock prevention, and balanced-pair end-to-end through abort/crash/success SSE paths.
docs/performance.md Adds item #5 to the "classic causes of slowness" list, explaining the v0.3.23 fix and advising users on older builds to restart the backend.
CHANGELOG.md Single one-liner under ### Fixed referencing #1191, consistent with house-rule changelog style.

Reviews (4): Last reviewed commit: "fix: strip leftover conflict markers fro..." | Re-trigger Greptile

CodeQL flagged the `return` inside the finally block ('break'/'return'
in finally swallows in-flight exceptions). The returns lived in a nested
def so nothing was actually swallowed, but the pattern is worth avoiding
outright: the dispatch helpers now live at endpoint scope and the finally
holds straight-line control flow only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change restores offloaded TTS models on every transcription-stream exit path, adds placement self-healing before subsequent model use, and adds regression tests for device detection, recovery, and successful or failed SSE flows.

Changes

TTS placement recovery

Layer / File(s) Summary
TTS placement self-healing
backend/services/model_manager.py, tests/test_tts_placement_self_heal.py
The model manager detects CPU-stranded TTS instances, restores them to the target accelerator under locking, and tests stranded, healthy, unsupported, and failed-move cases.
Streaming restore-debt handling
backend/api/routers/dub_core.py, tests/test_tts_placement_self_heal.py
The SSE transcription flow tracks successful offloads and restores TTS exactly once after normal, aborted, failed, or disconnected execution, with ASR unload ordering and regression coverage.
Release notes and performance documentation
CHANGELOG.md, docs/performance.md
The changelog and performance guide document the CPU-stranding failure and its restoration and placement-verification fix.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 7 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The body is freeform and omits the template's Summary, Changes, Type, Testing, and Checklist sections. Reformat the PR description to follow the template and fill each required section, especially Testing and Checklist.
Docstring Coverage ⚠️ Warning Docstring coverage is 34.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (7 passed)
Check name Status Explanation
Title check ✅ Passed Conventional commit scope and #1191 reference match the PR's TTS placement fix.
Linked Issues check ✅ Passed The code restores and self-heals TTS placement after aborts and disconnects, matching #1191's slowdown fix.
Out of Scope Changes check ✅ Passed The docs, changelog, and tests all support the TTS placement fix and do not introduce unrelated behavior.
Cross-Platform Default Parity ✅ Passed No platform-divergent default: _stranded_tts_target() bails unless _has_dedicated_vram() and target is cuda/xpu, so MPS/unified-memory paths are explicit no-ops.
I18n Completeness (21 Locales) ✅ Passed No frontend files changed in this PR, so there are no new/changed t() keys or hardcoded UI strings to audit.
Local-First Guarantee ✅ Passed The new paths only inspect/move local model weights via .to() and executors; no new HTTP/auth/telemetry code was added, and HF download logic is unchanged.
Backward Compatibility ✅ Passed No DB/schema ops or new migrations; model code only repositions loaded weights in memory and never forces reinstall/redownload.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/services/model_manager.py (1)

1285-1297: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

backend/services/model_manager.py:1285-1297 — get_model() rereads the global after await _heal_tts_placement(), so idle_worker() can unload the model in between and this branch returns None despite starting with a live instance. Capture the model before the await or re-check under _model_lock before returning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/model_manager.py` around lines 1285 - 1297, The get_model()
branch can return None if idle_worker() unloads the global model during the
placement-healing await. Capture the existing model instance before awaiting
_heal_tts_placement(), then return that captured instance, or re-check under
_model_lock before returning while preserving the placement-healing behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/services/model_manager.py`:
- Around line 1689-1712: Update ensure_tts_on_device so the model transfer and
related cleanup run through the same lock or serialized execution path used by
model.generate(), preventing concurrent requests from accessing the shared model
during m.to(target). If the existing generation path cannot be reused directly,
defer the self-heal until the model is idle while preserving the current
no-raise and boolean return behavior.

In `@tests/test_tts_placement_self_heal.py`:
- Line 39: Move the module-level services.model_manager import into the fixture
or test function that uses mm, resolving it at runtime; preserve the existing
import alias and update any affected references without changing test behavior.

---

Outside diff comments:
In `@backend/services/model_manager.py`:
- Around line 1285-1297: The get_model() branch can return None if idle_worker()
unloads the global model during the placement-healing await. Capture the
existing model instance before awaiting _heal_tts_placement(), then return that
captured instance, or re-check under _model_lock before returning while
preserving the placement-healing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e27135e7-dc09-4b54-bedb-0849bd214c15

📥 Commits

Reviewing files that changed from the base of the PR and between 6750c59 and 0f70744.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • backend/api/routers/dub_core.py
  • backend/services/model_manager.py
  • docs/performance.md
  • tests/test_tts_placement_self_heal.py

Comment thread backend/services/model_manager.py
Comment thread tests/test_tts_placement_self_heal.py Outdated
debpalash and others added 3 commits July 20, 2026 14:40
CodeRabbit (PR #1204): the self-heal moved the shared model on the CPU
pool, so a concurrent generate() could hit the instance mid-transfer.
Dispatch the move on the GPU pool instead — the hosts that can strand a
model are always 1-worker (offload only fires below 8 GB free VRAM), so
occupying a slot is real mutual exclusion.

Deadlock guard: OmniVoiceBackend._ensure_loaded() reaches get_model()
via asyncio.run() from inside generate(), already on a GPU-pool worker.
Dispatching back into that pool (or blocking on the model lock held by
the loop waiting on us) would deadlock, so that case heals inline —
it already owns the GPU slot. Pinned by a regression test.

Also resolve the test module's app import at run time (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/services/model_manager.py (1)

1997-2052: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Serialize all TTS placement moves
offload_tts_for_asr()/restore_tts_after_asr() can still race ensure_tts_on_device() on the same shared model object here; _model_lock only covers the non-inline heal path, so concurrent model.to("cpu") and m.to(target) calls are still possible. Route all placement moves through one shared lock or executor so only one transfer runs at a time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/model_manager.py` around lines 1997 - 2052, The TTS
placement operations can race because ensure_tts_on_device() bypasses
_model_lock on the GPU-pool path while offload_tts_for_asr() and
restore_tts_after_asr() move the same model. Route every model.to placement
operation, including those helpers and the inline heal path, through one shared
synchronization mechanism so transfers are serialized without introducing a
self-deadlock.

Source: Path instructions

🧹 Nitpick comments (1)
backend/services/model_manager.py (1)

2035-2042: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Deadlock avoidance depends on a thread-name convention.

threading.current_thread().name.startswith("gpu-pool") is the only signal preventing the pool from being submitted-into from within itself. If _build_gpu_pool's thread_name_prefix ever changes, this silently reintroduces the deadlock it's meant to avoid. A thread-local sentinel set when entering the pool worker would be more robust than name-sniffing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/model_manager.py` around lines 2035 - 2042, Replace the
thread-name check in the inline move path with a thread-local sentinel that is
set when entering and cleared when leaving each _build_gpu_pool worker. Use that
sentinel in the surrounding ensure_tts_on_device flow to detect pool re-entry,
preserving inline execution and return behavior while allowing
thread_name_prefix to change safely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@backend/services/model_manager.py`:
- Around line 1997-2052: The TTS placement operations can race because
ensure_tts_on_device() bypasses _model_lock on the GPU-pool path while
offload_tts_for_asr() and restore_tts_after_asr() move the same model. Route
every model.to placement operation, including those helpers and the inline heal
path, through one shared synchronization mechanism so transfers are serialized
without introducing a self-deadlock.

---

Nitpick comments:
In `@backend/services/model_manager.py`:
- Around line 2035-2042: Replace the thread-name check in the inline move path
with a thread-local sentinel that is set when entering and cleared when leaving
each _build_gpu_pool worker. Use that sentinel in the surrounding
ensure_tts_on_device flow to detect pool re-entry, preserving inline execution
and return behavior while allowing thread_name_prefix to change safely.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f93b30df-af62-43f0-a65a-f678b53554bc

📥 Commits

Reviewing files that changed from the base of the PR and between 220bfd0 and 15d6dc8.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • backend/services/model_manager.py
  • docs/performance.md
  • tests/test_tts_placement_self_heal.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • CHANGELOG.md
  • docs/performance.md

@debpalash
debpalash merged commit df3cf3e into main Jul 20, 2026
16 checks passed
@debpalash
debpalash deleted the fix/1191-tts-stranded-on-cpu branch July 20, 2026 10:38
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.

[Bug] Generation speed varies greatly depending on the time of day (v0.3.22)

2 participants