Repository navigation
fix(rtc): offload FFI handle disposal to thread in aclose and add sync close #872
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -315,15 +315,24 @@ async def _run(self) -> None: | |
|
|
||
| FfiClient.instance.queue.unsubscribe(self._ffi_queue) | ||
|
|
||
| def close(self) -> None: | ||
| """Synchronously close the audio stream and release its resources.""" | ||
| if self._track is not None: | ||
| self._track._unregister_audio_stream(self) | ||
| self._ffi_handle.dispose() | ||
| FfiClient.instance.queue.unsubscribe(self._ffi_queue) | ||
| if self._processor is not None and self._processor_auto_close: | ||
| self._processor._close() | ||
|
Comment on lines
+322
to
+325
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Synchronous close strands stream tasks When Learn moreA stream owns a background Example: A user creates a stream while connected to a track, calls Recommended fix: Make both synchronous close methods terminate or wake Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| async def aclose(self) -> None: | ||
| """Asynchronously close the audio stream. | ||
|
|
||
| This method cleans up resources associated with the audio stream and waits for | ||
| any pending operations to complete. | ||
| any pending operations to complete without blocking the event loop. | ||
| """ | ||
| if self._track is not None: | ||
| self._track._unregister_audio_stream(self) | ||
| self._ffi_handle.dispose() | ||
| await asyncio.to_thread(self._ffi_handle.dispose) | ||
| await self._task | ||
| if self._processor is not None and self._processor_auto_close: | ||
| self._processor._close() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # Copyright 2026 LiveKit, Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
|
|
||
| import pytest | ||
|
|
||
| from livekit import rtc | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_audio_source_aclose_releases_waiter_and_cancels_timer() -> None: | ||
| source = rtc.AudioSource(48000, 1) | ||
| frame = rtc.AudioFrame.create(48000, 1, 48000) | ||
| await source.capture_frame(frame) | ||
|
|
||
| assert source._join_fut is not None | ||
| assert source._join_handle is not None | ||
|
|
||
| await source.aclose() | ||
|
|
||
| assert source._ffi_handle.disposed | ||
| assert source._join_fut is None | ||
| assert source._join_handle is None | ||
|
|
||
| # After aclose, wait_for_playout should return immediately without hanging | ||
| await source.wait_for_playout() | ||
|
|
||
| # Repeated calls are idempotent | ||
| await source.aclose() | ||
|
|
||
|
|
||
| def test_audio_source_sync_close() -> None: | ||
| source = rtc.AudioSource(48000, 1) | ||
| assert not source._ffi_handle.disposed | ||
|
|
||
| source.close() | ||
| assert source._ffi_handle.disposed | ||
|
|
||
| # Repeated close is idempotent | ||
| source.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_audio_source_capture_after_close_is_noop() -> None: | ||
| source = rtc.AudioSource(48000, 1) | ||
| source.close() | ||
|
|
||
| frame = rtc.AudioFrame.create(48000, 1, 480) | ||
| # Should safely return without error | ||
| await source.capture_frame(frame) | ||
|
|
||
|
|
||
| def test_video_source_sync_close() -> None: | ||
| source = rtc.VideoSource(640, 480) | ||
| assert not source._ffi_handle.disposed | ||
|
|
||
| source.close() | ||
| assert source._ffi_handle.disposed | ||
|
|
||
| # Repeated close is idempotent | ||
| source.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_video_source_aclose_and_capture_after_close() -> None: | ||
| source = rtc.VideoSource(640, 480) | ||
| assert not source._ffi_handle.disposed | ||
|
|
||
| await source.aclose() | ||
| assert source._ffi_handle.disposed | ||
|
|
||
| # Capture after close is safely ignored | ||
| frame = rtc.VideoFrame( | ||
| 640, | ||
| 480, | ||
| rtc.VideoBufferType.RGBA, | ||
| b"\x00" * (640 * 480 * 4), | ||
| ) | ||
| source.capture_frame(frame) | ||
|
|
||
| # Repeated aclose is idempotent | ||
| await source.aclose() |
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.
🟡 Cross-thread audio close can strand playout
If
close()runs outside the source’s active loop,_release_waitercompletes a loop-owned future from the wrong thread. Pendingwait_for_playout()calls can remain blocked, and debug mode raisesRuntimeError.Learn more
An
AudioSourcebinds to an event loop during construction, and capture_frame creates a future on that loop. A caller can use the new synchronous close method from another thread while that loop runs. _release_waiter callsset_resultdirectly rather than scheduling it on the owning loop. Asyncio debug mode rejects the cross-thread wakeup; without debug mode, the waiting loop need not wake promptly.Example: A service runs an audio source on a worker event loop and calls
source.close()from its management thread during playback. The playout waiter stays blocked, orclose()raises before disposing the handle.Recommended fix: Dispatch waiter and timer cleanup through
self._loop.call_soon_threadsafewhen outside the loop thread, while preserving synchronous disposal and ensuring close cannot race with a pending timer callback.Was this helpful? React with 👍 or 👎 to provide feedback.