The first run went green but the best-effort tiers silently didn't exercise
anything:
- state.sh: the mpd `audio_output { ... }` block was on one line, which mpd
rejects ("Unknown tokens after '{'"), so mpd never started and the mpd
sub-tier (the #5183 empty-queue repro) skipped. Split the block across lines.
Also move teardown into a cleanup() with `|| true` so a dead-pid kill under
`set -e` no longer makes the step exit 1.
- hardware.sh: umockdev-run's LD_PRELOAD lands ahead of the linked-in ASan
runtime, so ASan aborted before main() ("ASan runtime does not come first")
and waybar never started -- the backlight/slider (#5179) path wasn't tested.
Set verify_asan_link_order=0 so the instrumented binary runs under the preload.
- leakcheck.sh: abort_on_error=1 turned LSan's exit report into a core dump;
force abort_on_error=0 for this report-only tier.
- lib.sh: assert_clean now also flags "ASan runtime does not come first" so a
future preload/link-order regression fails loudly instead of passing.
The smoke test only exercised steady-state rendering and killed waybar with
SIGTERM, so whole crash classes were invisible: exit-time use-after-free
(#5182), module state transitions (#5183) and hardware-backed modules (#5179).
Add tiers that hit those paths under ASan (+ _GLIBCXX_ASSERTIONS):
- lib.sh: assert_clean_exit (SIGINT teardown -> checks segfault/abort + ASan
report emitted during destruction), output hotplug helpers, signal/reload,
WAYBAR_WRAP hook, opt-in leak detection.
- lifecycle.sh (gating): clean exit, runtime output hotplug (Bar/module
destroy), toggle/reload churn, fast-interval teardown race (#5182 class).
- fuzz.sh (gating): pathological custom-backend output (empty, nonzero exit,
invalid JSON, huge, non-UTF8, empty format).
- coverage.sh (gating): every Factory module must be classified for smoke
coverage; a new unlisted module fails the job (#5179 slipped through).
- modules.sh: also render the whole matrix inside a group; SIGINT teardown per
module instead of SIGTERM.
- state.sh (best-effort): real mpd driven through an empty queue (#5183) +
stop/clear; pulseaudio + slider on a null sink.
- hardware.sh (best-effort): backlight, backlight/slider (#5179) and battery
via umockdev.
- leakcheck.sh (report-only): clean-exit run under LSan.
Members are destroyed in reverse declaration order, so modules_all_ (and
the modules it owns) are gone before the GtkWindow. Tearing down a mapped
window emits `unmap`, whose handler runs toggleSuspend() over the already
freed modules. Disconnect the map/unmap handlers in ~Bar first.
Fixes#5182
mpd_run_current_song() returns NULL when there is no current song (e.g.
after `mpc clear`), leaving song_ null. setLabel() always evaluates the
fmt::format() tag arguments -- even for format-stopped -- so getTag() and
getFilename() would call mpd_song_get_tag()/mpd_song_get_uri() on a null
song and segfault. Guard both against a null song_.
Fixes#5183
The un-ignore branch in hide_if_ignored() computed is_was_ignored
after the enclosing condition already required ignored_ to be false,
so it never ran, and a task whose app_id/title changed away from an
ignore-list entry stayed hidden forever.
Reset ignored_ and show the button again, subject to the same
all-outputs/output-membership check used everywhere else.
hide_if_duplicate() unconditionally re-ran handle_output_enter() with
the bar's own wl_output for every non-squashed task, so any app_id or
title event made the task visible on all bars and "all-outputs": false
was effectively ignored. The un-squash path in handle_closed() showed
the replacement task unconditionally, with the same effect.
Track whether the toplevel is actually on the bar's output from the
protocol's output_enter/output_leave events, split the button
show/hide logic out of the protocol handlers, and gate every synthetic
re-show on all-outputs or the tracked output membership.
Fixes#5178
The network module only populates the interface address from netlink
events (RTM_NEWADDR) or an explicit address dump. The interval timer
re-queries WiFi and bandwidth but never re-fetches the address, so the
module relies entirely on receiving the RTM_NEWADDR event.
Netlink multicast delivery is reliable unless the socket receive buffer
overflows, in which case the kernel drops notifications and reports
ENOBUFS. During a burst of link/address/route changes -- e.g. a router
reboot or a PPPoE redial -- this can drop the RTM_NEWADDR carrying the
interface's new IP (after the old one was removed by RTM_DELADDR). With
no overrun handling and no periodic resync, the address field stays
blank until Waybar is restarted (which re-dumps addresses).
Handle the overrun: when nl_recvmsgs_default reports ENOBUFS/NLE_NOMEM,
request a fresh link/address (and route, when auto-detecting) dump to
resynchronise, instead of silently continuing with lost state. Also
enlarge the event socket receive buffer to make overruns less likely in
the first place. The fix stays within the event thread, so it adds no
new locking or cross-thread socket access.
Fixes#5122.
The backlight udev worker thread called enumerate_devices() on every
epoll_wait timeout, i.e. once per polling interval. enumerate_devices()
runs udev_enumerate_scan_devices(), which walks the entire
/sys/class/backlight and /sys/class/leds trees and opens/closes the
sysfs root and every device path. With no `interval` configured the
module polls on its default cadence, so this full re-scan ran
continuously even when brightness never changed, flooding the
filesystem (observed via fatrace as constant open/close of `/`).
Raising `interval` only lowered the cadence, which is why the reporter's
`interval: 10` workaround reduced the flood.
The full re-enumeration is redundant: the udev monitor already delivers
change/add/remove events for the backlight and leds subsystems. On the
timeout path, re-read only the sysfs attributes of the devices already
tracked (via udev_device_new_from_subsystem_sysname) instead of
re-scanning the whole tree. This keeps periodic refresh working for
firmware backlights such as acpi_video that may not emit udev change
events, while eliminating the tree-wide scan. Device discovery of
new/removed devices continues through the udev monitor.
Fixes#5020.
Installing the bzip2 package does not ship a pkg-config file, so freetype2.pc
(a transitive requirement of gtkmm-3.0) still failed with 'Package bzip2 ...
not found'. bzip2 lives in the FreeBSD base system; write a minimal bzip2.pc
before meson setup so pkg-config can resolve it.
The FreeBSD build broke on 'Package bzip2, required by freetype2, not found':
freetype2.pc lists bzip2 as a (private) requirement, but no bzip2.pc was
installed, so pkg-config could not generate cflags for gtkmm-3.0. Add bzip2
to the package list.
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.