Skip to content

fix(win): subprocess spawns work under bun run dev (--reload) on Windows (#122) - #175

Merged
debpalash merged 1 commit into
mainfrom
fix/windows-reload-subprocess
May 30, 2026
Merged

fix(win): subprocess spawns work under bun run dev (--reload) on Windows (#122)#175
debpalash merged 1 commit into
mainfrom
fix/windows-reload-subprocess

Conversation

@debpalash

@debpalash debpalash commented May 30, 2026

Copy link
Copy Markdown
Owner

Closes the residual of #122 "Extract: Unknown Error" (Windows).

Root cause (definitive)

The reporter found that bun run dev fails but running backend + frontend separately works. Why:

  • dev:api launches uvicorn … --reloaduse_subprocess=True.
  • uvicorn 0.42's asyncio_loop_factory explicitly forces the SelectorEventLoop on Windows when use_subprocess=True (and passes it as loop_factory to asyncio_run, so it overrides any event-loop policy).
  • The SelectorEventLoop has no subprocess supportasyncio.create_subprocess_exec raises NotImplementedError.
  • python backend/main.py (no reload) uses the ProactorEventLoop → works.

So a policy-based fix is futile (uvicorn overrides it). The thread-based fallback is the only robust fix.

Fix

The ffmpeg extract path already routed through _spawn_async's thread fallback (#157), but other spawn sites used raw create_subprocess_exec and stayed broken on the dev loop:

  • spawn_subprocess() — public drop-in for create_subprocess_exec that routes through _spawn_with_retry → _spawn_async (NotImplementedError → thread fallback, plus EAGAIN retry). Native asyncio path unchanged on supported loops.
  • _spawn_thread_fallback now forwards cwd/env/etc. to subprocess.Popen (it was silently dropping them — breaks sonitranslate's cwd= pip install in the fallback).
  • Converted raw spawns: dub_generate (atempo), tools (ffprobe), gallery (yt-dlp ×2), sonitranslate install (×4). translation_engines already had its own fallback.
  • Tests: NotImplementedError → thread fallback; cwd forwarding; stdin input= (atempo); native path unchanged.

Verify

  • New tests/test_subprocess_fallback.py (4) ✓; router smoke (23) ✓.
  • No behavior change off the broken loop (macOS / Linux / Windows-prod python main.py): the native asyncio subprocess is still used; the fallback only triggers on NotImplementedError.

Cross-platform parity preserved; no engine/model state touched; local-first.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor

    • Centralized subprocess execution logic to improve consistency and reliability across API endpoints and background services. Enhanced subprocess handling to properly preserve environment and working directory settings.
  • Tests

    • Added comprehensive test coverage for subprocess fallback behavior on systems where native async subprocess support is unavailable, including environment variable and working directory forwarding.

Review Change Stack

…ndows (#122)

issue #122 'Extract: Unknown Error' on Windows: 'bun run dev' fails, running
backend+frontend separately works. Root cause: dev:api launches uvicorn with
--reload, so use_subprocess=True, and uvicorn 0.42's asyncio_loop_factory
EXPLICITLY forces the SelectorEventLoop on Windows in that case (passed as
loop_factory to asyncio_run, overriding any policy). The SelectorEventLoop has
no subprocess support -> asyncio.create_subprocess_exec raises
NotImplementedError. 'python backend/main.py' (no reload) uses ProactorEventLoop
-> works. So an event-loop-policy fix is futile; the thread fallback is the fix.

The ffmpeg extract path already routed through _spawn_async's thread fallback
(landed in #157), but several other spawn sites used raw create_subprocess_exec
and stayed broken on the dev loop:
- add public spawn_subprocess() (drop-in for create_subprocess_exec) that routes
  through _spawn_with_retry -> _spawn_async (NotImplementedError -> thread
  fallback + EAGAIN retry); native asyncio path unchanged on supported loops.
- fix _spawn_thread_fallback to forward cwd/env/etc. to subprocess.Popen (was
  silently dropping them -- breaks sonitranslate's cwd= pip install).
- convert raw spawns: dub_generate atempo, tools ffprobe, gallery yt-dlp (x2),
  sonitranslate install (x4). translation_engines already had its own fallback.
- tests: NotImplementedError -> thread fallback; cwd forwarding; stdin input
  (atempo); native path unchanged.

No behavior change off the broken loop (macOS/Linux/Windows-prod): the native
asyncio subprocess is still used; the fallback only triggers on NotImplementedError.

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

This PR introduces spawn_subprocess, a unified async subprocess wrapper in ffmpeg_utils that replaces direct asyncio.create_subprocess_exec calls across multiple API endpoints and services. The wrapper provides automatic fallback to thread-based spawning on event loops that do not support native subprocess operations, and enhances the fallback mechanism to forward kwargs like cwd and env consistently. Five backend modules are updated to use this new abstraction, and comprehensive test coverage validates both the fallback and native code paths.

Changes

Subprocess abstraction and fallback

Layer / File(s) Summary
Subprocess abstraction and fallback mechanism
backend/services/ffmpeg_utils.py
New spawn_subprocess(*args, **kwargs) async function acts as a drop-in replacement for asyncio.create_subprocess_exec, delegating to _spawn_with_retry and documenting event-loop limitations. The thread-based fallback in _spawn_thread_fallback is enhanced to forward remaining **kwargs into subprocess.Popen so parameters like cwd and env are preserved consistently between native and fallback paths.
API routers adoption
backend/api/routers/dub_generate.py, backend/api/routers/gallery.py, backend/api/routers/tools.py
Three API routers import and use spawn_subprocess in place of direct asyncio.create_subprocess_exec: dub_generate's pitch-preserving ffmpeg stretch, gallery's YouTube search and download endpoints using yt-dlp, and tools' ffprobe subprocess for the /tools/probe endpoint. All subprocess IO and error handling behavior remains unchanged.
Service integration
backend/services/sonitranslate.py
SoniTranslate's install() method replaces four asyncio.create_subprocess_exec calls with await spawn_subprocess(...) for Git clone, virtualenv creation, and base and extra pip requirements installation phases.
Fallback behavior test coverage
tests/test_subprocess_fallback.py
New test module covers the thread-based fallback when asyncio.create_subprocess_exec raises NotImplementedError, verifying fallback process spawning and stdout capture, cwd parameter forwarding, stdin input preservation via communicate(), and native path usage when the event loop supports subprocess natively.

Sequence Diagram

sequenceDiagram
    participant API as API Endpoint
    participant spawn as spawn_subprocess
    participant retry as _spawn_with_retry
    participant native as asyncio
    participant fallback as Thread Fallback
    
    API->>spawn: spawn_subprocess(cmd, ...)
    spawn->>retry: delegate with args/kwargs
    retry->>native: Try create_subprocess_exec
    alt Event Loop Supports Subprocess
        native-->>retry: Return native process
        retry-->>spawn: Success path
        spawn-->>API: Process object
    else NotImplementedError
        native-->>retry: Raise NotImplementedError
        retry->>fallback: Launch subprocess.Popen in thread
        fallback-->>retry: Return wrapped process
        retry-->>spawn: Success via fallback
        spawn-->>API: Process object (fallback)
    end
    API->>API: communicate() with process (identical API)
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • debpalash/OmniVoice-Studio#152: Directly related refactor of the same _pitch_preserving_stretch ffmpeg subprocess spawning in dub_generate.py that this PR builds upon by extending the abstraction across all subprocess creation sites.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.84% 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 clearly and specifically identifies the fix: subprocess spawning on Windows under bun run dev with reload, directly addressing the root cause of issue #122.
Description check ✅ Passed The description provides root cause analysis, explains the fix with technical detail, lists all changed components, and includes verification results. However, the Testing section is incomplete (no specific testing steps listed) and several checklist items are unchecked.
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-reload-subprocess

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.


from services import director, speech_rate, incremental
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe, spawn_subprocess
@greptile-apps

greptile-apps Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes subprocess spawning under bun run dev (uvicorn --reload) on Windows, where uvicorn forces the SelectorEventLoop which raises NotImplementedError on asyncio.create_subprocess_exec. A new spawn_subprocess drop-in wrapper routes all spawn sites through the existing _spawn_with_retry → _spawn_async fallback chain, and the thread fallback now correctly forwards cwd/env to subprocess.Popen (previously silently dropped, breaking sonitranslate's pip installs in the fallback path).

  • spawn_subprocess added as a public wrapper around _spawn_with_retry; five call sites across dub_generate, gallery, tools, and sonitranslate converted from raw asyncio.create_subprocess_exec.
  • _spawn_thread_fallback updated to forward remaining kwargs (cwd, env, etc.) to Popen via **kwargs, fixing the silent kwarg-drop regression.
  • Four regression tests added covering: NotImplementedError → thread fallback, cwd forwarding, stdin input= piping, and native-path preservation.

Confidence Score: 4/5

Safe to merge; the fallback is only triggered on the broken Windows SelectorEventLoop, leaving macOS/Linux/Windows-prod behaviour completely unchanged.

The _AsyncCompatProc.wait() method does not update self.returncode after the child exits, so any caller that uses wait() and then reads returncode will see a stale None. The existing run_ffmpeg cleanup path exposes this: after kill+wait on a timeout it re-enters the finally kill/wait loop because returncode is never written. The new callsites in this PR all use communicate() (which does update returncode), so no visible breakage today. The **kwargs forwarding to Popen is also a latent trap if a future caller passes asyncio-specific kwargs like limit=.

backend/services/ffmpeg_utils.py — specifically _AsyncCompatProc.wait() and the unconstrained **kwargs forwarding to subprocess.Popen.

Important Files Changed

Filename Overview
backend/services/ffmpeg_utils.py Core of the fix: adds spawn_subprocess public wrapper and fixes _spawn_thread_fallback to forward cwd/env/etc via **kwargs; _AsyncCompatProc.wait() does not update self.returncode after the process exits.
backend/api/routers/dub_generate.py Replaces asyncio.create_subprocess_exec with spawn_subprocess for the atempo ffmpeg call; uses communicate(input=...) correctly so the thread fallback path handles stdin bytes without issues.
backend/api/routers/gallery.py Two yt-dlp spawns converted to spawn_subprocess; both call communicate() before checking returncode, so the fallback path works correctly.
backend/api/routers/tools.py ffprobe spawn converted to spawn_subprocess; straightforward change with no new logic.
backend/services/sonitranslate.py Four subprocess spawns converted to spawn_subprocess; cwd=str(SONI_DIR) for the pip installs is now correctly forwarded through the fallback, fixing the previously silent drop.
tests/test_subprocess_fallback.py Four focused regression tests covering: NotImplementedError fallback, cwd forwarding, stdin input= piping, and native-path preservation; good coverage of the changed behaviour.

Sequence Diagram

sequenceDiagram
    participant Caller as Router / Service
    participant SW as spawn_subprocess()
    participant SR as _spawn_with_retry()
    participant SA as _spawn_async()
    participant AIO as asyncio.create_subprocess_exec
    participant FB as _spawn_thread_fallback()
    participant PO as subprocess.Popen (thread)

    Caller->>SW: "spawn_subprocess(*args, **kwargs)"
    SW->>SR: "_spawn_with_retry(list(args), **kwargs)"
    SR->>SA: "_spawn_async(cmd, **kwargs)"
    SA->>AIO: "create_subprocess_exec(*cmd, **kwargs)"
    alt Supported loop (Proactor / posix)
        AIO-->>SA: asyncio.Process
        SA-->>SR: Process
        SR-->>SW: Process
        SW-->>Caller: Process
    else NotImplementedError (Windows SelectorEventLoop)
        AIO-->>SA: NotImplementedError
        SA->>FB: "_spawn_thread_fallback(cmd, **kwargs)"
        Note over FB: pops stdout/stderr/stdin,<br/>forwards cwd/env/**kwargs to Popen
        FB->>PO: "Popen(cmd, ..., cwd=..., **kwargs)"
        PO-->>FB: Popen instance
        FB-->>SA: _AsyncCompatProc(popen)
        SA-->>SR: _AsyncCompatProc
        SR-->>SW: _AsyncCompatProc
        SW-->>Caller: _AsyncCompatProc
    end
    Caller->>Caller: "await proc.communicate([input=...])"
Loading

Comments Outside Diff (1)

  1. backend/services/ffmpeg_utils.py, line 158-159 (link)

    P2 wait() doesn't refresh self.returncode

    Popen.wait() blocks until the child exits and sets self._popen.returncode, but _AsyncCompatProc.wait() never copies that value back to self.returncode. Any caller that does await proc.wait() and then reads proc.returncode will see None. The run_ffmpeg cleanup path (kill → wait → finally: if proc.returncode is None) hits exactly this: after kill+wait the returncode is still None, so the finally block repeats kill/wait unnecessarily. The new callsites in this PR always use communicate() (which does update returncode), so there's no visible breakage today, but the stale-returncode contract leaks the bug to any future caller that uses wait() directly.

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(win): subprocess spawns work under `..." | Re-trigger Greptile

Comment on lines +139 to 140
**kwargs, # forward cwd / env / etc. so the fallback matches the async call
)

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 Asyncio-only kwargs forwarded verbatim to Popen

asyncio.create_subprocess_exec accepts a limit kwarg (StreamReader buffer size) that subprocess.Popen does not. If any caller passes limit=… to spawn_subprocess, the **kwargs forwarding here will produce TypeError: __init__() got an unexpected keyword argument 'limit' on the fallback path only, making the failure mode hard to diagnose. None of the current callsites pass limit, but since spawn_subprocess is documented as a "drop-in replacement for asyncio.create_subprocess_exec", future callers are likely to try it. Filtering or documenting the exclusion would prevent a confusing failure.

Fix in Claude Code

@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/sonitranslate.py (1)

104-104: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Fix SoniTranslate venv layout for Windows (bin/Scripts/)

  • backend/services/sonitranslate.py hardcodes SONI_VENV / "bin" / "pip" in is_venv_ready() and install(), and SONI_VENV / "bin" / "python" in start(). On Windows, python -m venv places executables under Scripts\\, so venv readiness/pip install/start won’t use the correct binaries.
  • Apply the same cross-platform venv layout approach already used by backend/engines/indextts/bootstrap.py (it branches to Scripts/python.exe on win32).
🛠️ Suggested platform-aware resolution
# module-level helper
_VENV_BIN = "Scripts" if os.name == "nt" else "bin"
_PIP_NAME = "pip.exe" if os.name == "nt" else "pip"
_PY_NAME = "python.exe" if os.name == "nt" else "python"
-        pip = str(SONI_VENV / "bin" / "pip")
+        pip = str(SONI_VENV / _VENV_BIN / _PIP_NAME)

Update is_venv_ready() (Line 40) and start() (Line 141) to use _VENV_BIN / _PIP_NAME and _VENV_BIN / _PY_NAME respectively.

🤖 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/sonitranslate.py` at line 104, The venv paths are hardcoded
to "bin" and non-Windows executable names; update
backend/services/sonitranslate.py to use a platform-aware layout: add
module-level helpers (_VENV_BIN = "Scripts" if os.name == "nt" else "bin",
_PIP_NAME = "pip.exe" if os.name == "nt" else "pip", _PY_NAME = "python.exe" if
os.name == "nt" else "python") and replace usages of SONI_VENV / "bin" / "pip"
in is_venv_ready() and install() with SONI_VENV / _VENV_BIN / _PIP_NAME, and
replace SONI_VENV / "bin" / "python" in start() with SONI_VENV / _VENV_BIN /
_PY_NAME so the code selects Scripts\\ on Windows and bin on POSIX.
🧹 Nitpick comments (1)
tests/test_subprocess_fallback.py (1)

74-87: 💤 Low value

This test doesn't verify the native path is actually taken.

The comment states the native asyncio path is "used unchanged", but the assertions only check that output is produced. Since nothing forces a failure, this test would still pass even if spawn_subprocess always fell through to the thread fallback — so it doesn't guard against a regression that disables the native path on supported loops. Consider spying on create_subprocess_exec to assert it was invoked.

♻️ Assert native path was exercised
-def test_spawn_subprocess_native_path_when_loop_supports_it():
+def test_spawn_subprocess_native_path_when_loop_supports_it(monkeypatch):
     # On a loop WITH subprocess support (posix / Windows Proactor) the native
     # asyncio path is used unchanged — no behavior change off the broken loop.
+    real = ffmpeg_utils.asyncio.create_subprocess_exec
+    calls = []
+
+    async def _spy(*a, **k):
+        calls.append(a)
+        return await real(*a, **k)
+
+    monkeypatch.setattr(ffmpeg_utils.asyncio, "create_subprocess_exec", _spy)
+
     async def run():
         proc = await ffmpeg_utils.spawn_subprocess(
             sys.executable, "-c", "print('hi')",
             stdout=asyncio.subprocess.PIPE,
         )
         out, _ = await proc.communicate()
         return proc.returncode, out

     rc, out = asyncio.run(run())
     assert rc == 0
     assert b"hi" in out
+    assert calls, "native create_subprocess_exec was not used"
🤖 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 `@tests/test_subprocess_fallback.py` around lines 74 - 87, The test
test_spawn_subprocess_native_path_when_loop_supports_it currently only checks
output and can pass even if ffmpeg_utils.spawn_subprocess used the thread
fallback; update the test to spy/mock asyncio.create_subprocess_exec and assert
it was called (or wrapped via a patch) when running the async run() so you
verify the native asyncio path is exercised; locate references to
ffmpeg_utils.spawn_subprocess and the test coroutine and add a mock/spy around
asyncio.create_subprocess_exec (or the loop's create_subprocess_exec) to assert
it was invoked during the test.
🤖 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/sonitranslate.py`:
- Line 104: The venv paths are hardcoded to "bin" and non-Windows executable
names; update backend/services/sonitranslate.py to use a platform-aware layout:
add module-level helpers (_VENV_BIN = "Scripts" if os.name == "nt" else "bin",
_PIP_NAME = "pip.exe" if os.name == "nt" else "pip", _PY_NAME = "python.exe" if
os.name == "nt" else "python") and replace usages of SONI_VENV / "bin" / "pip"
in is_venv_ready() and install() with SONI_VENV / _VENV_BIN / _PIP_NAME, and
replace SONI_VENV / "bin" / "python" in start() with SONI_VENV / _VENV_BIN /
_PY_NAME so the code selects Scripts\\ on Windows and bin on POSIX.

---

Nitpick comments:
In `@tests/test_subprocess_fallback.py`:
- Around line 74-87: The test
test_spawn_subprocess_native_path_when_loop_supports_it currently only checks
output and can pass even if ffmpeg_utils.spawn_subprocess used the thread
fallback; update the test to spy/mock asyncio.create_subprocess_exec and assert
it was called (or wrapped via a patch) when running the async run() so you
verify the native asyncio path is exercised; locate references to
ffmpeg_utils.spawn_subprocess and the test coroutine and add a mock/spy around
asyncio.create_subprocess_exec (or the loop's create_subprocess_exec) to assert
it was invoked during the test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d5ba86a7-b0df-4f5c-9211-12c504c247c4

📥 Commits

Reviewing files that changed from the base of the PR and between 1b08c03 and 80aa51e.

📒 Files selected for processing (6)
  • backend/api/routers/dub_generate.py
  • backend/api/routers/gallery.py
  • backend/api/routers/tools.py
  • backend/services/ffmpeg_utils.py
  • backend/services/sonitranslate.py
  • tests/test_subprocess_fallback.py

@debpalash
debpalash merged commit e69dcbb into main May 30, 2026
15 checks passed
@debpalash
debpalash deleted the fix/windows-reload-subprocess branch May 30, 2026 15:45
debpalash added a commit that referenced this pull request May 30, 2026
…181)

Completes the pro-output story: an Export-format selector (WAV / MP3) on the
Stories toolbar. WAV stays fully client-side; MP3 routes the client-stitched
WAV through a new backend POST /stories/encode (ffmpeg via the Windows-reload-
safe spawn_subprocess, #175), with a strict format whitelist (mp3/m4b/ogg) and
bitrate validation so the uploaded format can't inject ffmpeg args. Both the
audiobook and per-character stems honor the selected format; MP3 falls back to
WAV with a toast if ffmpeg is unavailable.

- backend/api/routers/stories.py + main.py registration; temp-file cleanup.
- frontend/src/api/stories.ts encodeAudio() (apiFetch: same-origin + PIN).
- tests: format-whitelist 400, ffmpeg-missing 501, real mp3 encode (skips if
  ffmpeg absent). Backend 26 incl. router smoke; frontend 152/152, typecheck/
  build/CJK ✓.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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