Files
VoiceStudio/tests/test_generation_audio_guard.py
T
6e600c48cb fix(generation): classify network/download failures — stop mislabeling every unknown error as OOM (#880) (#893)
A kittentts first-use HuggingFace download died with httpx's "Cannot send
a request, as the client has been closed", and the generation error
classifier's catch-all fallback told the user (CPU-only ~80 MB ONNX engine,
12 GB-VRAM box) they were OUT OF MEMORY and to press Flush — the wrong
remedy for a network failure.

Three-part class fix:

- generation.py: new #880 branch (before the OOM hint) classifies
  httpx/requests transport failures — matched over the whole exception
  chain (type names like ConnectError/ReadTimeout plus stringified
  signatures like "client has been closed") — as a download/network
  problem with a retry/check-connection remedy.
- generation.py (the real class bug): the OOM hint is no longer the
  catch-all. It now requires an actual OOM signature (typed
  OutOfMemoryError/MemoryError anywhere in the chain, or CUDA/MPS/CPU
  allocator wording); genuinely unknown errors surface as unrecognized
  with the underlying detail instead of a false "ran out of memory".
- tts_backend.py: KittenTTS's first-use load retries exactly once with a
  fresh HF Hub client (huggingface_hub.utils.close_session()) on the
  specific closed-client failure — hub ≥1.x shares one global httpx
  client, and a closed one is recoverable, so the download self-heals
  instead of failing the generation.

Fail-before/pass-after tests: classifier (closed-client message, wrapped
httpx type names, unknown error, real OOM signatures incl. typed
OutOfMemoryError, WinError 1455) + the retry helper (recovers once,
walks the chain, no retry on unrelated errors, single-shot).

Fixes #880

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:38:02 +05:30

208 lines
8.4 KiB
Python

