-
Notifications
You must be signed in to change notification settings - Fork 5
Flow controller perf and close fix #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
NikolayShakin
merged 6 commits into
NikolayShakin:main
from
Salman778:flow-controller-perf-and-close-fix
Jun 21, 2026
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5c586c1
perf(flow_controller): replace bytearray local buffer with deque[bytes]
51beef8
fix(flow_controller): split close() into sync close + async aclose
9b472ec
perf(flow_controller): use loguru lazy formatting on hot-path logs
aa23bac
test(flow_controller): add unit tests for buffer drain and close split
d07984f
refactor(flow_controller): address review feedback
72de41d
add remote buffer utilization check
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| """Unit tests for FlowController. | ||
|
|
||
| Covers the deque-buffer drain semantics, drop_buffer state reset, | ||
| and that the async close(gracefully=True) yields the event loop so the | ||
| flow_control task can actually drain. | ||
| """ | ||
|
|
||
| import asyncio | ||
| from unittest.mock import AsyncMock, MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
| from pipecat_asterisk.transport.flow_controller import FlowController | ||
|
|
||
|
|
||
| PTIME_MS = 20 | ||
| PSIZE_BYTES = 640 # slin16 @ 20ms == 16000 Hz * 2 bytes/sample * 0.02 s | ||
|
|
||
|
|
||
| def _make_controller() -> FlowController: | ||
| """Build a controller with a mock websocket client. | ||
|
|
||
| The controller starts its `flow_control` task in `__init__`, so this | ||
| must be called from inside a running event loop. | ||
| """ | ||
| client = MagicMock() | ||
| client.send = AsyncMock() | ||
| return FlowController(ptime=PTIME_MS, psize=PSIZE_BYTES, websocket_client=client) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # __call__ / buffer accounting | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_call_appends_chunk_and_tracks_size(): | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"\x00" * 640) | ||
| fc(b"\x01" * 320) | ||
| assert fc._local_buffer_size == 960 | ||
| assert list(fc._local_buffer) == [b"\x00" * 640, b"\x01" * 320] | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_call_ignores_empty_chunk(): | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"") | ||
| assert fc._local_buffer_size == 0 | ||
| assert len(fc._local_buffer) == 0 | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # _pop_bytes drain semantics | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_pop_bytes_drains_whole_chunks(): | ||
| """Popping a budget that lines up on chunk boundaries pops chunks whole.""" | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"a" * 100) | ||
| fc(b"b" * 100) | ||
| fc(b"c" * 100) | ||
| out = fc._pop_bytes(200) | ||
| assert out == b"a" * 100 + b"b" * 100 | ||
| assert fc._local_buffer_size == 100 | ||
| assert list(fc._local_buffer) == [b"c" * 100] | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_pop_bytes_slices_head_chunk(): | ||
| """A budget smaller than the head chunk slices the head and leaves the tail.""" | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"abcdef") | ||
| out = fc._pop_bytes(3) | ||
| assert out == b"abc" | ||
| assert fc._local_buffer_size == 3 | ||
| assert list(fc._local_buffer) == [b"def"] | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_pop_bytes_handles_mix_of_whole_and_partial(): | ||
| """A budget that straddles a chunk boundary pops one whole chunk + slices the next.""" | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"a" * 100) | ||
| fc(b"b" * 100) | ||
| out = fc._pop_bytes(150) | ||
| assert out == b"a" * 100 + b"b" * 50 | ||
| assert fc._local_buffer_size == 50 | ||
| assert list(fc._local_buffer) == [b"b" * 50] | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_pop_bytes_caps_at_buffer_size(): | ||
| """Asking for more bytes than are buffered returns everything available.""" | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"hello") | ||
| out = fc._pop_bytes(1_000_000) | ||
| assert out == b"hello" | ||
| assert fc._local_buffer_size == 0 | ||
| assert len(fc._local_buffer) == 0 | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_pop_bytes_returns_empty_when_empty(): | ||
| fc = _make_controller() | ||
| try: | ||
| assert fc._pop_bytes(100) == b"" | ||
| assert fc._pop_bytes(0) == b"" | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # drop_buffer | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_drop_buffer_resets_deque_size_and_utilization(): | ||
| fc = _make_controller() | ||
| try: | ||
| fc(b"a" * 100) | ||
| fc(b"b" * 100) | ||
| fc._remote_buffer_utilization = 1234.5 | ||
| fc.drop_buffer() | ||
| assert fc._local_buffer_size == 0 | ||
| assert len(fc._local_buffer) == 0 | ||
| assert fc._remote_buffer_utilization == 0.0 | ||
| finally: | ||
| await fc.close() | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # close() | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_close_non_graceful_cancels_immediately(): | ||
| fc = _make_controller() | ||
| task = fc._flow_control | ||
| fc(b"x" * 1024) | ||
| await fc.close(gracefully=False) | ||
| with pytest.raises(asyncio.CancelledError): | ||
| await task | ||
| # Non-graceful close leaves the unsent buffer in place by design. | ||
| assert fc._local_buffer_size == 1024 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_close_graceful_yields_loop_and_drains(): | ||
| """The bug fix: close(gracefully=True) must `await asyncio.sleep`, not | ||
| `time.sleep`, so the flow_control task can actually run and drain the | ||
| buffer. We prove this by checking that close returns once | ||
| _local_buffer_size hits zero - which can only happen if flow_control | ||
| was given CPU during the wait. | ||
| """ | ||
| fc = _make_controller() | ||
| try: | ||
| # Buffer one chunk worth of audio. Low water mark logic in | ||
| # flow_control will dispatch it on the next tick. | ||
| fc(b"\x00" * PSIZE_BYTES) | ||
| assert fc._local_buffer_size == PSIZE_BYTES | ||
|
|
||
| # If close used time.sleep, this would hang the event loop until | ||
| # the cancellation, and flow_control would never get a chance to | ||
| # send. Cap the wait so a regression times out instead of hanging. | ||
| await asyncio.wait_for(fc.close(gracefully=True), timeout=1.0) | ||
|
|
||
| assert fc._local_buffer_size == 0 | ||
| fc._websocket_client.send.assert_awaited() | ||
| finally: | ||
| # Already cancelled above; second close() is a no-op safety net. | ||
| await fc.close() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One more thing. Could you please add
or self._remote_buffer_utilization > 0in the while loop to wait until the remote buffer is empty as well, because if we close the websocket connection right after we send everything for the buffer, @abalashov found that Asterisk will remove the channel from the bridge right away, so the user will not hear anything that we uploaded to the buffer at the very end.So if we want to play everything provided by TTS (which is gracefully), we need to wait until the remote buffer is empty.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious - are you using
self._remote_buffer_utilizationas a casual proxy for the status ofQUEUE_DRAINED? :)I started with that before moving onto
QUEUE_DRAINED, and it worked about as well. But of course, this is a Pipecat-side calculation...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer the pipecat-side calculation because it only depends on the correctness of the calculation algorithm. If we rely on
QUEUE_DRAINEDevents, we still depend on the algorithm remote_buffer_utilization calculation algorithm (based on it, we subscribe to QUEUE_DRAINED every time the remote buffer is empty and we start sending new media), but besides that, we depend on the correctness of many other logical paths and algorithms, including the logic of Asterisk. So I think local(pipecat-side) calculation is more robust.When (it's planned)
REPORT_QUEUE_DRAINED/QUEUE_DRAINEDsubscription is implemented, we can useQUEUE_DRAINEDmessages to sync (reset to 0) our locally-calculated remote buffer utilization and measure the drift between the effective and calculated remote buffer utilization.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's interesting. Yet in my use-case, as you know,
QUEUE_DRAINEDis considered the final word on "So if we want to play everything provided by TTS", but in this case, no?I ask the question because it would have been far easier to just trust the calculation algorithm and have the transport expose something from the serialiser to
while True: await asyncio.sleep(0.05)until the remote buffer drains. :-) There's a reason neither of us thought that was the best idea, so I am trying to tease out the philosophical difference between that case and this one.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, definitely there are more elegant options for waiting for the remote buffer to be empty; for instance, with
asyncio.event()and unlocking it from the main loop of the flow_controller. But we will still need to calculate remote buffer utilization to understand the moment when we need to sendREPORT_QUEUE_DRAINED. So the accuracy of the logic still boils down to the correctness of the remote buffer utilization algorithm, even if we considerQUEUE_DRAINEDthe final word.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will defer to your wisdom on this. However, it seems to me there is a conflict between two somewhat irreconcilable sources of truth here, and that the system should stick to one or the other.