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:
co-authored by
Claude Opus 4.8
parent
1b08c03da9
commit
e69dcbb6b1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user