Skip to content

fix(model): first-run model load/download never hangs forever (Windows demo-voice hang) - #173

Merged
debpalash merged 1 commit into
mainfrom
fix/windows-model-load-hang
May 30, 2026
Merged

fix(model): first-run model load/download never hangs forever (Windows demo-voice hang)#173
debpalash merged 1 commit into
mainfrom
fix/windows-model-load-hang

Conversation

@debpalash

@debpalash debpalash commented May 30, 2026

Copy link
Copy Markdown
Owner

Bug

Windows users report: install succeeds, app runs, but creating the demo voice runs indefinitely — spinner forever, no audio, no error toast.

Root cause

The first /generate calls OmniVoice.from_pretrained(), which downloads the multi-GB weights via the legacy LFS path (HF_HUB_DISABLE_XET=1, set globally) with no timeout anywhere. A stalled socket (corporate proxy / firewall / antivirus inspecting the large transfer) blocks the GPU-pool worker inside get_model()before the try/except that would surface an error — so it neither returns nor raises. The frontend /generate fetch (useTTS.js) had no abort/timeout, so the UI spun forever with no message.

Fix (platform-agnostic, defense-in-depth)

  • HF socket timeouts (backend/main.py): HF_HUB_ETAG_TIMEOUT=15 + HF_HUB_DOWNLOAD_TIMEOUT=30. The download timeout is per-read — it resets on every received chunk, so a slow-but-progressing download is never punished; only a genuinely dead socket trips it (→ raises → surfaces as an error). Set before huggingface_hub import.
  • Watchdog + pool reset (model_manager.py): get_model()/preload_model() load via _load_model_with_timeout(), an asyncio.wait_for backstop (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) that drops the poisoned single-worker GPU pool and raises a clear, actionable RuntimeError so a retry gets a fresh worker instead of queueing behind the wedged thread.
  • Frontend abort backstop (useTTS.js): AbortController on /generate so the UI never spins forever even if the backend is unreachable; friendly timeout toast.
  • Tests: watchdog raises + resets pool + releases lock; env/floor parsing.

Verify

Backend pytest tests/test_model_load_timeout.py (2) + test_model_manager_preload.py (3) pass; frontend typecheck/build clean, vitest 103/103, legacy 36/0. On Windows, simulate a stalled HF download (unreachable proxy) → within the read-timeout the request now fails with a clear toast and the spinner clears, instead of hanging.

Constraints: cross-platform parity (no OS-specific behavior), backward-compatible with already-installed models, local-first preserved.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved timeout handling to prevent indefinite hangs during model loading and text-to-speech operations.
    • Network operations now fail gracefully with timeout messages instead of freezing the application.
    • Added automatic recovery mechanisms for stalled operations.

Review Change Stack

…ever

Windows users reported 'create demo voice runs indefinitely, no audio, no
error'. Root cause: the first /generate triggers OmniVoice.from_pretrained()
which downloads multi-GB weights via the legacy LFS path (HF_HUB_DISABLE_XET=1),
with NO timeout anywhere. A stalled socket (proxy/firewall/AV) blocks the GPU-
pool worker forever inside get_model() -- before the try/except that would
surface an error -- and the frontend /generate fetch had no abort, so the
spinner spun forever with no toast.

- backend/main.py: set HF_HUB_ETAG_TIMEOUT=15 + HF_HUB_DOWNLOAD_TIMEOUT=30
  (per-read timeout: resets on each chunk, so slow-but-progressing downloads
  are never punished; only a dead socket trips it). Set before hf import.
- model_manager: get_model()/preload_model() now load via _load_model_with_timeout(),
  an asyncio.wait_for backstop (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) that
  drops the poisoned GPU pool and raises a clear, actionable RuntimeError so a
  retry gets a fresh worker instead of queueing behind the wedged one.
- useTTS.js: AbortController backstop on /generate so the UI never spins forever
  even if the backend is unreachable; friendly timeout toast.
- tests: watchdog raises + resets pool + releases lock; env/floor parsing.

