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

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>
This commit is contained in:
Palash Debnath
2026-05-30 21:15:09 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 1b08c03da9
commit e69dcbb6b1
6 changed files with 115 additions and 10 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ from schemas.requests import DubRequest
from services.model_manager import get_model, _gpu_pool
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
from services.audio_io import atomic_save_wav, _safe_torchaudio_save
from services.ffmpeg_utils import find_ffmpeg
from services.ffmpeg_utils import find_ffmpeg, spawn_subprocess
from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
from services.incremental import segment_fingerprint
from services.watermark import embed_watermark
@@ -86,7 +86,7 @@ async def _pitch_preserving_stretch(
# Mono float32 via stdin → ffmpeg → stdout. One subprocess per segment,
# run off the event loop so concurrent requests stay responsive.
arr = wav.detach().cpu().to(torch.float32).numpy().reshape(-1).astype(np.float32, copy=False)
proc = await asyncio.create_subprocess_exec(
proc = await spawn_subprocess(
find_ffmpeg(), "-hide_banner", "-loglevel", "error", "-y",
"-f", "f32le", "-ar", str(sr), "-ac", "1", "-i", "pipe:0",
"-af", filter_str,
+3 -2
View File
@@ -14,6 +14,7 @@ from pydantic import BaseModel
from core.db import db_conn
from core.config import VOICES_DIR, OUTPUTS_DIR
from core import event_bus
from services.ffmpeg_utils import spawn_subprocess
logger = logging.getLogger("omnivoice.gallery")
@@ -199,7 +200,7 @@ async def search_youtube(
):
"""Search YouTube for character/celebrity clips using yt-dlp."""
try:
result = await asyncio.create_subprocess_exec(
result = await spawn_subprocess(
"yt-dlp",
"--dump-json",
"--remote-components", "ejs:github",
@@ -273,7 +274,7 @@ async def download_youtube_clip(
video_url,
]
result = await asyncio.create_subprocess_exec(
result = await spawn_subprocess(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
+2 -2
View File
@@ -27,7 +27,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
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
logger = logging.getLogger("omnivoice.tools")
router = APIRouter()
@@ -54,7 +54,7 @@ async def probe(req: ProbeReq):
status_code=501,
detail="ffprobe binary not available. Install system ffmpeg or re-run the setup.",
)
proc = await asyncio.create_subprocess_exec(
proc = await spawn_subprocess(
ffprobe, "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", target,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
+15
View File
@@ -136,6 +136,7 @@ async def _spawn_thread_fallback(cmd, **kwargs):
stdout=subprocess.PIPE if stdout == asyncio.subprocess.PIPE else stdout,
stderr=subprocess.PIPE if stderr == asyncio.subprocess.PIPE else stderr,
stdin=subprocess.PIPE if stdin == asyncio.subprocess.PIPE else stdin,
**kwargs, # forward cwd / env / etc. so the fallback matches the async call
)
proc = await loop.run_in_executor(None, _run)
@@ -166,6 +167,20 @@ async def _spawn_thread_fallback(cmd, **kwargs):
return _AsyncCompatProc(proc)
async def spawn_subprocess(*args, **kwargs):
"""Drop-in replacement for ``asyncio.create_subprocess_exec``.
Falls back to a thread-based ``subprocess.Popen`` (wrapped to match the
asyncio Process interface) on event loops without subprocess support —
notably the Windows ``SelectorEventLoop`` that uvicorn forces under
``--reload``/multi-worker (``use_subprocess=True``), where the native call
raises ``NotImplementedError`` (GH #122). Also inherits the EAGAIN retry.
On loops that DO support subprocesses (Proactor, posix) the native path is
used unchanged, so there is no behavior change off the broken loop.
"""
return await _spawn_with_retry(list(args), **kwargs)
async def _spawn_with_retry(cmd, **kwargs):
"""Spawn a subprocess, retrying briefly on EAGAIN (posix_spawn resource pressure)."""
delay = 0.1
+6 -4
View File
@@ -15,6 +15,8 @@ import sys
from pathlib import Path
from typing import Optional
from services.ffmpeg_utils import spawn_subprocess
logger = logging.getLogger("omnivoice.sonitranslate")
# Default install location — inside the OmniVoice project tree
@@ -73,7 +75,7 @@ async def install(progress_callback=None) -> dict:
logger.info("Cloning SoniTranslate...")
if progress_callback:
progress_callback("Cloning SoniTranslate repository...")
proc = await asyncio.create_subprocess_exec(
proc = await spawn_subprocess(
"git", "clone", "--depth", "1",
"https://github.com/R3gm/SoniTranslate.git",
str(SONI_DIR),
@@ -91,7 +93,7 @@ async def install(progress_callback=None) -> dict:
progress_callback("Creating virtualenv...")
python = sys.executable
proc = await asyncio.create_subprocess_exec(
proc = await spawn_subprocess(
python, "-m", "venv", str(SONI_VENV),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -103,7 +105,7 @@ async def install(progress_callback=None) -> dict:
if progress_callback:
progress_callback("Installing base requirements (this may take a while)...")
proc = await asyncio.create_subprocess_exec(
proc = await spawn_subprocess(
pip, "install", "-r", str(SONI_DIR / "requirements_base.txt"),
cwd=str(SONI_DIR),
stdout=asyncio.subprocess.PIPE,
@@ -116,7 +118,7 @@ async def install(progress_callback=None) -> dict:
# Install extra requirements
if progress_callback:
progress_callback("Installing extra requirements...")
proc = await asyncio.create_subprocess_exec(
proc = await spawn_subprocess(
pip, "install", "-r", str(SONI_DIR / "requirements_extra.txt"),
cwd=str(SONI_DIR),
stdout=asyncio.subprocess.PIPE,
+87
View File
@@ -0,0 +1,87 @@
"""Windows --reload subprocess regression (issue #122 "Extract: Unknown Error").
uvicorn forces the SelectorEventLoop on Windows when use_subprocess=True
(`--reload` / multi-worker), where `asyncio.create_subprocess_exec` raises
NotImplementedError. `spawn_subprocess` must transparently fall back to a
thread-based subprocess so ffmpeg/yt-dlp/ffprobe spawns still work.
"""
from __future__ import annotations
import asyncio
import os
import sys
from services import ffmpeg_utils
def _force_notimplemented(monkeypatch):
async def _boom(*a, **k):
raise NotImplementedError("no subprocess on this loop (simulated Windows SelectorEventLoop)")
monkeypatch.setattr(ffmpeg_utils.asyncio, "create_subprocess_exec", _boom)
def test_spawn_subprocess_falls_back_to_thread_on_notimplemented(monkeypatch):
_force_notimplemented(monkeypatch)
async def run():
proc = await ffmpeg_utils.spawn_subprocess(
sys.executable, "-c", "import sys; sys.stdout.write('ok')",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
return proc.returncode, out
rc, out = asyncio.run(run())
assert rc == 0
assert out == b"ok"
def test_spawn_subprocess_fallback_forwards_cwd(monkeypatch, tmp_path):
# sonitranslate's pip install passes cwd= — the thread fallback must honor it.
_force_notimplemented(monkeypatch)
async def run():
proc = await ffmpeg_utils.spawn_subprocess(
sys.executable, "-c", "import os,sys; sys.stdout.write(os.getcwd())",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=str(tmp_path),
)
out, _ = await proc.communicate()
return out
out = asyncio.run(run())
assert os.path.realpath(out.decode()) == os.path.realpath(str(tmp_path))
def test_spawn_subprocess_fallback_passes_stdin_input(monkeypatch):
# dub_generate's atempo pipes audio bytes via stdin → communicate(input=...).
_force_notimplemented(monkeypatch)
async def run():
proc = await ffmpeg_utils.spawn_subprocess(
sys.executable, "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate(input=b"abc123")
return proc.returncode, out
rc, out = asyncio.run(run())
assert rc == 0
assert out == b"abc123"
def test_spawn_subprocess_native_path_when_loop_supports_it():
# On a loop WITH subprocess support (posix / Windows Proactor) the native
# asyncio path is used unchanged — no behavior change off the broken loop.
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