Skip to content

Fix use-after-free freeze in Linux TaskRunnerLinux::EnqueueTask - #1177

Open
p-divita-salto wants to merge 2 commits into
livekit:mainfrom
p-divita-salto:fix/linux-taskrunner-use-after-free
Open

Fix use-after-free freeze in Linux TaskRunnerLinux::EnqueueTask#1177
p-divita-salto wants to merge 2 commits into
livekit:mainfrom
p-divita-salto:fix/linux-taskrunner-use-after-free

Conversation

@p-divita-salto

Copy link
Copy Markdown

On Linux, the app can freeze completely if an AudioRendererSink (or VisualizerSink) is torn down via stopAudioRenderer/stopVisualizer while an audio frame's task is still queued for dispatch on the GLib main loop.
The freeze is permanent and the whole UI thread hangs.

A gdb thread dump at the moment of freeze showed the main/GTK thread permanently blocked:

  #5 std::mutex::lock                                                                                                                                                                                                                                                        
  #7 livekit_client_plugin::TaskRunnerLinux::EnqueueTask(...)::$_0::operator()                                                                                                                                                                                               
     at linux/task_runner_linux.cc:19                                                                                                                                                                                                                                        
  #11 g_main_context_iteration

No other thread was holding the contended mutex — the signature of a stale pointer rather than live contention.

AudioRendererSink::PostEvent() calls TaskRunnerLinux::EnqueueTask(), which schedules a GLib idle callback via g_main_context_invoke(context, callback, this), capturing a raw TaskRunnerLinux*.

stopAudioRenderer destroys the owning sink (and its TaskRunnerLinux) synchronously:

it->second->RemoveSink();
renderers_.erase(it);

If an idle callback was already scheduled but had not yet run at that moment, the main loop later dispatches it against freed memory, locking a std::mutex inside deallocated memory is undefined behavior, which manifests here as a permanent futex wait.

My fix works as follow:

  1. TaskRunnerLinux now inherits std::enable_shared_from_this and is held via std::shared_ptr (both AudioRendererSink and VisualizerSink share this ownership pattern and exposure).
  2. EnqueueTask passes a heap-allocated std::weak_ptr through g_main_context_invoke_full (instead of a raw pointer through g_main_context_invoke), freeing it via the accompanying GDestroyNotify.
  3. The dispatched callback locks the weak pointer and no-ops if the runner has already been destroyed, instead of touching freed memory.

I reproduced and verified fixed on Linux: start a call, enable an audio renderer (I have a captions/transcription use case), talk for a while so several frames are queued, then tear down the renderer and end the call: no longer freezes.

I ran repeatedly without reproduction after the fix; reproduced reliably before it

scognito and others added 2 commits August 20, 2026 13:03
On Linux, enabling captions during a call keeps an AudioRendererSink
attached to the remote audio track for the call's duration. Every
audio frame posts a task via TaskRunnerLinux::EnqueueTask, which
scheduled a GLib idle callback through g_main_context_invoke capturing
a raw `this` pointer.

stopAudioRenderer destroys the owning sink (and its TaskRunnerLinux)
synchronously via RemoveSink() + renderers_.erase(it). If an idle
callback was already scheduled but hadn't run yet at that moment, the
main loop later dispatched it against freed memory, locking a
std::mutex inside a deallocated TaskRunnerLinux. That reproduced as
the whole app freezing on hangup after a captioned call: a gdb thread
dump showed the main/GTK thread permanently blocked in
std::mutex::lock inside EnqueueTask's callback, with no other thread
holding the mutex — the signature of a stale pointer rather than live
contention.

TaskRunnerLinux now inherits enable_shared_from_this and is held via
shared_ptr (AudioRendererSink and VisualizerSink both share this
lifetime pattern). EnqueueTask passes a heap-allocated weak_ptr through
g_main_context_invoke_full instead of a raw pointer through
g_main_context_invoke, freeing it via the accompanying GDestroyNotify.
The dispatched callback locks the weak_ptr and no-ops if the runner
was already destroyed, instead of touching freed memory.

Verified on Linux: call, answer, enable captions, talk, hang up no
longer freezes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

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.

4 participants