From 268d8590435d3005307e1629eefa7424a5683ca4 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 13:04:02 +0200 Subject: [PATCH 1/3] fix(reload): reset pending bar-batch state on rebind On reload the GApplication is recreated but the default main context (and any queued PRIORITY_HIGH_IDLE createBarsBatch source) survives. pending_outputs_ was left holding dangling waybar_output* into the just-cleared outputs_ list; createBarsBatch filters by address, which can mis-match once a freed slot is reused. Clear pending_outputs_ and reset bars_scheduled_ in bindInterfaces so the next run batches from a clean state. Mitigates the dangling-pointer path of #4129 (the cross-process app-id race in #4117 is separate). --- src/client.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/client.cpp b/src/client.cpp index 511e35af..d09de5bd 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -286,6 +286,15 @@ void waybar::Client::bindInterfaces() { // Clear stale outputs from previous run outputs_.clear(); + // Also drop any batch state that was left pending from the previous run. On + // reload the GApplication is swapped but the default main context (and its + // queued PRIORITY_HIGH_IDLE createBarsBatch source) survives; pending_outputs_ + // would then hold dangling waybar_output* into the just-cleared outputs_ list, + // which createBarsBatch's address comparison can mis-match if the freed slot is + // reused. Reset so the next run schedules its batch from a clean state (#4129). + pending_outputs_.clear(); + bars_scheduled_ = false; + // add existing outputs and subscribe to updates for (auto i = 0; i < gdk_display->get_n_monitors(); ++i) { auto monitor = gdk_display->get_monitor(i); From 8664d9a9635b0ffc61f7480fa0566cd950d8d59a Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 12:56:57 +0200 Subject: [PATCH 2/3] fix(wireplumber): guard async load callbacks against use-after-free on teardown The WirePlumber module registers three async callbacks (onDefaultNodesApiLoaded, onMixerApiLoaded, onPluginActivated) that receive a raw self pointer with a NULL GCancellable. WirePlumber cannot withdraw an in-flight callback, so if the module is destroyed before a queued callback fires (e.g. a temporary output/bar is removed while a component load is still pending, or during an audio route transition), the callback dereferences the freed self, causing heap corruption / a crash. Guard each of these callbacks with isModuleAlive(), which checks the existing static modules registry. The destructor already removes this from the registry before any teardown, so a missing entry means self is dangling and the callback bails out without touching it. A GCancellable cannot fix this cleanly here: every callback dereferences self on its first line, and wp_core_load_component completes via a WpTransition (not a GTask), so the cancellable is not recoverable from the GAsyncResult either. The liveness check must not touch self at all. Fixes #3974. --- include/modules/wireplumber.hpp | 3 +++ src/modules/wireplumber.cpp | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/include/modules/wireplumber.hpp b/include/modules/wireplumber.hpp index 66900501..f1650e92 100644 --- a/include/modules/wireplumber.hpp +++ b/include/modules/wireplumber.hpp @@ -36,6 +36,9 @@ class Wireplumber : public ALabel { std::vector getWPIcon(); static std::list modules; + // Returns true while `self` is still a live module. Async load/activation callbacks use this to + // avoid dereferencing a `self` that was destroyed before the callback fired (see #3974). + static bool isModuleAlive(waybar::modules::Wireplumber* self); uint32_t resolvePhysicalSink(uint32_t start_id); uint32_t findPlaybackNodeId(const gchar* description); diff --git a/src/modules/wireplumber.cpp b/src/modules/wireplumber.cpp index b3167381..a2391071 100644 --- a/src/modules/wireplumber.cpp +++ b/src/modules/wireplumber.cpp @@ -10,6 +10,17 @@ bool isValidNodeId(uint32_t id) { return id > 0 && id < G_MAXUINT32; } std::list waybar::modules::Wireplumber::modules; +// Async load/activation callbacks (onDefaultNodesApiLoaded, onMixerApiLoaded, onPluginActivated) +// are handed a raw `self` pointer with no GCancellable, and WirePlumber has no way to withdraw an +// in-flight callback. If the module is destroyed before such a callback fires (e.g. an output/bar +// is removed while a component load is still pending, or during an audio route transition), the +// callback would dereference a freed `self`. The destructor removes `this` from this registry +// before any teardown, so a missing entry means `self` is dangling and the callback must bail out +// without touching it. See https://github.com/Alexays/Waybar/issues/3974. +bool waybar::modules::Wireplumber::isModuleAlive(waybar::modules::Wireplumber* self) { + return std::find(modules.begin(), modules.end(), self) != modules.end(); +} + waybar::modules::Wireplumber::Wireplumber(const std::string& id, const Json::Value& config) : ALabel(config, "wireplumber", id, "{volume}%"), wp_core_(nullptr), @@ -387,6 +398,10 @@ void waybar::modules::Wireplumber::onObjectManagerInstalled(waybar::modules::Wir void waybar::modules::Wireplumber::onPluginActivated(WpObject* p, GAsyncResult* res, waybar::modules::Wireplumber* self) { + if (!isModuleAlive(self)) { + return; + } + const auto* pluginName = wp_plugin_get_name(WP_PLUGIN(p)); spdlog::debug("[{}]: onPluginActivated: {}", self->name_, pluginName); g_autoptr(GError) error = nullptr; @@ -432,6 +447,10 @@ void waybar::modules::Wireplumber::prepare(waybar::modules::Wireplumber* self) { void waybar::modules::Wireplumber::onDefaultNodesApiLoaded(WpObject* p, GAsyncResult* res, waybar::modules::Wireplumber* self) { + if (!isModuleAlive(self)) { + return; + } + gboolean success = FALSE; g_autoptr(GError) error = nullptr; @@ -453,6 +472,10 @@ void waybar::modules::Wireplumber::onDefaultNodesApiLoaded(WpObject* p, GAsyncRe void waybar::modules::Wireplumber::onMixerApiLoaded(WpObject* p, GAsyncResult* res, waybar::modules::Wireplumber* self) { + if (!isModuleAlive(self)) { + return; + } + gboolean success = FALSE; g_autoptr(GError) error = nullptr; From c19abf373bceb3eb264690f94d367407d7133568 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 12:58:45 +0200 Subject: [PATCH 3/3] fix(mpris): defer widget visibility to update() to stop resume SIGSEGV The name-vanished playerctl callback called event_box_.set_visible(false) directly. On resume from suspend this runs in a re-entrant / torn-down state and crashes in Gtk::Widget::set_visible. Only clear the player and dp.emit() from the callback; update() (on the main thread) computes visibility from player state and hides the module when there is no player, matching the other hardened handlers. Fixes #5124. --- src/modules/mpris/mpris.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modules/mpris/mpris.cpp b/src/modules/mpris/mpris.cpp index 7eb11594..387e0597 100644 --- a/src/modules/mpris/mpris.cpp +++ b/src/modules/mpris/mpris.cpp @@ -406,8 +406,11 @@ auto Mpris::onPlayerNameVanished(PlayerctlPlayerManager* manager, PlayerctlPlaye if (mpris->player_ == "playerctld") { mpris->dp.emit(); } else if (mpris->player_ == player_name->name) { + // Don't touch GTK widgets directly from the playerctl callback: on resume + // from suspend this can run in a re-entrant / torn-down state and crash in + // Gtk::Widget::set_visible. Only update state + emit; update() (on the main + // thread) hides the module when there is no player. See #5124. mpris->player = nullptr; - mpris->event_box_.set_visible(false); mpris->dp.emit(); } }