Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ async def barrier(self) -> None:
# using a while loop in case rotate_segment is called twice (this should not happen, but
# just in case, we do log a warning if it does)
while not self._rotate_segment_atask.done():
await self._rotate_segment_atask
await asyncio.shield(self._rotate_segment_atask)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cancelled shutdown leaks synchronizer tasks

Cancelling aclose() during rotation leaves the shielded task running after shutdown exits. _rotate_segment_task creates a new implementation whose background tasks remain open.

Learn more

aclose() marks the synchronizer closed and then waits at this barrier. Cancellation now exits aclose() without cancelling the rotation. The surviving rotation closes the old implementation, then _rotate_segment_task unconditionally constructs a new implementation with three background tasks. Since the synchronizer is already closed and aclose() has exited, nothing closes that new implementation.

Example: A session starts rotating a transcript segment and then shutdown calls aclose(). If shutdown cancellation arrives before rotation finishes, aclose() exits while rotation continues. Rotation creates a fresh closed-session implementation, leaving its main, capture, and speaking-rate tasks alive.

Recommended fix: Make cancelled shutdown retain ownership of the protected rotation and close whichever implementation it creates. One option is a dedicated close task that awaits rotation and closes the final _impl, with aclose() shielding that task so later cleanup can still await it.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



class _SyncedAudioOutput(io.AudioOutput):
Expand Down
63 changes: 63 additions & 0 deletions tests/test_transcript_sync_cancellation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import asyncio

import pytest

from livekit.agents import io
from livekit.agents.voice.transcription.synchronizer import TranscriptSynchronizer

pytestmark = pytest.mark.unit


class _TextOutput(io.TextOutput):
def __init__(self) -> None:
super().__init__(label="test", next_in_chain=None)

async def capture_text(self, text: str) -> None:
pass

def flush(self) -> None:
pass


class _AudioOutput(io.AudioOutput):
def __init__(self) -> None:
super().__init__(label="test", capabilities=io.AudioOutputCapabilities(pause=False))

async def capture_frame(self, frame) -> None:
pass

def flush(self) -> None:
pass

def clear_buffer(self) -> None:
pass

def pause(self) -> None:
pass

def resume(self) -> None:
pass


def test_barrier_cancellation_does_not_cancel_segment_rotation() -> None:
async def run() -> None:
sync = TranscriptSynchronizer(
next_in_chain_text=_TextOutput(), next_in_chain_audio=_AudioOutput()
)
release = asyncio.Event()

async def rotate() -> None:
await release.wait()

rotation = asyncio.create_task(rotate())
sync._rotate_segment_atask = rotation
waiter = asyncio.create_task(sync.barrier())
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
assert not rotation.cancelled()
release.set()
await sync.barrier()

asyncio.run(run())