fix: serialize TTSBridge IO and TTSService lifecycle to prevent concurrency crashes

- TTSBridge.synthesize wraps stdin/stdout request-response in asyncio.Lock;
  concurrent calls previously caused 'readuntil() called while another
  coroutine is already waiting' and ENOENT (temp dir deleted while bridge
  still reading input.txt)
- TTSService.start/stop guarded by lifecycle lock to prevent race from
  rapid Start clicks creating duplicate bridge/worker/danmaku connections
- Verified: 20 concurrent synthesize calls all succeed with correct WAV
- Rebuild portable zip (83.8 MB)
This commit is contained in:
2026-08-08 15:38:08 +08:00
parent f27bcfb055
commit f4c99b1692
2 changed files with 69 additions and 64 deletions
+25 -23
View File
@@ -46,6 +46,7 @@ class TTSBridge:
self._bridge_script = bridge_script or _resolve_path("tts_bridge.js")
self._node_exe = _get_node_exe()
self._stderr_task: asyncio.Task | None = None
self._io_lock = asyncio.Lock()
async def start(self) -> None:
if not pathlib.Path(self._bridge_script).exists():
@@ -96,39 +97,40 @@ class TTSBridge:
if self._process is None or self._process.stdin is None:
raise RuntimeError("Bridge not started. Call start() first.")
tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="tts_"))
try:
input_path = tmpdir / "input.txt"
output_path = tmpdir / "output.wav"
async with self._io_lock:
tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="tts_"))
try:
input_path = tmpdir / "input.txt"
output_path = tmpdir / "output.wav"
input_path.write_text(kana_text, encoding="utf-8")
input_path.write_text(kana_text, encoding="utf-8")
cmd_line = f"{input_path}|{output_path}\n"
self._process.stdin.write(cmd_line.encode("utf-8"))
await self._process.stdin.drain()
cmd_line = f"{input_path}|{output_path}\n"
self._process.stdin.write(cmd_line.encode("utf-8"))
await self._process.stdin.drain()
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=120)
line_str = line.decode("utf-8").strip()
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=120)
line_str = line.decode("utf-8").strip()
if line_str.startswith("ERR:"):
raise RuntimeError(f"TTS synthesis error: {line_str[4:]}")
if line_str.startswith("ERR:"):
raise RuntimeError(f"TTS synthesis error: {line_str[4:]}")
if not line_str.startswith("OK:") or line_str[3:] != str(output_path):
raise RuntimeError(f"Unexpected bridge response: {line_str}")
if not line_str.startswith("OK:") or line_str[3:] != str(output_path):
raise RuntimeError(f"Unexpected bridge response: {line_str}")
wav_data = output_path.read_bytes()
return wav_data
wav_data = output_path.read_bytes()
return wav_data
finally:
for f in tmpdir.iterdir():
finally:
for f in tmpdir.iterdir():
try:
f.unlink()
except OSError:
pass
try:
f.unlink()
tmpdir.rmdir()
except OSError:
pass
try:
tmpdir.rmdir()
except OSError:
pass
async def stop(self) -> None:
if self._process is not None: