From 97099095bd51a3c8030ddb87cb5fc648c0e63a5e Mon Sep 17 00:00:00 2001 From: Pheby Date: Tue, 16 Jun 2026 12:06:34 +0000 Subject: [PATCH] Fix recording artifact readiness race --- bot.py | 84 +++++++++++++++++++++++++++++++++++++++++------ tests/test_bot.py | 24 ++++++++++++++ 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index d8383b0..fdd6cc5 100644 --- a/bot.py +++ b/bot.py @@ -167,6 +167,70 @@ async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float = return False +async def wait_for_recording_artifacts_ready(file_path: str, attempts: int = 20, delay: float = 0.25) -> str | None: + """Wait until either speaker tracks appear or the mixed WAV is finalized. + + The voice receive library fires the post-recording callback before sink + cleanup writes ``tracks/tracks.json``. Polling for both artifact types keeps + us from failing on the corrupt mixed archive while finalized speaker tracks + are about to appear. + """ + last_size = -1 + stable_count = 0 + + for attempt in range(1, attempts + 1): + tracks = _load_speaker_tracks(file_path) + if tracks: + logger.info( + "Speaker tracks ready: path=%s attempt=%s/%s speakers=%s", + file_path, + attempt, + attempts, + len(tracks), + ) + return "tracks" + + if os.path.exists(file_path): + size = os.path.getsize(file_path) + logger.info( + "Checking recording artifacts readiness: path=%s attempt=%s/%s size_bytes=%s", + file_path, + attempt, + attempts, + size, + ) + if size > 0 and size == last_size: + stable_count += 1 + if stable_count >= 2: + try: + with wave.open(file_path, "rb") as wav_file: + wav_file.getparams() + except (wave.Error, EOFError, OSError) as exc: + logger.info( + "Mixed recording is size-stable but WAV header is not finalized yet: path=%s error=%s", + file_path, + exc, + ) + else: + logger.info("Mixed recording finalized: path=%s size_bytes=%s", file_path, size) + return "mixed" + else: + stable_count = 0 + last_size = size + else: + logger.info( + "Recording file not present yet while waiting for artifacts: path=%s attempt=%s/%s", + file_path, + attempt, + attempts, + ) + + await asyncio.sleep(delay) + + logger.warning("Recording artifacts were not ready before timeout: path=%s", file_path) + return None + + def build_retry_notice(file_path: str, reason: str) -> str: file_name = Path(file_path).name return ( @@ -287,16 +351,11 @@ async def process_recording( ) return - # Speaker tracks are written and finalized independently in MeetingSink - # cleanup — they always have valid WAV headers if they exist. Check - # them first so we can skip the mixed-file "ready" wait (which is prone - # to timing races with the SilenceGenerator shutdown path). - if _load_speaker_tracks(file_path): - logger.info( - "Speaker tracks present for guild_id=%s; skipping mixed-file readiness check", - guild_id, - ) - elif not await wait_for_file_ready(file_path): + # Speaker tracks are written during MeetingSink cleanup, but the voice + # library fires this callback before cleanup runs. Wait for either the + # manifest to appear or the mixed archive to become a valid WAV. + ready_source = await wait_for_recording_artifacts_ready(file_path) + if not ready_source: reason = "recording file was not finalized in time" await _save_retry_state(guild_id, file_path, fallback_channel_id, reason) await safe_send_chunked( @@ -306,6 +365,11 @@ async def process_recording( purpose="file-finalization warning", ) return + logger.info( + "Recording artifacts ready for guild_id=%s via %s", + guild_id, + ready_source, + ) if os.path.exists(file_path): logger.info( diff --git a/tests/test_bot.py b/tests/test_bot.py index 2027795..b9ee804 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -133,6 +133,30 @@ def test_wait_for_file_ready_accepts_valid_wav(tmp_path): assert ready is True +def test_wait_for_recording_artifacts_ready_accepts_tracks_created_during_wait(tmp_path): + meeting = tmp_path / "meeting.wav" + meeting.write_bytes(b"not a finalized mixed wav") + tracks_dir = tmp_path / "tracks" + tracks_dir.mkdir() + + async def create_tracks_later(): + await asyncio.sleep(0.003) + alice = tracks_dir / "track-111.wav" + alice.write_bytes(b"alice audio") + (tracks_dir / "tracks.json").write_text( + '{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}' + ) + + async def run_wait(): + writer = asyncio.create_task(create_tracks_later()) + try: + return await bot.wait_for_recording_artifacts_ready(str(meeting), attempts=20, delay=0.001) + finally: + await writer + + assert asyncio.run(run_wait()) == "tracks" + + def test_load_speaker_tracks_resolves_manifest_tracks_next_to_recording(tmp_path): meeting = tmp_path / "meeting.wav" meeting.write_bytes(b"RIFFdemo")