From 6b44a5b15d9e3e7408f7aeaa95f8e641166f403e Mon Sep 17 00:00:00 2001 From: Piero Di Vita Date: Thu, 20 Aug 2026 11:32:30 +0200 Subject: [PATCH 1/3] Fix use-after-free freeze in TaskRunnerLinux::EnqueueTask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- linux/livekit_plugin.cpp | 8 ++++---- linux/task_runner_linux.cc | 33 +++++++++++++++++++++++---------- linux/task_runner_linux.h | 8 +++++++- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/linux/livekit_plugin.cpp b/linux/livekit_plugin.cpp index 4c29d5e92..158843c70 100644 --- a/linux/livekit_plugin.cpp +++ b/linux/livekit_plugin.cpp @@ -73,7 +73,7 @@ class VisualizerSink : public libwebrtc::AudioTrackSink { &flutter::StandardMethodCodec::GetInstance())), media_track_(media_track), is_centered_(is_centered), bar_count_(bar_count) { - task_runner_ = std::make_unique(); + task_runner_ = std::make_shared(); auto handler = std::make_unique< flutter::StreamHandlerFunctions>( [&](const flutter::EncodableValue *arguments, @@ -151,7 +151,7 @@ class VisualizerSink : public libwebrtc::AudioTrackSink { private: std::unique_ptr audio_visualizer_; - std::unique_ptr task_runner_; + std::shared_ptr task_runner_; std::unique_ptr> channel_; std::shared_ptr> sink_; std::list event_queue_; @@ -172,7 +172,7 @@ class AudioRendererSink : public libwebrtc::AudioTrackSink { messenger, event_channel_name, &flutter::StandardMethodCodec::GetInstance())), media_track_(media_track), format_(format) { - task_runner_ = std::make_unique(); + task_runner_ = std::make_shared(); auto handler = std::make_unique< flutter::StreamHandlerFunctions>( [&](const flutter::EncodableValue *arguments, @@ -250,7 +250,7 @@ class AudioRendererSink : public libwebrtc::AudioTrackSink { } } - std::unique_ptr task_runner_; + std::shared_ptr task_runner_; std::unique_ptr> channel_; std::shared_ptr> sink_; bool on_listen_called_ = false; diff --git a/linux/task_runner_linux.cc b/linux/task_runner_linux.cc index 4beb298b4..ca7c2616e 100644 --- a/linux/task_runner_linux.cc +++ b/linux/task_runner_linux.cc @@ -12,19 +12,32 @@ void TaskRunnerLinux::EnqueueTask(TaskClosure task) { GMainContext* context = g_main_context_default(); if (context) { - g_main_context_invoke( - context, - [](gpointer user_data) -> gboolean { - TaskRunnerLinux* runner = static_cast(user_data); - std::lock_guard lock(runner->tasks_mutex_); - while (!runner->tasks_.empty()) { - TaskClosure task = std::move(runner->tasks_.front()); - runner->tasks_.pop(); - task(); + // A weak_ptr (not `this`) is passed through so that if the runner is + // destroyed before the main loop dispatches this callback — e.g. the + // owning sink is torn down while an audio frame's task is still queued — + // the callback safely no-ops instead of locking a mutex inside freed + // memory. g_main_context_invoke_full's notify always runs exactly once, + // whether the callback fired inline or via the idle source, so the + // heap-allocated weak_ptr is never leaked. + auto* weak_self = new std::weak_ptr(weak_from_this()); + g_main_context_invoke_full( + context, G_PRIORITY_DEFAULT, + [](gpointer user_data) -> gboolean { + auto* weak_self = static_cast*>(user_data); + if (auto runner = weak_self->lock()) { + std::lock_guard lock(runner->tasks_mutex_); + while (!runner->tasks_.empty()) { + TaskClosure task = std::move(runner->tasks_.front()); + runner->tasks_.pop(); + task(); + } } return G_SOURCE_REMOVE; }, - this); + weak_self, + [](gpointer user_data) { + delete static_cast*>(user_data); + }); } } diff --git a/linux/task_runner_linux.h b/linux/task_runner_linux.h index 9cd400a1d..ebc8be9ee 100644 --- a/linux/task_runner_linux.h +++ b/linux/task_runner_linux.h @@ -10,7 +10,13 @@ namespace livekit_client_plugin { -class TaskRunnerLinux { +// Owned via std::shared_ptr by its creator (never std::unique_ptr): EnqueueTask +// schedules a GLib idle callback that runs asynchronously on the main loop, and +// enable_shared_from_this lets that callback hold a weak reference so it can +// detect the runner having been destroyed in the meantime (e.g. stopAudioRenderer +// tearing down the sink while a callback is still queued) instead of dereferencing +// freed memory. +class TaskRunnerLinux : public std::enable_shared_from_this { public: TaskRunnerLinux() = default; ~TaskRunnerLinux() = default; From e3951a1e3eb29ff639aebfb161a552f3da63f88c Mon Sep 17 00:00:00 2001 From: Piero Di Vita Date: Thu, 20 Aug 2026 14:14:50 +0200 Subject: [PATCH 2/3] Add changeset for TaskRunnerLinux use-after-free fix Co-Authored-By: Claude Sonnet 5 --- .changes/linux-taskrunner-use-after-free | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/linux-taskrunner-use-after-free diff --git a/.changes/linux-taskrunner-use-after-free b/.changes/linux-taskrunner-use-after-free new file mode 100644 index 000000000..5c0131b46 --- /dev/null +++ b/.changes/linux-taskrunner-use-after-free @@ -0,0 +1 @@ +patch type="fixed" "Fix use-after-free crash in TaskRunnerLinux::EnqueueTask when the runner is destroyed before the main loop dispatches a queued task" \ No newline at end of file From a8a687d495f3ca3d32c451aa4485b6aa89693b87 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:34:25 +0800 Subject: [PATCH 3/3] Run queued tasks outside the runner lock and tidy comments Swap the pending queue out under tasks_mutex_ and run the closures unlocked so a task that re-enters EnqueueTask cannot deadlock on the non-recursive mutex. Document why weak_from_this() is safe to call from the audio thread (RemoveSink blocks until in-flight OnData returns). Plain punctuation in comments, fix stray indentation on two includes, trailing newline on the changeset file. --- .changes/linux-taskrunner-use-after-free | 2 +- linux/task_runner_linux.cc | 30 +++++++++++++++++------- linux/task_runner_linux.h | 14 +++++------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.changes/linux-taskrunner-use-after-free b/.changes/linux-taskrunner-use-after-free index 5c0131b46..f3066b634 100644 --- a/.changes/linux-taskrunner-use-after-free +++ b/.changes/linux-taskrunner-use-after-free @@ -1 +1 @@ -patch type="fixed" "Fix use-after-free crash in TaskRunnerLinux::EnqueueTask when the runner is destroyed before the main loop dispatches a queued task" \ No newline at end of file +patch type="fixed" "Fix use-after-free crash in TaskRunnerLinux::EnqueueTask when the runner is destroyed before the main loop dispatches a queued task" diff --git a/linux/task_runner_linux.cc b/linux/task_runner_linux.cc index ca7c2616e..c0054fab8 100644 --- a/linux/task_runner_linux.cc +++ b/linux/task_runner_linux.cc @@ -13,22 +13,34 @@ void TaskRunnerLinux::EnqueueTask(TaskClosure task) { GMainContext* context = g_main_context_default(); if (context) { // A weak_ptr (not `this`) is passed through so that if the runner is - // destroyed before the main loop dispatches this callback — e.g. the - // owning sink is torn down while an audio frame's task is still queued — - // the callback safely no-ops instead of locking a mutex inside freed - // memory. g_main_context_invoke_full's notify always runs exactly once, - // whether the callback fired inline or via the idle source, so the + // destroyed before the main loop dispatches this callback, for example + // when the owning sink is torn down while an audio frame's task is still + // queued, the callback safely no-ops instead of locking a mutex inside + // freed memory. g_main_context_invoke_full's notify always runs exactly + // once, whether the callback fired inline or via the idle source, so the // heap-allocated weak_ptr is never leaked. + // + // weak_from_this() itself is only safe here because the owning sink is + // destroyed after the audio track's RemoveSink() returns, and RemoveSink() + // blocks until any in-flight OnData() (and therefore this call) has + // finished. Callers must keep that ordering. auto* weak_self = new std::weak_ptr(weak_from_this()); g_main_context_invoke_full( context, G_PRIORITY_DEFAULT, [](gpointer user_data) -> gboolean { auto* weak_self = static_cast*>(user_data); if (auto runner = weak_self->lock()) { - std::lock_guard lock(runner->tasks_mutex_); - while (!runner->tasks_.empty()) { - TaskClosure task = std::move(runner->tasks_.front()); - runner->tasks_.pop(); + // Take the whole batch under the lock, then run it unlocked so a + // task that re-enters EnqueueTask on this runner cannot deadlock + // on the non-recursive mutex. + std::queue pending; + { + std::lock_guard lock(runner->tasks_mutex_); + std::swap(pending, runner->tasks_); + } + while (!pending.empty()) { + TaskClosure task = std::move(pending.front()); + pending.pop(); task(); } } diff --git a/linux/task_runner_linux.h b/linux/task_runner_linux.h index ebc8be9ee..9d1ab3fa0 100644 --- a/linux/task_runner_linux.h +++ b/linux/task_runner_linux.h @@ -4,18 +4,18 @@ #include #include #include - #include - - using TaskClosure = std::function; +#include + +using TaskClosure = std::function; namespace livekit_client_plugin { -// Owned via std::shared_ptr by its creator (never std::unique_ptr): EnqueueTask +// Owned via std::shared_ptr by its creator (never std::unique_ptr). EnqueueTask // schedules a GLib idle callback that runs asynchronously on the main loop, and // enable_shared_from_this lets that callback hold a weak reference so it can -// detect the runner having been destroyed in the meantime (e.g. stopAudioRenderer -// tearing down the sink while a callback is still queued) instead of dereferencing -// freed memory. +// detect the runner having been destroyed in the meantime (for example +// stopAudioRenderer tearing down the sink while a callback is still queued) +// instead of dereferencing freed memory. class TaskRunnerLinux : public std::enable_shared_from_this { public: TaskRunnerLinux() = default;