Skip to content

fix(rtc): offload FFI handle disposal to thread in aclose and add sync close - #872

Open
NabiBukhsh-AI wants to merge 1 commit into
livekit:mainfrom
NabiBukhsh-AI:fix/832-audio-source-aclose-hang
Open

NabiBukhsh-AI wants to merge 1 commit into
livekit:mainfrom
NabiBukhsh-AI:fix/832-audio-source-aclose-hang

Conversation

@NabiBukhsh-AI

Copy link
Copy Markdown
Contributor

Fixes #832

Problem

In AudioSource, VideoSource, AudioStream, and VideoStream, aclose() is declared async, but it directly executed self._ffi_handle.dispose() synchronously on the event loop thread. Because handle disposal invokes the native C FFI call livekit_ffi_drop_handle, any stall or wait in native teardown blocked the asyncio event loop and stalled other tasks in the process until a watchdog fired.

Additionally:

  • AudioSource, VideoSource, AudioStream, and VideoStream lacked a synchronous close() method, unlike DataStream and DataTrack, making it difficult to cleanly close resources in synchronous contexts outside of an event loop.
  • In AudioSource, calling aclose() did not release pending wait_for_playout() futures or cancel the scheduled _join_handle timer callback.
  • In VideoSource.capture_frame(), frames pushed after disposal were not guarded against calling the FFI with a disposed handle.

Solution

  1. Non-blocking aclose(): Run self._ffi_handle.dispose() via asyncio.to_thread in AudioSource.aclose(), VideoSource.aclose(), AudioStream.aclose(), and VideoStream.aclose(). This ensures the synchronous native drop never blocks the asyncio event loop.
  2. Synchronous close(): Added synchronous close() methods to AudioSource, VideoSource, AudioStream, and VideoStream for parity with DataStream / DataTrack.
  3. Playout waiter & timer cleanup: In AudioSource._release_waiter(), explicitly cancel self._join_handle if active. Call _release_waiter() from both close() and aclose() so tasks awaiting wait_for_playout() are promptly unblocked.
  4. Disposal guard: Added if self._ffi_handle.disposed: return guard to VideoSource.capture_frame() (mirroring AudioSource).

Verification

  • Added regression tests in livekit-rtc/tests/test_source_stream_close.py:
    • Verified AudioSource.aclose() releases pending wait_for_playout waiters and cancels active timer handles.
    • Verified AudioSource.close() synchronous cleanup.
    • Verified capturing frames after close is a safe no-op.
    • Verified VideoSource.close() and aclose(), and that post-close capture is safely ignored.
    • Verified closing is idempotent across all classes.
  • Ran test suite and verified all passed: uv run pytest livekit-rtc/tests/test_source_stream_close.py
  • Verified type check with uv run mypy livekit-protocol livekit-api livekit-rtc (106 files clean).
  • Verified code formatting and linting with uv run ruff check and uv run ruff format.

…c close (livekit#832)

- In AudioSource, AudioStream, VideoSource, and VideoStream, add a synchronous close() method.
- In AudioSource, VideoSource, AudioStream, and VideoStream, run self._ffi_handle.dispose() via asyncio.to_thread in aclose() so the synchronous native drop call does not block the asyncio event loop.
- In AudioSource, cancel any pending _join_handle timer and resolve _join_fut when close() or aclose() is called so waiters on wait_for_playout are immediately unblocked.
- In VideoSource.capture_frame, safely ignore frames if the handle is already disposed.

Fixes livekit#832

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +322 to +325
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()

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.

🔴 Synchronous close strands stream tasks

When close() runs before EOS, both streams unsubscribe while _run still waits for events. The task remains pending, retaining resources, and a later aclose() waits forever.

Learn more

A stream owns a background _run task that waits on its FFI subscription for frame or end-of-stream events. Synchronous close() removes that subscription immediately after dropping the native handle. If the task has not already handled EOS, it can no longer receive that event and stays pending. The same sequence applies to VideoStream.close. The pending task keeps its stream alive; AudioStream.aclose and VideoStream.aclose then wait on that task without completing.

Example: A user creates a stream while connected to a track, calls stream.close() before the track ends, then calls await stream.aclose(). The event subscription has gone away, so _run never sees EOS and aclose() never returns.

Recommended fix: Make both synchronous close methods terminate or wake _run before unsubscribing. Also arrange a terminal signal for consumers waiting on __anext__, and make subsequent aclose() calls able to join the terminated task.

Devin Review


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

Comment on lines +185 to 186
self._release_waiter()
self._ffi_handle.dispose()

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.

🟡 Cross-thread audio close can strand playout

If close() runs outside the source’s active loop, _release_waiter completes a loop-owned future from the wrong thread. Pending wait_for_playout() calls can remain blocked, and debug mode raises RuntimeError.

Learn more

An AudioSource binds 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 calls set_result directly 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, or close() raises before disposing the handle.

Recommended fix: Dispatch waiter and timer cleanup through self._loop.call_soon_threadsafe when outside the loop thread, while preserving synchronous disposal and ensuring close cannot race with a pending timer callback.

Devin Review


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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AudioSource.aclose() can hang forever in livekit_ffi_drop_handle` after the track is unpublished, and blocks the event loop while it does

1 participant