Cross-platform (no OS-specific behavior); backward-compatible with installed
models; local-first preserved.

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

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR adds timeout enforcement and pool cleanup to prevent model loading and TTS requests from hanging indefinitely. Hugging Face Hub network timeouts are set at startup, model load operations run with deadline enforcement and GPU pool reset on timeout, and frontend TTS requests abort after 21 minutes with cleanup.

Changes

Hang Prevention Infrastructure

Layer / File(s) Summary
HF Hub network timeout initialization
backend/main.py
Sets HF_HUB_ETAG_TIMEOUT and HF_HUB_DOWNLOAD_TIMEOUT environment defaults before module initialization to prevent stalled network operations from hanging.
Model load timeout infrastructure
backend/services/model_manager.py
Adds _model_load_timeout() to compute load deadline with environment override and safety floor; _reset_gpu_pool() to clear the GPU executor singleton and cancel pending futures; and _load_model_with_timeout() to run the blocking loader under an async deadline, updating status and resetting the pool on timeout.
Model load integration with timeout
backend/services/model_manager.py
Updates get_model() and preload_model() to use _load_model_with_timeout() instead of direct executor-based loading, ensuring timeout and cleanup behavior applies to both eager and background loads.
Frontend TTS abort backstop
frontend/src/hooks/useTTS.js
Wraps generateSpeech call with AbortController and 21-minute timeout; handles AbortError with a dedicated user-facing timeout message; clears the abort timer in finally block.
Model load timeout regression tests
tests/test_model_load_timeout.py
Adds model_manager fixture for clean module isolation between tests. One test verifies _model_load_timeout() respects OMNIVOICE_MODEL_LOAD_TIMEOUT environment override, falls back on invalid values, and enforces a safety floor. Second test simulates wedged synchronous load, forces short timeout, asserts get_model() raises timeout RuntimeError, and verifies cleanup (cleared model, dropped poisoned GPU pool, released lock).

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically describes the main bug fix (model load/download hanging forever on Windows), using clear terminology that matches the changeset.
Description check ✅ Passed The description is comprehensive and covers all template sections: clear bug/root cause, detailed fixes across backend and frontend, testing verification, and constraints. Some non-critical checklist items are unchecked but the core content is complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/windows-model-load-hang

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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 and usage tips.

if pool is not None:
try:
pool.shutdown(wait=False, cancel_futures=True)
except Exception:

@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: 1

🤖 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 `@frontend/src/hooks/useTTS.js`:
- Around line 157-159: The hardcoded timeout message assigned to msg in
useTTS.js must be routed through the i18n layer: replace the inline string for
the AbortError branch with a call to t('tts.timeout') and add the corresponding
key/value to the locales JSON files (e.g., locales/en.json) with the original
message text; ensure you import/use the existing t function in the same scope as
msg so the conditional becomes err?.name === 'AbortError' ? t('tts.timeout') :
("Error: " + err.message) and keep the non-timeout branch unchanged.
🪄 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: 8419003a-69e1-46dd-85f5-0bc820311ec8

📥 Commits

Reviewing files that changed from the base of the PR and between 40cf9bf and 0a297fe.

📒 Files selected for processing (4)
  • backend/main.py
  • backend/services/model_manager.py
  • frontend/src/hooks/useTTS.js
  • tests/test_model_load_timeout.py

Comment on lines +157 to +159
const msg = err?.name === 'AbortError'
? 'Generation timed out — the model may still be downloading. Check Settings → Logs, then try again.'
: ("Error: " + err.message);

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Route the new timeout toast through the i18n layer.

