Merge branch 'codex/review-oom-order' into codex/pr-queue-integration

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-07 11:10:11 +05:30
3 changed files with 121 additions and 5 deletions
+1
View File
@@ -24,6 +24,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
- 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
+43 -5
View File
@@ -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:
+77
View File
@@ -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(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 == [1], "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