"""Generation audio guards (#629).
A numerical glitch (seen on MPS) could leave NaN/inf in the rendered audio,
which writes an unreadable WAV that then fails decoding with an opaque
"ffmpeg returned error code: 183 / Invalid data" — surfaced to the user as a
misleading "ran out of memory". Two guards: sanitize non-finite samples before
any encode, and classify a decode/ffmpeg failure as unreadable-audio (not OOM).
"""
import os
import sys
import pytest
import torch
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
from api.routers.generation import _sanitize_audio, _oom_friendly_reraise # noqa: E402
def test_sanitize_replaces_non_finite_with_silence():
t = torch.tensor([0.1, float("nan"), float("inf"), -float("inf"), 0.2])
out = _sanitize_audio(t)
assert torch.isfinite(out).all()
assert out[0].item() == pytest.approx(0.1)
assert out[1].item() == 0.0 and out[2].item() == 0.0 and out[3].item() == 0.0
def test_sanitize_leaves_finite_audio_unchanged():
t = torch.tensor([0.0, 0.5, -0.5, 0.25])
out = _sanitize_audio(t)
assert torch.equal(out, t)
def test_sanitize_passes_through_non_tensor():
assert _sanitize_audio(None) is None
obj = object()
assert _sanitize_audio(obj) is obj
def test_ffmpeg_decode_failure_is_not_labelled_oom():
err = RuntimeError(
"Decoding failed. ffmpeg returned error code: 183\n"
"Invalid data found when processing input"
)
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "unreadable audio" in msg
assert "out of memory" not in msg
def test_generic_failure_still_uses_oom_hint():
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(RuntimeError("CUDA error: out of memory"))
assert "ran out of memory" in str(ei.value)
def test_httpx_closed_client_is_a_download_failure_not_oom():
# #880: kittentts's first-use HF download died with httpx's closed-client
# lifecycle error, and the OOM catch-all told a user running a CPU-only
# ~80 MB ONNX engine on a 12 GB-VRAM box to press Flush. It's a network
# failure — say so, and don't send them to the Flush button.
err = RuntimeError("Cannot send a request, as the client has been closed.")
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "network" in msg
assert "download" in msg
assert "Retry" in msg
assert "client has been closed" in msg # underlying detail preserved
assert "ran out of memory" not in msg
assert "Try the Flush button" not in msg
@pytest.mark.parametrize("exc_name", ["ConnectError", "ReadTimeout"])
def test_httpx_transport_error_in_chain_is_a_download_failure(exc_name):
# #880: engines wrap the original httpx error, so classification must
# look at exception TYPE NAMES anywhere in the chain, not just the
# outermost message (which here carries no network signature at all).
fake_httpx_exc = type(exc_name, (Exception,), {})
try:
try:
raise fake_httpx_exc("")
except Exception as inner:
raise RuntimeError("model load failed") from inner
except RuntimeError as wrapped:
err = wrapped
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "network" in msg
assert "ran out of memory" not in msg
assert "Try the Flush button" not in msg
def test_unknown_error_is_not_labelled_oom():
# #880 (the class bug): the OOM hint was the catch-all fallback, so ANY
# unrecognized error claimed "ran out of memory" + Flush. A genuinely
# unknown error must surface as unknown, detail intact.
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(RuntimeError("segfault in frobnicator: code 7"))
msg = str(ei.value)
assert "segfault in frobnicator: code 7" in msg
assert "ran out of memory" not in msg
assert "Try the Flush button" not in msg
@pytest.mark.parametrize("reason", [
"CUDA out of memory. Tried to allocate 20.00 MiB",
"MPS backend out of memory (MPS allocated: 8.00 GB)",
"DefaultCPUAllocator: not enough memory: you tried to allocate 1073741824 bytes",
"[enforce fail at alloc_cpu.cpp] posix_memalign. Cannot allocate memory",
"[WinError 1455] The paging file is too small for this operation to complete",
])
def test_real_oom_signatures_still_classify_as_oom(reason):
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(RuntimeError(reason))
assert "ran out of memory" in str(ei.value)
assert "Try the Flush button" in str(ei.value)
def test_typed_oom_without_oom_message_still_classifies_as_oom():
# torch.cuda.OutOfMemoryError can carry an opaque allocator message; the
# tightened OOM branch must also match the exception type name.
fake_torch_oom = type("OutOfMemoryError", (RuntimeError,), {})
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(fake_torch_oom("CUBLAS workspace reservation failed"))
assert "ran out of memory" in str(ei.value)
def test_unsupported_instruct_is_a_validation_error_not_oom():
# #664: free-form prose in the instruct field must surface as a 400-mapped
# ValueError with the instruct guidance — NOT a 500 "ran out of memory".
err = ValueError(
"Unsupported instruct items found in Speak with high energy:\n"
" 'Speak with high energy' -> 'speak with high energy' (unsupported)\n\n"
"Valid English items: male, whisper, ..."
)
with pytest.raises(ValueError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "Unsupported instruct items" in msg
assert "ran out of memory" not in msg
def test_instruct_error_wrapped_in_runtimeerror_is_still_validation():
# A lower layer can wrap the original ValueError; we must classify on the
# message signature, not the type, so the route still returns a clean 400.
err = RuntimeError(
"model.generate failed: Conflicting instruct items within the same "
"category: 'male' vs 'female'."
)
with pytest.raises(ValueError) as ei:
_oom_friendly_reraise(err)
assert "Conflicting instruct items" in str(ei.value)
assert "ran out of memory" not in str(ei.value)
def test_broken_pipe_is_a_lost_pipe_not_oom():
# #715: a "[Errno 32] Broken pipe" surfacing from generation means the
# backend's stdout/stderr pipe to the desktop shell closed mid-render (an
# orphaned/relaunched backend) — NOT out of memory. Telling the user to
# press Flush for memory they never ran out of is the wrong next step;
# restarting the app re-parents the backend. Covers both the typed
# BrokenPipeError and a string-wrapped "[Errno 32] Broken pipe".
for err in (
BrokenPipeError(32, "Broken pipe"),
RuntimeError("model.generate failed: [Errno 32] Broken pipe"),
):
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "pipe" in msg.lower()
assert "Restart the app" in msg
assert "ran out of memory" not in msg
def test_no_kernel_image_is_an_unsupported_gpu_not_oom():
# #756: a GPU whose compute capability isn't in the torch build's arch list
# (Pascal sm_61 on new wheels, Blackwell sm_120 on old wheels) raises "CUDA
# error: no kernel image is available for execution". That's NOT OOM and Flush
# won't help — point at CPU / a matching torch.
err = RuntimeError(
"CUDA error: no kernel image is available for execution on the device"
)
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "GPU isn't supported" in msg or "isn't supported by the installed" in msg
assert "CPU" in msg
assert "ran out of memory" not in msg
def test_winerror_193_is_a_corrupt_binary_not_oom():
# #705: a corrupt / wrong-architecture native component (torch, ffmpeg, an
# engine binary) fails on Windows with "[WinError 193] %1 is not a valid
# Win32 application". That is NOT OOM and Flush won't help — say so.
err = RuntimeError(
"TTS engine stopped mid-generation: [WinError 193] %1 is not a valid "
"Win32 application"
)
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "WinError 193" in msg
assert "corrupt" in msg or "wrong architecture" in msg
assert "ran out of memory" not in msg