This newly added user-facing string is hardcoded rather than going through a t('...') key. Existing toasts in this file share the same pattern, but per the change-scoped guideline this new message should use the translation layer (e.g., t('tts.timeout')) with the copy living in locales/*.json.

As per coding guidelines: "All user-facing text in the UI must go through the i18n translation layer using t('...') keys in locales/*.json files, never hardcode non-English text".

🤖 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 `@frontend/src/hooks/useTTS.js` around lines 157 - 159, The hardcoded timeout
message assigned to msg in useTTS.js must be routed through the i18n layer:
replace the inline string for the AbortError branch with a call to
t('tts.timeout') and add the corresponding key/value to the locales JSON files
(e.g., locales/en.json) with the original message text; ensure you import/use
the existing t function in the same scope as msg so the conditional becomes
err?.name === 'AbortError' ? t('tts.timeout') : ("Error: " + err.message) and
keep the non-timeout branch unchanged.

@debpalash
debpalash merged commit 5a723c0 into main May 30, 2026
15 checks passed
@debpalash
debpalash deleted the fix/windows-model-load-hang branch May 30, 2026 15:20
@greptile-apps

greptile-apps Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the "demo voice spins forever" Windows hang by adding defense-in-depth timeouts across the full model-load stack. The approach is sound and cross-platform.

  • backend/main.py: Sets HF_HUB_ETAG_TIMEOUT=15 and HF_HUB_DOWNLOAD_TIMEOUT=30 (per-read, not total) via setdefault before the first huggingface_hub import, so a dead socket raises quickly rather than blocking indefinitely.
  • model_manager.py: Introduces _load_model_with_timeout() wrapping the GPU-pool executor in asyncio.wait_for (default 1200 s); correctly resets the pool on timeout, but an external CancelledError (HTTP disconnect mid-load) bypasses the handler and leaves the poisoned pool unreset.
  • useTTS.js: Adds an AbortController backstop (21 min) and a user-friendly timeout toast; generateSpeech correctly accepts and forwards the signal.

Confidence Score: 3/5

Safe to merge for the primary stalled-socket scenario, but a retry after an HTTP client disconnect during model load will queue behind the stuck thread for up to 20 minutes rather than getting a fresh pool immediately.

The pool-reset logic in _load_model_with_timeout is only triggered by asyncio.TimeoutError. An external cancellation raises CancelledError instead, which propagates past the handler without calling _reset_gpu_pool(). The next request queues behind the wedged thread and waits up to 20 minutes for the watchdog, undermining the instant-retry guarantee the PR is designed to deliver.

backend/services/model_manager.py — specifically the exception handling in _load_model_with_timeout, which needs to cover CancelledError alongside TimeoutError to ensure pool reset on all exit paths.

Important Files Changed

Filename Overview
backend/services/model_manager.py Adds _load_model_with_timeout() wrapping run_in_executor in asyncio.wait_for; correctly resets GPU pool on timeout, but CancelledError from external cancellation bypasses _reset_gpu_pool(), leaving the pool poisoned until the watchdog fires.
backend/main.py Adds HF_HUB_ETAG_TIMEOUT=15 and HF_HUB_DOWNLOAD_TIMEOUT=30 via os.environ.setdefault before huggingface_hub is imported; correctly user-overridable and cross-platform.
frontend/src/hooks/useTTS.js Adds AbortController backstop on /generate with a hardcoded 21-minute timer and friendly timeout toast; generateSpeech correctly accepts and forwards the signal; magic constant not symbolically named.
tests/test_model_load_timeout.py New regression tests for timeout and env-var floor; fixture correctly patches _model_lock and _load_model_sync, but doesn't manage _gpu_pool_singleton via monkeypatch so pool state leaks between tests.

Sequence Diagram

sequenceDiagram
    participant FE as Frontend (useTTS.js)
    participant BE as /generate (FastAPI)
    participant GM as get_model()
    participant Pool as GPU ThreadPool
    participant HF as HuggingFace Hub

    FE->>+BE: "POST /generate (AbortController T=21min)"
    BE->>+GM: get_model()
    GM->>GM: acquire _model_lock
    GM->>+Pool: run_in_executor(_load_model_sync)
    Pool->>+HF: "from_pretrained() [HF_HUB_DOWNLOAD_TIMEOUT=30s per-read]"

    alt Normal download
        HF-->>Pool: model weights
        Pool-->>GM: model loaded
        GM-->>BE: model ready
        BE-->>FE: WAV stream
        FE->>FE: clearTimeout(abortTimer)
    else Socket stall HF read timeout fires
        HF--xPool: socket.timeout after 30s
        Pool-->>GM: raises TimeoutError
        GM-->>BE: error
        BE-->>FE: HTTP error
        FE->>FE: toast.error
    else Watchdog fires asyncio.wait_for 1200s
        Pool--xGM: asyncio.TimeoutError
        GM->>GM: _reset_gpu_pool()
        GM->>GM: release _model_lock
        GM-->>BE: RuntimeError
        BE-->>FE: HTTP 500
        FE->>FE: toast.error
    else Client abort backstop 21 min
        FE->>FE: AbortController fires
        FE-xBE: abort signal
        FE->>FE: toast.error timeout
    end
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(model): bound first-run model load/d..." | Re-trigger Greptile

Comment on lines +353 to +374
async def _load_model_with_timeout():
"""Run the blocking model load on the GPU pool, bounded by a deadline.

Raises RuntimeError on timeout (and resets the poisoned pool) so callers
surface an actionable error instead of hanging indefinitely.
"""
loop = asyncio.get_running_loop()
timeout = _model_load_timeout()
try:
return await asyncio.wait_for(
loop.run_in_executor(_get_gpu_pool(), _load_model_sync),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
_set_loading("error", "Model load timed out", error="timeout")
_reset_gpu_pool()
logger.error("Model load exceeded %ss; resetting GPU pool.", timeout)
raise RuntimeError(
f"Model loading timed out after {int(timeout)}s — usually a network "
"stall downloading the model (proxy, firewall, or antivirus). Check "
"your connection or set a Hugging Face mirror in Settings, then retry."
) from exc

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.

P1 Pool not reset on external CancelledError

_load_model_with_timeout only catches asyncio.TimeoutError. If the outer task is cancelled (e.g., the HTTP client disconnects mid-load, which uvicorn propagates as CancelledError), the exception bypasses the except asyncio.TimeoutError block entirely — _reset_gpu_pool() is never called. The poisoned single-worker pool persists, and the next /generate request silently queues behind the still-running stuck thread. The watchdog still fires eventually (after up to OMNIVOICE_MODEL_LOAD_TIMEOUT seconds), so it's not "hang forever" — but a retry that should be instant instead waits up to 20 minutes before the watchdog finally resets the pool.

Fix in Claude Code

Comment on lines +131 to +132
const ac = new AbortController();
abortTimer = setTimeout(() => ac.abort(), 21 * 60 * 1000);

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.

P2 The 21-minute client-side abort timer is hardcoded and doesn't adapt when OMNIVOICE_MODEL_LOAD_TIMEOUT is overridden on the backend. A user on a very slow link who sets, e.g., OMNIVOICE_MODEL_LOAD_TIMEOUT=3600 will have the frontend silently abort after 21 minutes while the backend is still downloading. The magic constant should at least be defined symbolically so it's obvious it must track the backend value.

Suggested change
const ac = new AbortController();
abortTimer = setTimeout(() => ac.abort(), 21 * 60 * 1000);
const ac = new AbortController();
// Keep this ceiling above the backend's OMNIVOICE_MODEL_LOAD_TIMEOUT
// (default 1200 s = 20 min) so the backend's descriptive error wins.
const FRONTEND_ABORT_MS = 21 * 60 * 1000; // 21 min — must exceed backend default
abortTimer = setTimeout(() => ac.abort(), FRONTEND_ABORT_MS);

Fix in Claude Code

Comment on lines +25 to +26
monkeypatch.setattr(mm, "model", None)
return mm

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.

P2 The fixture resets mm.model via monkeypatch (auto-restored on teardown), but _gpu_pool_singleton is modified directly by _reset_gpu_pool() inside the test and monkeypatch never restores it. Subsequent tests start with no pool — self-healing via lazy init, but the teardown asymmetry is fragile for anyone adding pool-state assertions later.

Suggested change
monkeypatch.setattr(mm, "model", None)
return mm
monkeypatch.setattr(mm, "model", None)
monkeypatch.setattr(mm, "_gpu_pool_singleton", None)
return mm

Fix in Claude Code

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.

2 participants