Fix speaker-track readiness race: skip mixed-file wait when tracks exist
This commit is contained in:
@@ -287,7 +287,16 @@ async def process_recording(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not await wait_for_file_ready(file_path):
|
# 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):
|
||||||
reason = "recording file was not finalized in time"
|
reason = "recording file was not finalized in time"
|
||||||
await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
|
await _save_retry_state(guild_id, file_path, fallback_channel_id, reason)
|
||||||
await safe_send_chunked(
|
await safe_send_chunked(
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ class MeetingSink(voice_recv.AudioSink):
|
|||||||
return track
|
return track
|
||||||
|
|
||||||
def write(self, user, data) -> None:
|
def write(self, user, data) -> None:
|
||||||
|
if self._cleaned_up:
|
||||||
|
return
|
||||||
|
|
||||||
pcm = getattr(data, "pcm", b"") or b""
|
pcm = getattr(data, "pcm", b"") or b""
|
||||||
|
|
||||||
# Mixed archive always gets every frame (including silence) so the
|
# Mixed archive always gets every frame (including silence) so the
|
||||||
|
|||||||
@@ -200,3 +200,56 @@ def test_transcribe_recording_falls_back_to_single_file_for_old_recordings(tmp_p
|
|||||||
|
|
||||||
assert result == "single transcript"
|
assert result == "single transcript"
|
||||||
assert calls == [("single", str(meeting))]
|
assert calls == [("single", str(meeting))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_recording_skips_ready_wait_when_tracks_exist(tmp_path, monkeypatch):
|
||||||
|
"""When speaker tracks exist, process_recording should not call wait_for_file_ready."""
|
||||||
|
meeting = tmp_path / "meeting.wav"
|
||||||
|
meeting.write_bytes(b"RIFFdemo")
|
||||||
|
tracks_dir = tmp_path / "tracks"
|
||||||
|
tracks_dir.mkdir()
|
||||||
|
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) + '"}]}'
|
||||||
|
)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_transcribe_recording(path, guild_id):
|
||||||
|
calls.append(("transcribe", path))
|
||||||
|
return "[Alice]\nhello"
|
||||||
|
|
||||||
|
async def fake_summarize(transcript):
|
||||||
|
calls.append(("summarize", transcript))
|
||||||
|
return "Summary text."
|
||||||
|
|
||||||
|
async def fake_get_output_channel(guild_id):
|
||||||
|
return 999
|
||||||
|
|
||||||
|
async def fake_clear_retry_state(_guild_id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def fake_resolve_text_channel(_channel_id):
|
||||||
|
return SimpleNamespace(id=999, send=lambda t: None)
|
||||||
|
|
||||||
|
async def fake_safe_send_chunked(_channel, text, **kw):
|
||||||
|
calls.append(("delivered", text[:30]))
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(bot, "_transcribe_recording", fake_transcribe_recording)
|
||||||
|
monkeypatch.setattr(bot, "summarize", fake_summarize)
|
||||||
|
monkeypatch.setattr(bot, "wait_for_file_ready", lambda *a, **kw: calls.append(("ready",)) or True)
|
||||||
|
monkeypatch.setattr(bot, "safe_send_chunked", fake_safe_send_chunked)
|
||||||
|
monkeypatch.setattr(bot, "config", SimpleNamespace(
|
||||||
|
get_output_channel=fake_get_output_channel,
|
||||||
|
clear_retry_state=fake_clear_retry_state,
|
||||||
|
set_retry_state=lambda g, p, c, r: None,
|
||||||
|
))
|
||||||
|
monkeypatch.setattr(bot, "resolve_text_channel", fake_resolve_text_channel)
|
||||||
|
|
||||||
|
asyncio.run(bot.process_recording(str(meeting), 123, 456, None))
|
||||||
|
|
||||||
|
# wait_for_file_ready should NOT have been called
|
||||||
|
assert ("ready",) not in calls, "wait_for_file_ready was called despite tracks existing"
|
||||||
|
assert calls[:2] == [("transcribe", str(meeting)),
|
||||||
|
("summarize", "[Alice]\nhello")]
|
||||||
|
|||||||
@@ -92,3 +92,25 @@ def test_meeting_sink_ignores_silence_only_and_unknown_users(tmp_path):
|
|||||||
assert manifest["tracks"] == []
|
assert manifest["tracks"] == []
|
||||||
# Mixed archive still finalized and valid.
|
# Mixed archive still finalized and valid.
|
||||||
assert _read_wav_frames(mixed) == SILENCE * 2
|
assert _read_wav_frames(mixed) == SILENCE * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_meeting_sink_ignores_writes_after_cleanup(tmp_path):
|
||||||
|
"""Verify that writes after cleanup() are silently dropped."""
|
||||||
|
mixed = tmp_path / "meeting.wav"
|
||||||
|
tracks_dir = tmp_path / "tracks"
|
||||||
|
sink = recording.MeetingSink(str(mixed), str(tracks_dir))
|
||||||
|
|
||||||
|
alice = FakeUser(111, display_name="Alice")
|
||||||
|
sink.write(alice, FakeData(SPEECH))
|
||||||
|
sink.cleanup()
|
||||||
|
|
||||||
|
# These writes should be dropped, not cause crashes or corrupt the files.
|
||||||
|
sink.write(alice, FakeData(SPEECH))
|
||||||
|
sink.write(None, FakeData(SILENCE))
|
||||||
|
|
||||||
|
# Mixed file should contain only the pre-cleanup frame.
|
||||||
|
assert _read_wav_frames(mixed) == SPEECH
|
||||||
|
assert _read_wav_frames(tracks_dir / "track-111.wav") == SPEECH
|
||||||
|
# Manifest should still reference Alice.
|
||||||
|
manifest = json.loads((tracks_dir / "tracks.json").read_text())
|
||||||
|
assert len(manifest["tracks"]) == 1
|
||||||
Reference in New Issue
Block a user