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
+44 -41
View File
@@ -37,6 +37,7 @@ class TTSService:
self._shutdown_event: asyncio.Event | None = None
self._worker_task: asyncio.Task | None = None
self._cookies: dict[str, str] | None = None
self._lifecycle_lock = asyncio.Lock()
self.running = False
self.start_time: float = 0
self.messages_processed = 0
@@ -48,49 +49,51 @@ class TTSService:
self._cookies = cookies
async def start(self, config: dict) -> None:
if self.running:
raise RuntimeError("Already running")
room_id = config["room_id"]
voice = config.get("voice", "f1")
speed = config.get("speed", 100)
self._volume = config.get("volume", 100)
message_format = config.get("format", "{uname}\u8bf4\u3001 {msg}")
convert_numbers = config.get("convert_numbers", True)
self.current_config = config
self.recent_messages = []
self.messages_processed = 0
self.start_time = time.time()
self._queue = asyncio.Queue(maxsize=256)
self._shutdown_event = asyncio.Event()
self._bridge = TTSBridge(voice=voice, speed=speed)
await self._bridge.start()
self._danmaku_client = DanmakuClient(
room_id=room_id, queue=self._queue,
cookies=self._cookies, message_format=message_format,
)
await self._danmaku_client.start()
self.running = True
self._worker_task = asyncio.create_task(self._tts_worker(convert_numbers))
async with self._lifecycle_lock:
if self.running:
raise RuntimeError("Already running")
room_id = config["room_id"]
voice = config.get("voice", "f1")
speed = config.get("speed", 100)
self._volume = config.get("volume", 100)
message_format = config.get("format", "{uname}\u8bf4\u3001 {msg}")
convert_numbers = config.get("convert_numbers", True)
self.current_config = config
self.recent_messages = []
self.messages_processed = 0
self.start_time = time.time()
self._queue = asyncio.Queue(maxsize=256)
self._shutdown_event = asyncio.Event()
self._bridge = TTSBridge(voice=voice, speed=speed)
await self._bridge.start()
self._danmaku_client = DanmakuClient(
room_id=room_id, queue=self._queue,
cookies=self._cookies, message_format=message_format,
)
await self._danmaku_client.start()
self.running = True
self._worker_task = asyncio.create_task(self._tts_worker(convert_numbers))
async def stop(self) -> None:
if not self.running:
return
self.running = False
if self._shutdown_event:
self._shutdown_event.set()
if self._worker_task:
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass
if self._danmaku_client:
await self._danmaku_client.stop()
if self._bridge:
await self._bridge.stop()
self._queue = None
self._bridge = None
self._danmaku_client = None
async with self._lifecycle_lock:
if not self.running:
return
self.running = False
if self._shutdown_event:
self._shutdown_event.set()
if self._worker_task:
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass
if self._danmaku_client:
await self._danmaku_client.stop()
if self._bridge:
await self._bridge.stop()
self._queue = None
self._bridge = None
self._danmaku_client = None
async def _tts_worker(self, convert_numbers: bool) -> None:
total_in = 0