From 1d86e8dfa9d5926fa72374174c63c620218aa896 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:09:20 +0200 Subject: [PATCH 01/13] fix(custom/graph): guard signal config with isInt() to avoid SIGABRT refresh() called config_["signal"].asInt() unconditionally on every RT signal; a non-integer "signal" value throws Json::LogicError and aborts Waybar. Matches the guard already present in custom/image/idle_inhibitor. Fixes #3514. --- src/modules/custom_graph.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/custom_graph.cpp b/src/modules/custom_graph.cpp index 0f236304..8b0c70af 100644 --- a/src/modules/custom_graph.cpp +++ b/src/modules/custom_graph.cpp @@ -139,7 +139,7 @@ void waybar::modules::CustomGraph::waitingWorker() { } void waybar::modules::CustomGraph::refresh(int sig) { - if (sig == SIGRTMIN + config_["signal"].asInt()) { + if (config_["signal"].isInt() && sig == SIGRTMIN + config_["signal"].asInt()) { thread_.wake_up(); } } From 4eb2513cc0d99d6ea0e326c230fa945810bd2e12 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:09:20 +0200 Subject: [PATCH 02/13] fix(hyprland/language): pass named args in format- branches The format-[-] override branches only passed a positional arg, so a format using {short}/{long}/{variant} threw 'argument not found', which disabled the whole module. Now supply the same named args as the fallback/tooltip branches. Fixes #5120. --- src/modules/hyprland/language.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/modules/hyprland/language.cpp b/src/modules/hyprland/language.cpp index 9c082d3a..cc9c6c59 100644 --- a/src/modules/hyprland/language.cpp +++ b/src/modules/hyprland/language.cpp @@ -38,10 +38,18 @@ auto Language::update() -> void { std::string layoutName = std::string{}; if (config_.isMember("format-" + layout_.short_description + "-" + layout_.variant)) { const auto propName = "format-" + layout_.short_description + "-" + layout_.variant; - layoutName = fmt::format(fmt::runtime(format_), config_[propName].asString()); + layoutName = trim(fmt::format(fmt::runtime(format_), config_[propName].asString(), + fmt::arg("long", layout_.full_name), + fmt::arg("short", layout_.short_name), + fmt::arg("shortDescription", layout_.short_description), + fmt::arg("variant", layout_.variant))); } else if (config_.isMember("format-" + layout_.short_description)) { const auto propName = "format-" + layout_.short_description; - layoutName = fmt::format(fmt::runtime(format_), config_[propName].asString()); + layoutName = trim(fmt::format(fmt::runtime(format_), config_[propName].asString(), + fmt::arg("long", layout_.full_name), + fmt::arg("short", layout_.short_name), + fmt::arg("shortDescription", layout_.short_description), + fmt::arg("variant", layout_.variant))); } else { layoutName = trim(fmt::format(fmt::runtime(format_), fmt::arg("long", layout_.full_name), fmt::arg("short", layout_.short_name), From b5842d4f53d3aa06d4e07b86706da403b3d7ae2f Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:09:20 +0200 Subject: [PATCH 03/13] fix(core): don't floor an explicit interval:0 to 1ms std::max(1L, interval*1000) turned a user's explicit "interval": 0 into a 1ms periodic refresh, i.e. a ~1000x/s busy loop that starves the GTK main loop and leaks memory (mpris RSS growth, missing tooltips, frozen updates). An explicit 0 now stays the 'no periodic refresh' sentinel. Fixes #4987, #4842; helps #4864, #4917, #4998, #5145. --- src/ALabel.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ALabel.cpp b/src/ALabel.cpp index f7a98329..5db03457 100644 --- a/src/ALabel.cpp +++ b/src/ALabel.cpp @@ -25,8 +25,14 @@ ALabel::ALabel(const Json::Value& config, const std::string& name, const std::st ? std::chrono::milliseconds::max() : std::chrono::milliseconds( (config_["interval"].isNumeric() - ? std::max(1L, // Minimum 1ms due to millisecond precision - static_cast(config_["interval"].asDouble() * 1000)) + ? (config_["interval"].asDouble() > 0 + // Minimum 1ms due to millisecond precision + ? std::max(1L, static_cast( + config_["interval"].asDouble() * 1000)) + // An explicit interval of 0 means "no periodic refresh" + // (event-driven only). Flooring it to 1ms busy-loops the + // main thread; keep it as the 0 sentinel (see custom.cpp). + : 0L) : 1000 * (long)interval))), default_format_(format_) { label_.set_name(name); From bfe38bd1e2f7f76cb86cc30e626ff33e12f2d091 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:09:20 +0200 Subject: [PATCH 04/13] fix(custom): validate JSON output UTF-8 and stop restart-interval:0 busy loop - parseOutputJson() passed script text/alt/tooltip straight to Pango/GTK; an invalid-UTF-8 byte aborted the bar in g_utf8_collate. Validate/make_valid like parseOutputRaw already does. Fixes #2829. - restart-interval:0 was floored to 1ms, respawning the script ~1000x/s and starving the main loop; a non-positive restart-interval now stops instead. Part of #4842. --- src/modules/custom.cpp | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/modules/custom.cpp b/src/modules/custom.cpp index 01781036..e27802d2 100644 --- a/src/modules/custom.cpp +++ b/src/modules/custom.cpp @@ -105,7 +105,8 @@ void waybar::modules::Custom::continuousWorker() { dp.emit(); spdlog::error("{} stopped unexpectedly, is it endless?", name_); } - if (config_["restart-interval"].isNumeric()) { + if (config_["restart-interval"].isNumeric() && + config_["restart-interval"].asDouble() > 0) { pid_ = -1; thread_.sleep_for(std::chrono::milliseconds( std::max(1L, // Minimum 1ms due to millisecond precision @@ -115,6 +116,8 @@ void waybar::modules::Custom::continuousWorker() { throw std::runtime_error("Unable to open " + cmd); } } else { + // A non-positive restart-interval must not busy-respawn the script + // (that starves the GTK main loop); treat it as "do not restart". thread_.stop(); return; } @@ -316,22 +319,32 @@ void waybar::modules::Custom::parseOutputJson() { std::istringstream output(output_.out); std::string line; class_.clear(); + // A script can emit invalid UTF-8; passing it unchecked to Pango/GTK aborts + // the whole bar in g_utf8_* (see parseOutputRaw, which validates the same way). + auto sanitize = [](const std::string& s) -> Glib::ustring { + Glib::ustring value = s; + if (!value.validate()) { + value = value.make_valid(); + } + return value; + }; while (getline(output, line)) { auto parsed = parser_.parse(line); - if (config_["escape"].isBool() && config_["escape"].asBool()) { - text_ = Glib::Markup::escape_text(parsed["text"].asString()); + const bool escape = config_["escape"].isBool() && config_["escape"].asBool(); + if (escape) { + text_ = Glib::Markup::escape_text(sanitize(parsed["text"].asString())); } else { - text_ = parsed["text"].asString(); + text_ = sanitize(parsed["text"].asString()); } - if (config_["escape"].isBool() && config_["escape"].asBool()) { - alt_ = Glib::Markup::escape_text(parsed["alt"].asString()); + if (escape) { + alt_ = Glib::Markup::escape_text(sanitize(parsed["alt"].asString())); } else { - alt_ = parsed["alt"].asString(); + alt_ = sanitize(parsed["alt"].asString()); } - if (config_["escape"].isBool() && config_["escape"].asBool()) { - tooltip_ = Glib::Markup::escape_text(parsed["tooltip"].asString()); + if (escape) { + tooltip_ = Glib::Markup::escape_text(sanitize(parsed["tooltip"].asString())); } else { - tooltip_ = parsed["tooltip"].asString(); + tooltip_ = sanitize(parsed["tooltip"].asString()); } if (parsed["class"].isString()) { class_.push_back(parsed["class"].asString()); From d130ce6a664f79c4256b712049570e6bfd4a94af Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:10:55 +0200 Subject: [PATCH 05/13] fix(clock): disambiguate DST transitions to stop tooltip crash Building a zoned_time/zoned_seconds from a local_time throws ambiguous_local_time during the DST fall-back hour and nonexistent_local_time across the spring-forward gap. update() runs this every minute with the tooltip enabled by default and has no try/catch, so Waybar aborts every minute during a DST transition. Pass choose::earliest at each construction to resolve deterministically instead of throwing. Fixes #2615; resolves the recurring DST-crash duplicates #5006, #5018, #5063, #5096, #3024. --- src/modules/clock.cpp | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/modules/clock.cpp b/src/modules/clock.cpp index 016d1d75..bd1a17d1 100644 --- a/src/modules/clock.cpp +++ b/src/modules/clock.cpp @@ -190,8 +190,13 @@ auto waybar::modules::Clock::update() -> void { if (tooltipEnabled()) { const year_month_day today{floor(now.get_local_time())}; const auto shiftedDay{today + cldCurrShift_}; + // choose::earliest disambiguates the DST fall-back hour (ambiguous local + // time) and skips forward over the spring-forward gap (nonexistent local + // time); without it this constructor throws and aborts Waybar every minute + // during a DST transition. Fixes #2615 (and its many duplicates). const zoned_time shiftedNow{ - tz, local_days(shiftedDay) + (now.get_local_time() - floor(now.get_local_time()))}; + tz, local_days(shiftedDay) + (now.get_local_time() - floor(now.get_local_time())), + choose::earliest}; if (tzInTooltip_) tzText_ = getTZtext(now.get_sys_time()); if (cldInTooltip_) cldText_ = get_calendar(today, shiftedDay, tz); @@ -441,9 +446,11 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea fmt_lib::make_format_args( (line == 2) ? static_cast( - zoned_seconds{tz, local_days{ymTmp / 1}}) - : static_cast(zoned_seconds{ - tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}}))) + zoned_seconds{tz, local_days{ymTmp / 1}, choose::earliest}) + : static_cast( + zoned_seconds{tz, + local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, + choose::earliest}))) << ' '; } else { os << pads; @@ -482,11 +489,11 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea << fmt_lib::vformat( m_locale_, fmtMap_[4], fmt_lib::make_format_args( - (line == 2) ? static_cast( - zoned_seconds{tz, local_days{ymTmp / 1}}) - : static_cast( - zoned_seconds{tz, local_days{cldGetWeekForLine( - ymTmp, firstdow, line)}}))); + (line == 2) ? static_cast(zoned_seconds{ + tz, local_days{ymTmp / 1}, choose::earliest}) + : static_cast(zoned_seconds{ + tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, + choose::earliest}))); else os << pads; } From c29ed5f97214d022322345f5de11dac2cad6cf8b Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:13:30 +0200 Subject: [PATCH 06/13] fix(client): don't exit when the desktop portal fails to start The Portal constructor synchronously auto-starts org.freedesktop.portal.Desktop via a Gio::DBus::Proxy. If that service fails or crashes on start it throws a Glib::Error, which was previously uncaught and terminated Waybar. Wrap the construction in a try/catch, log a warning and leave portal as nullptr on failure, and null-guard every dereference so a missing portal simply disables light/dark appearance detection instead of crashing. Fixes #3140, #3601. --- src/client.cpp | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/client.cpp b/src/client.cpp index 57e0fe5c..511e35af 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -211,7 +211,7 @@ const std::string waybar::Client::getStyle(const std::string& style, if (style.empty()) { std::vector search_files; - switch (appearance.value_or(portal->getAppearance())) { + switch (appearance.value_or(portal ? portal->getAppearance() : waybar::Appearance::UNKNOWN)) { case waybar::Appearance::LIGHT: search_files.emplace_back("style-light.css"); gtk_settings->property_gtk_application_prefer_dark_theme() = false; @@ -344,16 +344,26 @@ int waybar::Client::main(int argc, char* argv[]) { wl_display = gdk_wayland_display_get_wl_display(gdk_display->gobj()); config.load(config_opt); if (!portal) { - portal = std::make_unique(); + try { + portal = std::make_unique(); + } catch (const Glib::Error& e) { + spdlog::warn( + "Failed to connect to the desktop portal, light/dark theme detection disabled: {}", + std::string(e.what())); + } catch (...) { + spdlog::warn("Failed to connect to the desktop portal, light/dark theme detection disabled"); + } } m_cssFile = getStyle(style_opt); setupCss(m_cssFile); m_cssReloadHelper = std::make_unique(m_cssFile, [&](const std::string& css_file) { setupCss(css_file); }); - portal->signal_appearance_changed().connect([&](waybar::Appearance appearance) { - auto css_file = getStyle(style_opt, appearance); - m_cssReloadHelper->changeCssFile(css_file); - setupCss(css_file); - }); + if (portal) { + portal->signal_appearance_changed().connect([&](waybar::Appearance appearance) { + auto css_file = getStyle(style_opt, appearance); + m_cssReloadHelper->changeCssFile(css_file); + setupCss(css_file); + }); + } auto m_config = config.getConfig(); if (m_config.isObject() && m_config["reload_style_on_change"].asBool()) { @@ -378,5 +388,7 @@ int waybar::Client::main(int argc, char* argv[]) { void waybar::Client::reset() { gtk_app->quit(); // delete signal handler for css changes - portal->signal_appearance_changed().clear(); + if (portal) { + portal->signal_appearance_changed().clear(); + } } From c0a26104a59a37ebd9c11fe9233f975f09c7376e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:13:20 +0200 Subject: [PATCH 07/13] fix(sway/language): apply CSS classes on the main thread to stop SIGSEGV set_current_layout() mutated label_'s GTK style context (remove_class/ add_class) while being called from the sway IPC worker thread via onEvent(). Off-main-thread GTK widget mutation caused a SIGSEGV. Record only the target layout in set_current_layout() and apply the matching CSS class in update(), which the dispatcher runs on the GTK main thread. A new applied_class_ member tracks the currently applied class so update() can swap it. The shared layout_/applied_class_ state is guarded by the existing mutex_. Fixes #3702. --- include/modules/sway/language.hpp | 3 +++ src/modules/sway/language.cpp | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/include/modules/sway/language.hpp b/include/modules/sway/language.hpp index 470b9879..6ababa4f 100644 --- a/include/modules/sway/language.hpp +++ b/include/modules/sway/language.hpp @@ -54,6 +54,9 @@ class Language : public ALabel, public sigc::trackable { const static std::string XKB_ACTIVE_LAYOUT_NAME_KEY; Layout layout_; + // CSS class currently applied to label_. Tracked so update() (main thread) can swap classes + // instead of set_current_layout() mutating the widget from the IPC worker thread (#3702). + std::string applied_class_; std::string tooltip_format_ = ""; std::map layouts_map_; bool hide_single_; diff --git a/src/modules/sway/language.cpp b/src/modules/sway/language.cpp index 7113d9ef..c284e53c 100644 --- a/src/modules/sway/language.cpp +++ b/src/modules/sway/language.cpp @@ -116,6 +116,17 @@ void Language::onEvent(const struct Ipc::ipc_response& res) { auto Language::update() -> void { std::lock_guard lock(mutex_); + // Apply the CSS class here, on the GTK main thread. set_current_layout() runs on the IPC worker + // thread, so mutating label_'s style context there would crash (#3702). + if (layout_.short_name != applied_class_) { + if (!applied_class_.empty()) { + label_.get_style_context()->remove_class(applied_class_); + } + if (!layout_.short_name.empty()) { + label_.get_style_context()->add_class(layout_.short_name); + } + applied_class_ = layout_.short_name; + } if (hide_single_ && layouts_map_.size() <= 1) { event_box_.hide(); return; @@ -145,6 +156,10 @@ auto Language::update() -> void { } auto Language::set_current_layout(const std::string& current_layout) -> void { + // Runs on the IPC worker thread (via onEvent) as well as the main thread (via onCmd), so it must + // not touch GTK widgets - off-main-thread widget mutation caused SIGSEGV (#3702). Only record the + // target layout here; update() applies the matching CSS class on the main thread. + // // Guard against unknown / empty layout names: transient virtual keyboards (e.g. wtype) and // hot-plugged devices whose layouts haven't made it into the map yet would otherwise blank out // layout_ via map::operator[]'s default-construct-on-miss. @@ -152,9 +167,7 @@ auto Language::set_current_layout(const std::string& current_layout) -> void { if (it == layouts_map_.end()) { return; } - label_.get_style_context()->remove_class(layout_.short_name); layout_ = it->second; - label_.get_style_context()->add_class(layout_.short_name); } auto Language::init_layouts_map(const std::vector& used_layouts) -> void { From 4229dc8ac262d145b4aa4af3eb8d08f814dfd90a Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:12:46 +0200 Subject: [PATCH 08/13] fix(sway/window): resolve app icon on the main thread to stop icon-theme race onCmd() runs on the sway IPC worker thread and called updateAppIconName(), which touches the global Gtk::IconTheme cache. Concurrent access with the main thread's draw (propagate_draw -> gtk_icon_theme_has_icon -> g_hash_table_lookup) races and can segfault, notably on multi-monitor and focus changes. Move the icon-theme lookup into Window::update(), which runs on the main thread via dp.emit(), and only store app_id_/app_class_ in onCmd(). Fixes #4108. --- src/modules/sway/window.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/modules/sway/window.cpp b/src/modules/sway/window.cpp index 2b62da14..f005bd14 100644 --- a/src/modules/sway/window.cpp +++ b/src/modules/sway/window.cpp @@ -43,7 +43,9 @@ void Window::onCmd(const struct Ipc::ipc_response& res) { auto output = payload["output"].isString() ? payload["output"].asString() : ""; std::tie(app_nb_, floating_count_, windowId_, window_, app_id_, app_class_, shell_, layout_, marks_) = getFocusedNode(payload["nodes"], output); - updateAppIconName(app_id_, app_class_); + // Do not resolve the app icon here: onCmd runs on the sway IPC worker thread and + // updateAppIconName() touches the global Gtk::IconTheme cache, which is not thread-safe. + // The icon is resolved in update() on the main thread instead (triggered by dp.emit()). dp.emit(); } catch (const std::exception& e) { spdlog::error("Window: {}", e.what()); @@ -102,6 +104,9 @@ auto Window::update() -> void { setTooltipMarkup(window_); } + // Resolve the app icon on the main thread to avoid racing with GTK draw on the + // global Gtk::IconTheme cache (see onCmd). + updateAppIconName(app_id_, app_class_); updateAppIcon(); // Call parent update From d3bfec13cc9a1880221db14b9d8e9f43fbaf3c02 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:13:16 +0200 Subject: [PATCH 09/13] fix(keyboard-state): make device removal idempotent to stop libinput abort On device unplug the inotify IN_DELETE handler removed the libinput device and unref'd it before erasing the entry from libinput_devices_. A repeated IN_DELETE event for the same path (observed as the "has been removed" log line printed twice) could reach an already-unlinked device and trigger a libinput list_remove assertion abort. Erase the map entry first (under devices_mutex_) so a second delete for the same path is a no-op, then call libinput_path_remove_device() and libinput_device_unref() exactly once per device pointer. Fixes #5143, #4443, #4566. --- src/modules/keyboard_state.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/modules/keyboard_state.cpp b/src/modules/keyboard_state.cpp index 7e43c74c..458cf0af 100644 --- a/src/modules/keyboard_state.cpp +++ b/src/modules/keyboard_state.cpp @@ -268,10 +268,16 @@ waybar::modules::KeyboardState::KeyboardState(const std::string& id, const Bar& std::lock_guard lock(devices_mutex_); auto it = libinput_devices_.find(dev_path); if (it != libinput_devices_.end()) { - spdlog::info("Keyboard {} has been removed.", dev_path); - libinput_path_remove_device(it->second); - libinput_device_unref(it->second); + struct libinput_device* device = it->second; + // Erase from the map first so that a second IN_DELETE event for the + // same path becomes a no-op. This keeps removal idempotent and + // ensures libinput_path_remove_device()/libinput_device_unref() are + // called exactly once per device, avoiding a libinput list_remove + // assertion abort on double removal. libinput_devices_.erase(it); + spdlog::info("Keyboard {} has been removed.", dev_path); + libinput_path_remove_device(device); + libinput_device_unref(device); } } i += sizeof(struct inotify_event) + event->len; From 3831524ba81416ac5cbb25f0dca6eff5786943ac Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:13:37 +0200 Subject: [PATCH 10/13] fix(audio_backend): never throw across the PulseAudio callback boundary connectContext() throws std::runtime_error when pa_context_connect() fails. It was called directly from contextStateCb (the libpulse mainloop thread, running pure-C callback frames) on the PA_CONTEXT_FAILED reconnect path, so on a pipewire/pulse restart the exception unwound across the C callback boundary and triggered std::terminate/SIGABRT. Add reconnectContext() noexcept which wraps connectContext() and logs failures instead of throwing, and use it from the callback. Guard against the FAILED -> connect -> FAILED recursion/busy loop with a reentrancy flag. The constructor-time connectContext() still throws as before. Fixes #5141. --- include/util/audio_backend.hpp | 7 +++++++ src/util/audio_backend.cpp | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/include/util/audio_backend.hpp b/include/util/audio_backend.hpp index ec732e1e..2086e868 100644 --- a/include/util/audio_backend.hpp +++ b/include/util/audio_backend.hpp @@ -29,10 +29,17 @@ class AudioBackend { static void volumeModifyCb(pa_context*, int, void*); static void sourceVolumeModifyCb(pa_context*, int, void*); void connectContext(); + // Non-throwing reconnect used from the PulseAudio callback thread. Throwing + // across the libpulse C callback boundary calls std::terminate, so this + // swallows any failure and reports it via the return value instead. + bool reconnectContext() noexcept; pa_threaded_mainloop* mainloop_; pa_mainloop_api* mainloop_api_; pa_context* context_; + // Guards against the FAILED -> connect -> FAILED recursion / busy loop when a + // reconnect attempt fails synchronously inside pa_context_connect(). + bool reconnecting_{false}; pa_cvolume pa_volume_; pa_cvolume pa_source_volume_; diff --git a/src/util/audio_backend.cpp b/src/util/audio_backend.cpp index 482d7886..25ccde37 100644 --- a/src/util/audio_backend.cpp +++ b/src/util/audio_backend.cpp @@ -74,6 +74,22 @@ void AudioBackend::connectContext() { } } +// Reconnect the context without ever throwing. This is safe to call from within +// a PulseAudio state callback (which runs in pure C libpulse frames), where an +// escaping C++ exception cannot be unwound and would abort the process. +bool AudioBackend::reconnectContext() noexcept { + try { + connectContext(); + return true; + } catch (const std::exception& e) { + spdlog::error("PulseAudio reconnect failed: {}", e.what()); + return false; + } catch (...) { + spdlog::error("PulseAudio reconnect failed: unknown error"); + return false; + } +} + void AudioBackend::contextStateCb(pa_context* c, void* data) { auto* backend = static_cast(data); switch (pa_context_get_state(c)) { @@ -104,12 +120,29 @@ void AudioBackend::contextStateCb(pa_context* c, void* data) { // When pulseaudio server restarts, the connection is "failed". Try to reconnect. // pa_threaded_mainloop_lock is already acquired in callback threads. // So there is no need to lock it again. + // + // Guard against re-entrancy: pa_context_connect() can fire this callback + // synchronously with PA_CONTEXT_FAILED again, which would otherwise + // recurse (FAILED -> connect -> FAILED -> ...) and busy-loop. + if (backend->reconnecting_) { + break; + } if (backend->context_ != nullptr) { pa_context_disconnect(backend->context_); pa_context_unref(backend->context_); backend->context_ = nullptr; } - backend->connectContext(); + backend->reconnecting_ = true; + // Never throw across the libpulse C callback boundary: a failed reconnect + // is logged and left for a later PA event to retry instead of aborting. + if (!backend->reconnectContext()) { + spdlog::warn("PulseAudio context reconnect failed; will retry on next event"); + if (backend->context_ != nullptr) { + pa_context_unref(backend->context_); + backend->context_ = nullptr; + } + } + backend->reconnecting_ = false; } break; case PA_CONTEXT_CONNECTING: From 6672e924df67ed08160033c8dc836d79850ccc8e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:13:42 +0200 Subject: [PATCH 11/13] fix(niri): reconnect IPC and stop per-event throttling to prevent freeze The niri IPC worker slept 1ms per event and never reconnected. Under an event burst the per-event cap back-pressures the socket, niri fills its send buffer and drops the stream; read_line then returns false, the detached thread exits and the module freezes permanently. Remove the per-event sleep so events drain as fast as they arrive, and wrap the socket setup and read loop in a reconnect loop that backs off and re-establishes the stream on drop. A running_ flag lets the thread exit cleanly on teardown. Fixes #5117. --- include/modules/niri/backend.hpp | 4 ++ src/modules/niri/backend.cpp | 86 ++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/include/modules/niri/backend.hpp b/include/modules/niri/backend.hpp index 07be039a..acbbf52e 100644 --- a/include/modules/niri/backend.hpp +++ b/include/modules/niri/backend.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -18,6 +19,7 @@ class EventHandler { class IPC { public: IPC(); + ~IPC(); void registerForIPC(const std::string& ev, EventHandler* ev_handler); void unregisterForIPC(EventHandler* handler); @@ -45,6 +47,8 @@ class IPC { util::JsonParser parser_; std::mutex callbackMutex_; std::list> callbacks_; + + std::atomic running_{true}; }; inline std::unique_ptr gIPC; diff --git a/src/modules/niri/backend.cpp b/src/modules/niri/backend.cpp index 0900e092..245bf2de 100644 --- a/src/modules/niri/backend.cpp +++ b/src/modules/niri/backend.cpp @@ -23,6 +23,8 @@ namespace waybar::modules::niri { IPC::IPC() { startIPC(); } +IPC::~IPC() { running_ = false; } + int IPC::connectToSocket() { const char* socket_path = getenv("NIRI_SOCKET"); @@ -55,40 +57,60 @@ int IPC::connectToSocket() { void IPC::startIPC() { // will start IPC and relay events to parseIPC - int socketfd = connectToSocket(); - - std::thread([this, socketfd]() { + std::thread([this]() { spdlog::info("Niri IPC starting"); - auto unix_istream = Gio::UnixInputStream::create(socketfd, true); - auto unix_ostream = Gio::UnixOutputStream::create(socketfd, false); - auto istream = Gio::DataInputStream::create(unix_istream); - auto ostream = Gio::DataOutputStream::create(unix_ostream); - - if (!ostream->put_string("\"EventStream\"\n") || !ostream->flush()) { - spdlog::error("Niri IPC: failed to start event stream"); - return; - } - - std::string line; - if (!istream->read_line(line) || line != R"({"Ok":"Handled"})") { - spdlog::error("Niri IPC: failed to start event stream"); - return; - } - - while (istream->read_line(line)) { - spdlog::debug("Niri IPC: received {}", line); - + // Reconnect loop: if the event stream drops we back off briefly and + // re-establish the socket instead of leaving the module frozen forever. + while (running_) { + int socketfd; try { - parseIPC(line); + socketfd = connectToSocket(); } catch (std::exception& e) { - spdlog::warn("Failed to parse IPC message: {}, reason: {}", line, e.what()); - } catch (...) { - throw; + spdlog::error("Niri IPC: failed to connect: {}", e.what()); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; } - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + auto unix_istream = Gio::UnixInputStream::create(socketfd, true); + auto unix_ostream = Gio::UnixOutputStream::create(socketfd, false); + auto istream = Gio::DataInputStream::create(unix_istream); + auto ostream = Gio::DataOutputStream::create(unix_ostream); + + if (!ostream->put_string("\"EventStream\"\n") || !ostream->flush()) { + spdlog::error("Niri IPC: failed to start event stream"); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } + + std::string line; + if (!istream->read_line(line) || line != R"({"Ok":"Handled"})") { + spdlog::error("Niri IPC: failed to start event stream"); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } + + // Drain events as fast as they arrive; throttling here back-pressures the + // socket, fills niri's send buffer and makes niri drop the stream. + while (running_ && istream->read_line(line)) { + spdlog::debug("Niri IPC: received {}", line); + + try { + parseIPC(line); + } catch (std::exception& e) { + spdlog::warn("Failed to parse IPC message: {}, reason: {}", line, e.what()); + } catch (...) { + throw; + } + } + + if (!running_) break; + + spdlog::warn("Niri IPC: event stream closed, reconnecting"); + std::this_thread::sleep_for(std::chrono::seconds(2)); } + + spdlog::info("Niri IPC stopping"); }).detach(); } @@ -196,12 +218,12 @@ void IPC::parseIPC(const std::string& line) { for (auto& win : windows_) { win["is_focused"] = focused && win["id"].asUInt64() == id; } - } else if (const auto &payload = ev["WindowLayoutsChanged"]) { - const auto &values = payload["changes"]; - for (const auto &changed : values) { + } else if (const auto& payload = ev["WindowLayoutsChanged"]) { + const auto& values = payload["changes"]; + for (const auto& changed : values) { const auto id = changed[0].asUInt64(); - const auto &change = changed[1]; - for (auto &win : windows_) { + const auto& change = changed[1]; + for (auto& win : windows_) { if (win["id"].asUInt64() == id) { win["layout"] = change; break; From 5c06233340b0d3a5d3e5882e8d19e0216fa8a20f Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 03:17:33 +0200 Subject: [PATCH 12/13] style: clang-format the crash-fix changes --- src/modules/clock.cpp | 18 +++++++++--------- src/modules/custom.cpp | 3 +-- src/modules/hyprland/language.cpp | 20 ++++++++++---------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/modules/clock.cpp b/src/modules/clock.cpp index bd1a17d1..095ac403 100644 --- a/src/modules/clock.cpp +++ b/src/modules/clock.cpp @@ -447,10 +447,9 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea (line == 2) ? static_cast( zoned_seconds{tz, local_days{ymTmp / 1}, choose::earliest}) - : static_cast( - zoned_seconds{tz, - local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, - choose::earliest}))) + : static_cast(zoned_seconds{ + tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, + choose::earliest}))) << ' '; } else { os << pads; @@ -489,11 +488,12 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea << fmt_lib::vformat( m_locale_, fmtMap_[4], fmt_lib::make_format_args( - (line == 2) ? static_cast(zoned_seconds{ - tz, local_days{ymTmp / 1}, choose::earliest}) - : static_cast(zoned_seconds{ - tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, - choose::earliest}))); + (line == 2) + ? static_cast( + zoned_seconds{tz, local_days{ymTmp / 1}, choose::earliest}) + : static_cast(zoned_seconds{ + tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, + choose::earliest}))); else os << pads; } diff --git a/src/modules/custom.cpp b/src/modules/custom.cpp index e27802d2..83f36797 100644 --- a/src/modules/custom.cpp +++ b/src/modules/custom.cpp @@ -105,8 +105,7 @@ void waybar::modules::Custom::continuousWorker() { dp.emit(); spdlog::error("{} stopped unexpectedly, is it endless?", name_); } - if (config_["restart-interval"].isNumeric() && - config_["restart-interval"].asDouble() > 0) { + if (config_["restart-interval"].isNumeric() && config_["restart-interval"].asDouble() > 0) { pid_ = -1; thread_.sleep_for(std::chrono::milliseconds( std::max(1L, // Minimum 1ms due to millisecond precision diff --git a/src/modules/hyprland/language.cpp b/src/modules/hyprland/language.cpp index cc9c6c59..85adaee0 100644 --- a/src/modules/hyprland/language.cpp +++ b/src/modules/hyprland/language.cpp @@ -38,18 +38,18 @@ auto Language::update() -> void { std::string layoutName = std::string{}; if (config_.isMember("format-" + layout_.short_description + "-" + layout_.variant)) { const auto propName = "format-" + layout_.short_description + "-" + layout_.variant; - layoutName = trim(fmt::format(fmt::runtime(format_), config_[propName].asString(), - fmt::arg("long", layout_.full_name), - fmt::arg("short", layout_.short_name), - fmt::arg("shortDescription", layout_.short_description), - fmt::arg("variant", layout_.variant))); + layoutName = + trim(fmt::format(fmt::runtime(format_), config_[propName].asString(), + fmt::arg("long", layout_.full_name), fmt::arg("short", layout_.short_name), + fmt::arg("shortDescription", layout_.short_description), + fmt::arg("variant", layout_.variant))); } else if (config_.isMember("format-" + layout_.short_description)) { const auto propName = "format-" + layout_.short_description; - layoutName = trim(fmt::format(fmt::runtime(format_), config_[propName].asString(), - fmt::arg("long", layout_.full_name), - fmt::arg("short", layout_.short_name), - fmt::arg("shortDescription", layout_.short_description), - fmt::arg("variant", layout_.variant))); + layoutName = + trim(fmt::format(fmt::runtime(format_), config_[propName].asString(), + fmt::arg("long", layout_.full_name), fmt::arg("short", layout_.short_name), + fmt::arg("shortDescription", layout_.short_description), + fmt::arg("variant", layout_.variant))); } else { layoutName = trim(fmt::format(fmt::runtime(format_), fmt::arg("long", layout_.full_name), fmt::arg("short", layout_.short_name), From 45ab8a1211948b7dbee1e11e82d182ac24ce2a49 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Jul 2026 08:35:11 +0200 Subject: [PATCH 13/13] fix(core): fall back to default interval for periodic modules on interval:0 Addresses review: a zero interval_ must stay reserved for modules whose default interval is already 0 (event-driven). Periodic modules (clock, simpleclock, pollers) would otherwise do % interval_ (modulo by zero) or sleep_for(0) in a tight loop. interval:0 on a periodic module now falls back to its default interval. --- src/ALabel.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ALabel.cpp b/src/ALabel.cpp index 5db03457..84ac2052 100644 --- a/src/ALabel.cpp +++ b/src/ALabel.cpp @@ -29,10 +29,11 @@ ALabel::ALabel(const Json::Value& config, const std::string& name, const std::st // Minimum 1ms due to millisecond precision ? std::max(1L, static_cast( config_["interval"].asDouble() * 1000)) - // An explicit interval of 0 means "no periodic refresh" - // (event-driven only). Flooring it to 1ms busy-loops the - // main thread; keep it as the 0 sentinel (see custom.cpp). - : 0L) + // Only modules with no periodic default use 0 as an + // event-driven sentinel. Periodic modules fall back to their + // default interval so interval:0 cannot busy-loop or hit + // modulo-by-zero clock code. + : (interval == 0 ? 0L : 1000L * static_cast(interval))) : 1000 * (long)interval))), default_format_(format_) { label_.set_name(name);