From 6a2ada2f113ad480325a366d6c65c815cf935a36 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:11:20 +0530 Subject: [PATCH 1/2] fix(tts): don't answer a GPU OOM by repeating the same allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_get_clone_prompt` catches everything and returns None so synthesis falls back to `generate()`'s inline reference path. For a device OOM that is not a fallback at all: the inline path runs the SAME encode on the SAME device — producing identical output is the entire point of the precompute — so it is guaranteed to hit the same wall moments later, on a GPU with even less headroom than the first attempt found. Two reporters' backends died with a Windows access violation (exit code -1073741819) seconds after this fallback logged, mid-generation, on a card that had just refused an 86 MiB allocation. An OOM here is also the most recoverable kind. The allocator is typically sitting on reserved-but-unallocated blocks — #1790's own log reports 90 MiB reserved against that 86 MiB request — so drop them and try once more. If it still will not fit, raise: the failure layer turns a device OOM into "close other GPU-heavy apps or unload models, then retry", which is a far better answer than walking into a native fault. Every other failure still falls back silently, since for a non-memory fault the inline path may genuinely succeed. Fixes #1790. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF --- CHANGELOG.md | 1 + backend/services/tts_backend.py | 48 +++++++++++++++++--- tests/test_clone_prompt_cache.py | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf42b70..9fc567ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- A GPU out-of-memory error while preparing a voice-clone reference no longer falls back to repeating the identical allocation, which could take the backend down with a native crash — the app now frees the allocator's reserved memory and retries once, then reports the actionable out-of-memory message if it still will not fit (#1790, #1777) - The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787) - Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783) - Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781) diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index f84bb406..2d32d78b 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -566,7 +566,12 @@ def _get_clone_prompt( ): """Return a cached/precomputed ``VoiceClonePrompt`` for (ref_audio, ref_text, preprocess_prompt), or ``None`` to fall back to the - inline ref path. Never raises. + inline ref path. + + Raises only on a device OOM that survives a cache-drop retry (#1790): the + inline path is the same allocation on the same device, so falling back to + it after an OOM cannot succeed and has been observed taking the whole + process down instead. Every other failure still falls back silently. ``store=False`` still *reads* the cache (a hit is free) but never inserts: it exists for single-use references — a dub's per-segment ref clips are each @@ -596,10 +601,43 @@ def _get_clone_prompt( ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt ) except Exception as e: # noqa: BLE001 — fall back, never break synthesis - logger.warning( - "voice-clone prompt precompute failed; using inline ref: %s", e - ) - return None + # #1790/#1777: a GPU OOM is the one failure this fallback cannot + # absorb. `generate()`'s inline ref path runs the SAME encode on the + # SAME device — the docstring above says so, because producing + # identical output is the point — so returning None after an OOM + # guarantees a second OOM moments later, on a device with even less + # headroom than the first attempt found. Both reporters' backends + # then died with a Windows access violation (exit code + # -1073741819) seconds after this exact log line, mid-generation on + # a GPU that had just refused an 86 MiB allocation. + # + # An OOM here is also the most recoverable kind: the allocator is + # typically holding reserved-but-unallocated blocks (#1790's own + # log reports 90 MiB reserved against an 86 MiB request). Drop them + # and try once more. If it still will not fit, raise — the failure + # layer turns a device OOM into the actionable GPU_OOM message + # ("close other GPU-heavy apps or unload models…"), which is a far + # better answer than walking into a native fault. + from core.failure import is_gpu_oom + + if is_gpu_oom(e): + logger.warning( + "voice-clone prompt precompute hit a device OOM (%s) — " + "releasing allocator caches and retrying once", e, + ) + try: + from services.model_manager import free_vram + free_vram() + except Exception: # noqa: BLE001 — reclaim is best-effort + logger.debug("VRAM reclaim before OOM retry failed", exc_info=True) + prompt = model.create_voice_clone_prompt( + ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt + ) + else: + logger.warning( + "voice-clone prompt precompute failed; using inline ref: %s", e + ) + return None if store: _prompt_disk_save(key, prompt) if not store: diff --git a/tests/test_clone_prompt_cache.py b/tests/test_clone_prompt_cache.py index 964be021..5d5a5bee 100644 --- a/tests/test_clone_prompt_cache.py +++ b/tests/test_clone_prompt_cache.py @@ -152,3 +152,80 @@ def test_single_use_flood_does_not_evict_reused_prompts(tmp_path): assert m.calls == before, ( "the speaker prompt was evicted by single-use segment refs and re-encoded" ) + + +# ── A device OOM is not a fallback-able failure (#1790/#1777) ─────────────── +# +# `generate()`'s inline ref path runs the SAME encode on the SAME device — the +# whole point of the precompute is that its output is identical — so returning +# None after an OOM guarantees a second OOM moments later, on a device with +# even less headroom than the first attempt found. Two reporters' backends died +# with a Windows access violation (exit code -1073741819) seconds after this +# fallback logged, mid-generation on a GPU that had just refused an 86 MiB +# allocation. Drop the allocator's reserved-but-unallocated blocks and retry +# once; if it still will not fit, raise so the failure layer can say +# "close other GPU-heavy apps or unload models" instead of walking into a +# native fault. + + +class _OomModel: + """Raises a torch-shaped OOM for the first `fails` calls, then succeeds.""" + + class OutOfMemoryError(RuntimeError): + pass + + def __init__(self, fails=1): + self.calls = 0 + self.fails = fails + + def create_voice_clone_prompt(self, ref_audio, ref_text=None, preprocess_prompt=True): + self.calls += 1 + if self.calls <= self.fails: + raise self.OutOfMemoryError( + "CUDA out of memory. Tried to allocate 86.00 MiB. GPU 0 has a " + "total capacity of 11.91 GiB of which 1.98 GiB is free." + ) + return f"PROMPT::{ref_audio}" + + +def test_oom_frees_vram_and_retries_once(tmp_path, monkeypatch): + freed = [] + monkeypatch.setattr( + "services.model_manager.free_vram", lambda: freed.append(True), raising=False + ) + m = _OomModel(fails=1) + got = tb._get_clone_prompt(m, _wav(tmp_path), "hello") + assert got == f"PROMPT::{_wav(tmp_path)}" + assert m.calls == 2, "the OOM must be retried, not fallen back from" + assert freed, "allocator caches must be dropped before the retry" + + +def test_oom_that_survives_the_retry_raises_instead_of_falling_back(tmp_path, monkeypatch): + monkeypatch.setattr("services.model_manager.free_vram", lambda: None, raising=False) + m = _OomModel(fails=2) + with pytest.raises(Exception) as excinfo: + tb._get_clone_prompt(m, _wav(tmp_path), "hello") + from core.failure import is_gpu_oom + + assert is_gpu_oom(excinfo.value), "the OOM must reach the failure layer intact" + assert m.calls == 2, "exactly one retry — not an unbounded loop" + + +def test_a_reclaim_failure_does_not_mask_the_retry(tmp_path, monkeypatch): + # Making room is best-effort: if it throws, the retry still happens, because + # the reason we are here is that falling back cannot work. + def _boom(): + raise RuntimeError("no allocator") + + monkeypatch.setattr("services.model_manager.free_vram", _boom, raising=False) + m = _OomModel(fails=1) + assert tb._get_clone_prompt(m, _wav(tmp_path), "hi") is not None + assert m.calls == 2 + + +def test_non_oom_failures_still_fall_back_silently(tmp_path): + # The existing contract for every other error is unchanged: None, so the + # caller uses the inline ref — which for a non-memory fault may well work. + m = _StubModel(fail=True) + assert tb._get_clone_prompt(m, _wav(tmp_path), "hello") is None + assert m.calls == 1 From 5d134f22ec5772faf730c35f0c5360ef1e42caee Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:59:09 +0530 Subject: [PATCH 2/2] test: enforce VRAM reclaim before clone prompt retry --- CHANGELOG.md | 2 +- tests/test_clone_prompt_cache.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d2c0e0..68e66277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811) ## [0.5.2] — 2026-09-02 @@ -60,7 +61,6 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed -- A GPU out-of-memory error while preparing a voice-clone reference no longer falls back to repeating the identical allocation, which could take the backend down with a native crash — the app now frees the allocator's reserved memory and retries once, then reports the actionable out-of-memory message if it still will not fit (#1790, #1777) - The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787) - Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783) - Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781) diff --git a/tests/test_clone_prompt_cache.py b/tests/test_clone_prompt_cache.py index 5d5a5bee..a5af5cb5 100644 --- a/tests/test_clone_prompt_cache.py +++ b/tests/test_clone_prompt_cache.py @@ -191,13 +191,13 @@ class _OomModel: def test_oom_frees_vram_and_retries_once(tmp_path, monkeypatch): freed = [] monkeypatch.setattr( - "services.model_manager.free_vram", lambda: freed.append(True), raising=False + "services.model_manager.free_vram", lambda: freed.append(m.calls), raising=False ) m = _OomModel(fails=1) got = tb._get_clone_prompt(m, _wav(tmp_path), "hello") assert got == f"PROMPT::{_wav(tmp_path)}" assert m.calls == 2, "the OOM must be retried, not fallen back from" - assert freed, "allocator caches must be dropped before the retry" + assert freed == [1], "allocator caches must be dropped before the retry" def test_oom_that_survives_the_retry_raises_instead_of_falling_back(tmp_path, monkeypatch):