Make EndFrame and StopFrame uninterruptible to prevent pipeline freeze

This commit is contained in:
Luke Payyapilli
2026-01-23 10:11:22 -05:00
parent 4c10ddb7bb
commit 8d3e10f054
2 changed files with 111 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ from pipecat.frames.frames import (
Frame,
InterruptionFrame,
OutputTransportMessageUrgentFrame,
StopFrame,
SystemFrame,
TextFrame,
UninterruptibleFrame,
@@ -348,6 +349,106 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
self.assertIs(down_frame.metadata, orig.metadata)
self.assertIs(up_frame.metadata, orig.metadata)
async def test_terminal_frames_survive_interruption(self):
"""Test that EndFrame survives interruption (it is uninterruptible).
This test simulates issue #3524 where an InterruptionFrame during slow
processing would cause terminal frames to be lost, freezing the pipeline.
"""
received_frames: List[Frame] = []
class DelayAndInterruptProcessor(FrameProcessor):
"""This processor delays processing and then generates an interruption.
When processing a TextFrame, it sleeps and then pushes an
InterruptionFrame to simulate what happens when interruption occurs
while a terminal frame is in the queue.
"""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
# Delay to allow EndFrame to be queued
await asyncio.sleep(0.1)
# Push interruption - this should NOT discard the EndFrame
await self.push_frame(InterruptionFrame(), direction)
await self.push_frame(frame, direction)
class CaptureFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
received_frames.append(frame)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
]
expected_down_frames = [
InterruptionFrame,
TextFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify EndFrame was received by our capture processor (survived interruption)
# Note: run_test filters EndFrame from expected_down_frames when send_end_frame=True,
# but our capture processor sees it before that filtering.
end_frames = [f for f in received_frames if isinstance(f, EndFrame)]
self.assertEqual(len(end_frames), 1, "EndFrame should survive interruption")
async def test_stop_frame_survives_interruption(self):
"""Test that StopFrame survives interruption (it is uninterruptible).
Similar to test_terminal_frames_survive_interruption but specifically
for StopFrame.
"""
received_frames: List[Frame] = []
class DelayAndInterruptProcessor(FrameProcessor):
"""This processor delays processing and then generates an interruption."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
# Delay to allow StopFrame to be queued
await asyncio.sleep(0.1)
# Push interruption - this should NOT discard the StopFrame
await self.push_frame(InterruptionFrame(), direction)
await self.push_frame(frame, direction)
class CaptureFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
received_frames.append(frame)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
StopFrame(),
]
expected_down_frames = [
InterruptionFrame,
TextFrame,
StopFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
send_end_frame=False,
)
# Verify StopFrame was received (survived interruption)
stop_frames = [f for f in received_frames if isinstance(f, StopFrame)]
self.assertEqual(len(stop_frames), 1, "StopFrame should survive interruption")
if __name__ == "__main__":
unittest.main()