The GtkBuilder created for menu construction was never unref'd on any
path (success or throw), leaking one builder per graph module with a
menu. Unref on each throw and at the end, and take an explicit ref on
menu_ so it survives dropping the builder (mirrors ALabel).
The per-menu-action string duplicated with g_strdup was never freed,
leaking one string per action on every menu build and reload. Use
g_signal_connect_data with (GClosureNotify)g_free so the copy is freed
when the closure is destroyed.
With a sink-mapping configured, sinkInfoCb could report the wrong sink's
volume depending on the order in which PulseAudio enumerated sinks during a
pa_context_get_sink_info_list sweep.
The mapping override was keyed on the mutable current_sink_name_ and ran
before the 'pick a running sink' fallback, which also mutated
current_sink_name_. If the default sink was running while the mapped target
was suspended, the fallback could reassign the selection to the default sink
after the mapping had already matched, so the reported sink depended on
enumeration order (and each sweep wrote the state twice, causing a flicker).
Resolve the target up front: key the mapping on the stable default_sink_name
and, when a mapping is in effect, treat the mapped target sink as the sole
definitive selection - every other sink is ignored and the running-sink
fallback is skipped. The default-sink + running-fallback behavior is
unchanged when no mapping applies. Verified in isolation across all sink
enumeration orders.
Every pa_operation* returned by the PulseAudio context introspection,
subscribe and volume/mute calls was discarded without pa_operation_unref,
leaking one operation object per call. Over a long session the periodic
subscription events accumulate an unbounded number of these handles.
Capture each returned handle and unref it (guarded against NULL) at every
discard site. Callback behavior is unchanged; these calls already run under
the threaded-mainloop lock, where unref is safe.
Turn the smoke test into a real runtime safety net. Waybar is now built with
AddressSanitizer and exercised end to end in a headless compositor:
- Tier 0: run everything under ASan; fail on ASan reports and Gtk/GLib
criticals in the log (lib.sh assert_clean)
- Tier 1: per-module render matrix (modules.sh) — each headless-safe module
rendered in isolation
- Tier 2: pointer interaction (interact.sh) — inject clicks via sway, assert
on-click side effect and format-alt toggle; AT-SPI assertions (a11y.sh/.py)
check module labels semantically instead of by pixels
- Tier 3: layout matrix (positions.sh) — top/bottom/left + HiDPI scale 2;
second compositor run under labwc
Shared helpers extracted to lib.sh. Adds a workflow_dispatch `bless` input to
regenerate the golden reference in one click. Trigger push only on master to
avoid duplicate PR runs. AT-SPI and labwc steps are continue-on-error.
MultipleImageStrategy::update() and handleClick() ran util::command::exec
(a blocking fork+exec+read) on the GTK main thread, so the whole bar froze
for the script's duration on every interval and on every click.
Move the exec into a new IStrategy::fetch() hook that the SleeperThread
worker runs before dp.emit(); update() now only parses the cached output
and draws on the main thread (mirroring how custom.cpp separates exec from
formatting). handleClick() uses forkExec() so clicks fire-and-forget instead
of blocking on the command's output. The entries and single-image paths are
unchanged.
g_variant_lookup with the "b" format writes a gboolean (gint, 4 bytes),
but muted_ and source_muted_ are C++ bool members (1 byte). Passing their
addresses caused a 3-byte out-of-bounds write past the member (undefined
behavior). Read into a gboolean temporary and assign back to the bool,
preserving the prior value when "mute" is absent.
A malformed user format or tooltip-format (unknown {placeholder}) made
fmt::format throw fmt::format_error out of update(). Wrap the label and
tooltip format calls in try/catch that warn once and fall back to a safe
label / skip the tooltip instead of taking the module down.
Only the label vformat was wrapped in try/catch. An unsupported specifier
(e.g. %-I / %OI) in tooltip-format or the calendar format still threw out
of update() every tick via the calendar/tooltip vformat calls. Wrap the
tooltip-building section in try/catch that warns once and skips the
tooltip for that tick instead of letting the exception escape update().
When playerctl_player_new_from_name() fails for a candidate player, the
loop continued without clearing the GError. The stale non-NULL error then
leaked into the next GLib call (GLib-CRITICAL assertion) and made the
post-loop 'if (error) goto errorexit' fire even when a valid playing
player had been selected, blanking the whole module. Clear the error at
the discard point with g_clear_error().
/sys/class/net/<if>/speed reports -1 with no carrier. Reading it into a
uint32_t wrapped to 4294967295 (without setting failbit), so {linkSpeed}
showed an absurd value. Read into int64_t, check fail(), and treat negative
or failed reads as 0.
setTooltipMarkup uses set_tooltip_markup without escaping. Raw window titles
routinely contain &, < and >, which break Pango markup parsing and the
tooltip. Escape the title with Glib::Markup::escape_text before passing it.
openDevice() throws without closing the fd if libevdev_new_from_fd fails.
In both update() and tryAddDevice() the outer catch only logged, so
closeFile(fd) was never reached and a descriptor leaked on every failing
tick. Guard openDevice with a try/catch that closes the fd before rethrowing.
The parser assumed each entry was '<dimension> <comparator> <value>'.
An entry with no space caused str.substr((size_t)-1) to throw out_of_range,
and a non-integer value made std::stoi throw invalid_argument, failing the
whole bar on that output. Validate spaces with find()!=npos and wrap stoi in
try/catch; log a warning and skip malformed entries instead of throwing.
With reveal-delay set, handleMouseEnter arms a Glib::signal_timeout that
captures 'this'. sigc::connection's destructor does not remove the GLib
source, so a Group destroyed with a pending reveal timer would fire the
timeout on freed memory. Add a destructor that disconnects reveal_timeout_.
The IPC event thread had no reconnect: on POLLHUP/POLLERR/POLLNVAL or
read()==0/error it broke out of the loop and the thread exited
permanently, freezing every mango module with stale content until
Waybar was restarted. Wrap the connect + poll/read loop in a reconnect
loop with a bounded 2s backoff, re-establishing the socket and resuming
on disconnect, modeled on the niri backend.
Add an atomic running_ flag so the worker exits cleanly on teardown;
the destructor now sets it false before closing the socket so the
worker breaks out and joins, and leaves the final close to the
destructor to avoid a double close.
Remove leftover unconditional assignment that clobbered the value
computed from rxkb_layout_get_brief() with short_name, which made
short_description always equal short_name and defeated
format-<shortDescription> / {shortDescription}.
The item-ordering feature made Host::reorderItems() re-run the full
remove/add path over items_ via std::ranges::for_each(on_remove_/on_add_).
This caused two confirmed bugs:
BUG 1 (iterator invalidation / UAF): on_add_ (Tray::onAdd) calls
Host::checkIgnoreList, which erases from items_ while for_each is still
iterating items_, invalidating iterators/pointers. Triggered by a
non-empty ignore-list matching an item with >=2 items present.
BUG 2 (double add): reorderItems runs while an item's Id is resolved in
proxyReady, i.e. before setReady(). It added the not-yet-ready item
(re-parenting its event_box, pushing into Tray::items_, connecting
signal_show/hide), then setReady() -> itemReady -> onAdd added it again:
GTK 'widget already has a parent' critical, duplicate Item* and
signal handlers that accumulated unbounded.
Fixes:
- reorderItems() now only reorders already-added GTK box children via a
dedicated on_reorder_ callback (Tray::reorderBox), never re-adding or
removing. reorderBox stable-sorts items_ by order_ and repositions
children with gtk_box_reorder_child (honouring reverse-direction).
- Tray::onAdd is idempotent (guards against an already-added item) and
positions the new widget via reorderBox before the ignore-list check.
- signal_show/signal_hide connections are stored per item and
disconnected in Tray::onRemove; onRemove is a no-op for items that were
never added.
isLuaProtocol() probed the protocol by sending a real
"dispatch workspace __waybar_probe__". On Hyprland < 0.54 "workspace"
is a valid dispatcher, so the probe actually switched the user to a junk
workspace named __waybar_probe__ on the first workspace click/scroll.
Detect the protocol with the read-only "version" IPC query instead:
parse the numeric "version" field (falling back to the always-present
"tag" field) and treat Hyprland >= 0.54 as Lua. This has no side
effects. On any parse/query failure we log and fall back to the legacy
protocol, preserving prior behavior for older versions.
The debounce timer added for flicker prevention was armed from the IPC
listener thread via Glib::signal_timeout().connect(), while its timeout
lambda and the m_updatePending flag ran on the GTK main thread — an
unsynchronized cross-thread data race on GLib timer/main-loop state.
Additionally ~Workspaces() never disconnected the timer, so a pending
timeout could fire on a freed 'this' (use-after-free).
Restore the pre-refactor threading model: onEvent now only mutates state
under m_mutex on the IPC thread and calls dp.emit() (Glib::Dispatcher is
thread-safe). The debounce timer is owned entirely by the main-thread
update() path, which arms/re-arms it on each dispatch and coalesces
bursts into a single refresh. ~Workspaces() disconnects the timer
(guarded) so none outlives the object. Debounce behavior is preserved.
The destructor unconditionally unref'd current_modem, manager and
connection, but the constructor can leave them NULL or already-unref'd:
- On the mm_manager_new_sync failure path the ctor unref'd connection
without nulling it, so the dtor unref'd it a second time -> double-free.
- On the g_bus_get_sync failure path all three stay NULL, and in the
common no-WWAN-hardware case current_modem is NULL, so the dtor ran
g_object_unref(NULL) -> G_IS_OBJECT assertion criticals.
Use g_clear_object() in the failing ctor path (unref + null) and in the
destructor (NULL-safe unref + null). Teardown is now safe for every ctor
outcome (bus fail, MM fail, no modem, normal), and a normal run still
unrefs each owned ref exactly once.
IPC::send() wrapped the socket fd in a util::ScopedFd, which closes the
fd in its destructor. The input stream was created with close_fd=true,
so the stream also closed the same fd, resulting in a double-close. In
multithreaded Waybar another thread can open a new fd with the same
number between the two close() calls, which the second close() then
wrongly closes. Pass close_fd=false so ScopedFd is the sole owner and
the fd is closed exactly once. The streams are declared after socketfd,
so they flush and destruct while the fd is still open, then ScopedFd
closes it.
Bring src/modules/custom_graph.cpp in line with the hardened custom.cpp:
- continuousWorker: on the restart path, an open() failure threw
std::runtime_error out of the SleeperThread lambda, which escaped the
thread and called std::terminate, killing all of Waybar. Log the error
and stop the worker gracefully instead of throwing.
- parseOutputJson: validate/make_valid the text/alt/tooltip JSON string
fields before they reach fmt markup / set_tooltip_markup. Invalid UTF-8
from a script otherwise aborts the bar in g_utf8_* (parseOutputRaw
already validated the same way).
- refresh: wrap the SIGRTMIN-based signal check in #ifdef SIGRTMIN so the
module builds on platforms without SIGRTMIN (e.g. some BSDs).
Fixes a std::terminate crash on continuous-exec restart failure, an
invalid-UTF-8 bar abort via JSON output, and a build break on platforms
lacking SIGRTMIN.
toggleSuspend dynamic_cast<AModule*>-ed the children of the left/center/right
Gtk::Box. But modules are packed via AModule::operator Gtk::Widget&(), which
returns the member event_box_, so every box child is a Gtk::EventBox and the
cast is always null -- suspend()/resume() never ran, making disable-on-sleep a
silent no-op. Iterate modules_all_ (the real module pointers) instead.
Fixes disable-on-sleep DPMS suspend/resume never firing.
handleUserEvent ran the configured command through fmt::format(fmt::runtime(...))
to substitute {x}/{y}. Commands containing literal braces that aren't {x}/{y}
(e.g. `echo ${HOME}`, `awk '{print $1}'`, brace expansions) made libfmt throw
fmt::format_error. Uncaught inside a GTK signal handler this aborts the whole bar.
Only format when {x}/{y} is present and fall back to the raw command on failure.
Fixes bar abort/std::terminate on on-click/on-scroll commands containing braces.
These workflows triggered on both push and pull_request with no branch
filter, so every push to a PR branch ran each workflow twice. Restrict the
push trigger to master; pull_request already covers PR branches.
Addresses review on #5168:
- Generational aliasing (blocking): setupConnection()/onReconnectTimeout()
rebuild wp_core_/om_/pending_plugins_ in place on the same self, but the
async load/activate callbacks carried no generation, and isModuleAlive()
only proves self still exists. If PipeWire dropped again while a previous
connection's async chain was still in flight, a stale completion would run
against the rebuilt connection (a stray --pending_plugins_, an out-of-order
install_object_manager), re-creating #2882's stale/blank state. Each async
call now carries an AsyncCall{self, generation}; connection_generation_ is
bumped in setupConnection(), and every callback drops out when its
generation no longer matches (checked after isModuleAlive short-circuits).
- Duplicate scroll handlers: onMixerApiLoaded re-runs on every reconnect and
connected a new scroll handler each time (dead but accumulating). Moved the
one-time wiring to the constructor; handleScroll no-ops while mixer_api_ is
null, so wiring it before the first connect is safe.
The HiDPI code path builds a cairo surface from the pixbuf via
Gdk::Cairo::create_surface_from_pixbuf(pixbuf, scale, image_.get_window()),
which requires a realized Gtk::Image. During startup an image module can
run its first update() before the widget is realized, so get_window()
returns a null Gdk::Window and that path aborts before anything is
logged. The more image modules are configured, the more likely at least
one updates before realization, which is why >2 images reliably kills
startup.
Guard on get_window(): only take the surface path when a window is
available, otherwise fall back to image_.set(pixbuf) (the pre-HiDPI
behavior). This keeps HiDPI crispness once realized and never crashes at
startup.
Fixes#5051.
2190871a (perf(label): skip redundant markup updates) caches the last
label/tooltip markup as Glib::ustring and skips set_markup() when the
new markup compares equal. Glib::ustring::operator== goes through
g_utf8_collate(), and under the UTF-8 locale GTK sets at startup,
Unicode private-use codepoints carry no collation weight. All nerd-font
icons live in the PUA, so two labels that differ only in their icon
glyph collate as equal and the visual update is silently dropped.
idle_inhibitor is the visible victim (#5169): clicking toggles the
state class (highlight changes) but the {icon} glyph never switches
between the activated/deactivated icons, while plain-text icons like
"YES"/"NO" work. Any ALabel module whose consecutive updates differ
only by a PUA glyph is affected. The module only started routing
through this cache when db4941ef migrated it onto the shared
setLabelMarkup()/setTooltipMarkup() helpers, which is why it broke in
the latest batch of refactors.
Store the cache as raw UTF-8 bytes (std::string via ustring::raw())
and compare those instead, so the skip only triggers on byte-identical
markup. Reproduced and verified under a nested niri session: before the
fix RTMIN+n toggled the state class but left the sleep glyph unchanged;
after it the glyph flips as expected, and byte-identical updates are
still skipped.
#5158 (6672e924) moved connectToSocket() off the constructing thread and
into the detached IPC worker's own try/catch, so a missing NIRI_SOCKET no
longer throws out of IPC::IPC(). That was needed to fix#5117 (the worker
should reconnect instead of dying when an established stream drops), but
it also meant the very first connection attempt can never fail anymore.
Factory::makeModule()/Bar::getModules() rely on that constructor throwing
to disable a module it can't construct. With niri/workspaces and
niri/window always constructing successfully now, they get added to every
bar regardless of which compositor is actually running, showing up as a
permanently-empty widget next to the real workspace modules under
Hyprland/Sway.
Restore the old semantics for the first connection: connectToSocket() runs
synchronously in IPC::IPC() again, so a missing socket still throws and
the module gets disabled as before. Only a drop *after* that succeeds
falls into the retrying reconnect loop, preserving the #5117 fix.
Extend the pow_format spec parser beyond alignment so configs can shape the
rendered number, not just pad it. A forced scale (#, k, M, G, T, P) pins the SI
prefix instead of auto-selecting it and, since the author then knows both scale
and unit, hides the prefix and unit by default; U brings the unit back, u hides
it in auto mode, and i forces integer display. b and B override the decimal or
binary base independently of the call site, and a trailing width now fixes the
coefficient field when a scale is forced, overflowing to '#' when it does not
fit.
The old '>' and '<' branches rendered via fmt::format("{}", s), which discarded
every new field on the recursive call; they now build from a single render path
that reads the current formatter, so modifiers survive alignment. The auto path
is byte-identical to before, verified against the previous implementation.
Adds a Catch2 suite covering each modifier alone and combined, plus the
backward-compatible alignment cases, and documents the modifiers in the network
and disk man pages.
Move WAYBAR_OUTPUT_NAME injection into the parent-provided spawn environment and strip logging and setenv() out of the GLib child-setup hook.
That keeps the helper's post-fork path limited to the process setup it actually needs, which is a safer fit for sanitizer-heavy platforms such as FreeBSD.
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
Run clang-format on the changed C++ files and fix the clang-tidy findings introduced by the custom command migration.
The only codegen-relevant change here is switching the new res assignments in custom.cpp to designated initializers. The rest is formatting only.
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
Stop the interval worker from waiting synchronously on every pid in pid_children_ before it refreshes the module.
Switching this reap pass to waitpid(..., WNOHANG) keeps the worker responsive when an older event-triggered child is still running, while still removing children that have already exited.
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
Replace the custom module's continuous getline() worker with the new GLib-backed command stream helper.
This moves line delivery, child exit handling, and restart scheduling onto the main loop so continuous commands no longer depend on a blocking FILE* read inside SleeperThread.
The behavior is kept aligned with the old module semantics: stdout lines still emit updates, non-zero exits still surface as errors, and restart-interval still respawns the command.
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
Add focused coverage for the new GLib command stream helper.
These tests verify that complete lines are emitted as they arrive and that EOF flushes a final unterminated line without duplicating a newline-terminated one.
That behavior is the contract the custom module will rely on when its continuous command handling moves onto this helper.
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
Add a small GLib-backed helper for command stdout that integrates with the main loop instead of blocking on getline() in a worker thread.
The helper keeps the existing child setup semantics used by Waybar commands, including process groups, parent-death signaling, and WAYBAR_OUTPUT_NAME propagation.
This is the foundation for moving long-running custom commands away from manual poll/read logic in the module itself.
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
PR #4190 (merged as 93d85a0) reworked getNetworkState() so the rfkill
"disabled" state is evaluated whenever the module has no carrier. Because
the module always watches an RFKILL_TYPE_WLAN switch, a wired ethernet
module whose cable is unplugged (carrier lost) would return "disabled"
instead of "disconnected" whenever the system's WLAN radio happened to be
rfkill-blocked. With no format-disabled configured, that state falls back
to plain "format", so the interface kept looking connected after unplug.
rfkill only concerns wireless radios, so only honor it when there is no
interface at all or the current interface is actually wireless (detected
via /sys/class/net/<if>/phy80211 or /wireless). A wired interface that
lost its carrier now correctly reports "disconnected", while wifi rfkill
display from #4190 is preserved.
Fixes#4364.
When sway's event-subscription send buffer overflows during an event
flood, sway closes the client connection. The sway IPC event worker
(SleeperThread running handleEvent -> recv) then threw on every
iteration and the SleeperThread immediately re-invoked it, leaving the
sway modules broken while busy-looping on a dead socket and pegging a
CPU.
Mirror the niri backend's reconnect loop: on a read/EOF/parse error from
the event socket, close the old connection, back off for a couple of
seconds (so we don't busy-spin), re-open the socket and replay the same
subscriptions, then resume. A running_ flag set at the start of teardown
makes the worker bail out cleanly instead of reconnecting to a socket
that is being closed on purpose. The IPC message protocol and event
parsing are unchanged.
Fixes#3166.
The backlight module only enumerated and monitored the udev "backlight"
subsystem, so keyboard-backlight LEDs in the "leds" class (e.g.
white:kbd_backlight, platform::kbd_backlight) were never discovered and
the module fell back to the default when pointed at one.
Enumerate and monitor the "leds" subsystem in addition to "backlight".
Those LEDs expose the same brightness/max_brightness attributes, so the
read path is unchanged. Each device now records its subsystem so the
login1 SetBrightness call targets the correct one. Automatic device
selection still prefers a "backlight" device and only falls back to a
"leds" device when named explicitly or when no screen backlight exists.
Fixes#2848.
Previously the wireplumber module connected to PipeWire once in its
constructor and had no handling for the connection being lost. When
PipeWire or the wireplumber service restarted (or crashed), the module
went stale/blank and never recovered until Waybar itself was restarted.
Connect to the WpCore "disconnected" signal and, on disconnect, schedule
a bounded main-loop retry (Glib::signal_timeout) that tears down the now
invalid core/object-manager/mixer-api references and rebuilds the whole
connection from scratch, re-running the async API and object-manager
setup. Connection setup/teardown is factored into setupConnection() and
teardownConnection() so startup and reconnect share one code path.
The reconnect timer is cancelled in the destructor and the existing
isModuleAlive() registry guard still protects in-flight async callbacks,
so teardown during a pending reconnect stays safe.
Fixes#2882.
Blessed from the CI artifact of the passing headless run (Waybar / CI smoke
test / 12:34 on a pinned DejaVu Sans bar). Regenerate from a new artifact when
an intended visual change trips the comparison — see test/smoke/README.md.
The static custom modules used `exec: echo` (empty output), so the modules
rendered nothing and the bar strip was blank. Have exec print the label text
and use the default `{}` format.
Launch the real Waybar binary inside a headless, software-rendered sway
compositor and verify it actually runs and renders — something the unit
tests and build jobs never do.
- test/smoke/run.sh: boots sway (WLR_BACKENDS=headless, pixman), starts
waybar, asserts it stays alive with no fatal log, optionally grabs a
screenshot with grim
- level 1: real modules (clock/cpu/memory/disk) load without crashing
- level 2: deterministic config is screenshotted and checked to be non-blank
- level 3: screenshot compared to test/smoke/reference.png (fuzz 8%,
800px tolerance); screenshot + diff uploaded as artifacts
- .github/workflows/smoke.yml runs it on push/PR
The reference image must be blessed from a CI artifact (see test/smoke/README.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>