ci(smoke): cover teardown, state transitions and fake hardware

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.
This commit is contained in:
Alex
2026-07-05 22:21:46 +02:00
parent 445e2aec1f
commit 9a10949938
10 changed files with 558 additions and 13 deletions
+37 -2
View File
@@ -38,6 +38,7 @@ jobs:
sudo apt-get install --no-install-recommends -y \
sway labwc grim imagemagick fonts-dejavu-core jq \
at-spi2-core python3-pyatspi dbus-x11 \
mpd mpc pulseaudio pulseaudio-utils umockdev \
meson ninja-build g++ pkg-config scdoc wayland-protocols \
gobject-introspection libdbusmenu-gtk3-dev libevdev-dev libfmt-dev \
libgirepository1.0-dev libgtk-3-dev libgtkmm-3.0-dev libinput-dev \
@@ -45,9 +46,17 @@ jobs:
libpulse-dev libsigc++-2.0-dev libspdlog-dev libwayland-dev \
upower libxkbregistry-dev libxkbcommon-dev
# Cheap static gate (source-only): every Factory module must be classified
# for smoke coverage. Runs before the build so it fails fast.
- name: Module coverage tripwire
run: ./test/smoke/coverage.sh
- name: Build Waybar with AddressSanitizer
run: |
meson setup build -Dman-pages=disabled -Db_sanitize=address
# _GLIBCXX_ASSERTIONS turns libstdc++ container bounds/precondition
# violations (e.g. out-of-range vector access) into aborts under ASan.
meson setup build -Dman-pages=disabled -Db_sanitize=address \
-Dcpp_args=-D_GLIBCXX_ASSERTIONS
ninja -C build
# --- Tier 0 + 2: render + sanitizer/critical gate ---
@@ -83,10 +92,36 @@ jobs:
echo "::warning::No reference image; run this workflow via 'Run workflow' with bless=true to create it."
fi
# --- Tier 1: per-module matrix ---
# --- Tier 1: per-module matrix (standalone + grouped) ---
- name: Per-module render matrix
run: ./test/smoke/modules.sh module-shots
# --- Tier 4: lifecycle / teardown (exit, output hotplug, reload) ---
# Runs the C++ destructor path a plain kill never touches (#5182).
- name: Lifecycle / teardown
run: ./test/smoke/lifecycle.sh
# --- Tier 4: custom-backend fuzz (pathological exec output) ---
- name: Custom-backend fuzz
run: ./test/smoke/fuzz.sh
# --- Tier 5: state transitions with real daemons (mpd/pulseaudio) ---
# Best-effort: daemon setup varies by runner; keep non-blocking for now.
- name: State transitions (mpd / pulseaudio)
continue-on-error: true
run: ./test/smoke/state.sh
# --- Tier 6: fake hardware via umockdev (backlight/slider, battery) ---
# Best-effort: the mock device descriptions may need per-distro tuning.
- name: Fake hardware (umockdev)
continue-on-error: true
run: ./test/smoke/hardware.sh
# --- Leak check (report-only) — we fixed several leaks recently ---
- name: Leak check (LSan, report-only)
continue-on-error: true
run: ./test/smoke/leakcheck.sh
# --- Tier 2: pointer interaction (best-effort) ---
# Headless seats may not expose a pointer capability, so injected clicks
# can be dropped; keep this non-blocking while it is hardened.
+26 -6
View File
@@ -10,18 +10,31 @@ AddressSanitizer** and runs the real binary inside a **headless** compositor
| Tier | Check | Script |
| --- | --- | --- |
| 0 | Launches, stays alive, and logs **no ASan / `Gtk-CRITICAL` / crash** — for every launch below | [`lib.sh`](lib.sh) `assert_clean` |
| — | **Coverage tripwire** — every `Factory` module must be classified (source-only, runs before the build) | [`coverage.sh`](coverage.sh) |
| 1 | **Renders** — deterministic config screenshotted with `grim`, bar strip not blank | [`run.sh`](run.sh) |
| 1 | **Per-module matrix** — each headless-safe module rendered in isolation | [`modules.sh`](modules.sh) |
| 1 | **Per-module matrix** — each headless-safe module rendered standalone **and inside a group**, each torn down via SIGINT | [`modules.sh`](modules.sh) |
| 2 | **Golden** — screenshot vs [`reference.png`](reference.png) (fuzz 8%, 800px) | workflow |
| 2 | **Interaction** — inject pointer clicks via sway; assert `on-click` side effect + `format-alt` toggle | [`interact.sh`](interact.sh) |
| 2 | **Accessibility** — assert module labels via the AT-SPI tree (robust to fonts) | [`a11y.sh`](a11y.sh) / [`a11y.py`](a11y.py) |
| 3 | **Layout matrix** — top / bottom / left (vertical) + HiDPI (scale 2) | [`positions.sh`](positions.sh) |
| 3 | **Second compositor** — same launch under `labwc` | workflow (`COMPOSITOR=labwc`) |
| 4 | **Lifecycle / teardown** — clean SIGINT exit, runtime **output hotplug**, toggle + reload churn, fast-interval teardown (the C++ destructor path a `kill` never runs, #5182) | [`lifecycle.sh`](lifecycle.sh) |
| 4 | **Custom-backend fuzz** — empty / non-zero-exit / invalid-JSON / huge / non-UTF8 / empty-format exec output | [`fuzz.sh`](fuzz.sh) |
| 5 | **State transitions** *(best-effort)* — real `mpd` driven through an **empty queue** (#5183) + stop/clear, `pulseaudio` + slider on a null sink | [`state.sh`](state.sh) |
| 6 | **Fake hardware** *(best-effort)*`backlight`, `backlight/slider` (#5179) and `battery` via **umockdev** | [`hardware.sh`](hardware.sh) |
| — | **Leak check** *(report-only)* — clean-exit run under LSan | [`leakcheck.sh`](leakcheck.sh) |
AddressSanitizer runs under *all* of the above, so any memory bug in the real
render path aborts the run. The interaction step is best-effort
(`continue-on-error`): a headless seat exposes no pointer capability, so injected
clicks may be dropped — hardening it needs a virtual pointer (uinput/wlr-virtual-pointer).
AddressSanitizer (plus `-D_GLIBCXX_ASSERTIONS`) runs under *all* of the above, so
any memory bug in the real render/teardown path aborts the run. Tiers 56 and the
leak check are best-effort (`continue-on-error`): they depend on daemon setup /
mock devices that vary by runner. The interaction step is likewise best-effort — a
headless seat exposes no pointer capability, so injected clicks may be dropped
(hardening it needs a virtual pointer, uinput/wlr-virtual-pointer).
Because the teardown path (`~Bar` → window unmap → `toggleSuspend`) is where the
exit-time crashes live, tests assert on a **clean SIGINT exit** via
`smoke::assert_clean_exit` rather than killing with SIGTERM, and re-check the log
for sanitizer reports emitted *during* destruction.
## Updating the reference image
@@ -37,13 +50,20 @@ Do this whenever an **intended** visual change trips the golden comparison.
## Run locally
On a machine with `sway`, `grim`, `imagemagick` and Waybar built:
On a machine with `sway`, `grim`, `imagemagick`, `jq` and Waybar built:
```sh
export WAYBAR_BIN=$PWD/build/waybar
./test/smoke/coverage.sh # no compositor needed
./test/smoke/run.sh test/smoke/config.jsonc test/smoke/style.css /tmp/bar.png
./test/smoke/modules.sh /tmp/module-shots
./test/smoke/lifecycle.sh
./test/smoke/fuzz.sh
./test/smoke/interact.sh /tmp/interaction-shots
./test/smoke/positions.sh /tmp/layout-shots
dbus-run-session -- ./test/smoke/a11y.sh
# best-effort (need extra daemons / tools):
./test/smoke/state.sh # needs mpd + mpc, pulseaudio
./test/smoke/hardware.sh # needs umockdev
./test/smoke/leakcheck.sh
```
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Tier — coverage tripwire. Every module the Factory can build must be classified
# below: either smoke-tested headless (SAFE), or explicitly parked as needing real
# hardware/a daemon (HARDWARE, best-effort in state.sh / hardware.sh) or a specific
# compositor (COMPOSITOR). A newly added module that isn't listed fails this job,
# forcing a conscious "is it covered?" decision instead of silently escaping the
# smoke net -- which is how the slider/backlight crash class (#5179) slipped by.
#
# Usage: coverage.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
FACTORY_SRC="$ROOT/src/factory.cpp"
mapfile -t FACTORY < <(grep -oE 'ref == "[^"]+"' "$FACTORY_SRC" | sed -E 's/.*"(.*)"/\1/' | sort -u)
[ "${#FACTORY[@]}" -gt 0 ] || { echo "::error::could not parse module names from $FACTORY_SRC"; exit 1; }
# Rendered headless with no hardware/daemon/compositor dependency.
SAFE=(clock cpu cpu_usage cpu_graph cpu_frequency load memory disk
idle_inhibitor image user inhibitor)
# Need real hardware or a system daemon; covered best-effort by hardware.sh / state.sh.
HARDWARE=(backlight backlight/slider battery upower power-profiles-daemon bluetooth
network wwan gps pulseaudio pulseaudio/slider wireplumber jack sndio cava
mpd mpris temperature keyboard-state systemd-failed-units privacy tray gamemode)
# Need a specific compositor / its IPC to produce output.
COMPOSITOR=(sway/language sway/mode sway/scratchpad sway/window sway/workspaces
hyprland/language hyprland/submap hyprland/window hyprland/windowcount hyprland/workspaces
river/layout river/mode river/tags river/window
niri/language niri/window niri/workspaces
dwl/tags dwl/window
mango/keymode mango/language mango/layout mango/window mango/workspaces
wayfire/window wayfire/workspaces ext/workspaces wlr/taskbar)
declare -A known=()
for m in "${SAFE[@]}" "${HARDWARE[@]}" "${COMPOSITOR[@]}"; do known["$m"]=1; done
missing=()
for m in "${FACTORY[@]}"; do [ -n "${known[$m]:-}" ] || missing+=("$m"); done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::modules not classified for smoke coverage: ${missing[*]}"
echo "Add each to SAFE / HARDWARE / COMPOSITOR in test/smoke/coverage.sh"
echo "(and, if it can run headless, to the matrix in modules.sh)."
exit 1
fi
echo "✓ all ${#FACTORY[@]} factory modules are classified for smoke coverage"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Tier — custom-backend fuzz. The custom module's exec output flows straight into
# formatting / sanitising / (optionally) JSON parsing, so feed it the pathological
# inputs that tend to segfault (empty, non-zero exit, invalid JSON, huge, non-UTF8,
# empty format) and assert no crash / critical, including on clean exit.
#
# Usage: fuzz.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "$DIR/lib.sh"
smoke::setup
smoke::start_compositor
trap smoke::stop EXIT
fail=0
# run_case <name> <custom-module-json>
run_case() {
local name="$1" mod="$2" cfg
echo "::group::fuzz: $name"
cfg="$(mktemp --suffix=.json)"
cat > "$cfg" <<EOF
{ "layer": "top", "position": "top", "height": 30,
"modules-center": ["custom/x"], "custom/x": $mod }
EOF
smoke::launch_waybar "$cfg" "$DIR/style.css" 4
smoke::assert_alive || fail=1
smoke::assert_clean_exit || fail=1
echo "::endgroup::"
}
run_case "empty output" '{"exec":"true","interval":"once"}'
run_case "nonzero exit" '{"exec":"echo hi; exit 3","interval":"once"}'
run_case "invalid json" '{"exec":"echo not-json","return-type":"json","interval":"once"}'
run_case "huge output" '{"exec":"yes A | head -c 100000","interval":"once"}'
run_case "non-utf8 bytes" '{"exec":"printf \\377\\376\\375","interval":"once"}'
run_case "empty format" '{"exec":"echo x","interval":"once","format":""}'
run_case "newlines flood" '{"exec":"yes | head -n 500","interval":"once"}'
[ "$fail" = 0 ] || { echo "::error::fuzz tier failed"; exit 1; }
echo "✓ custom-backend fuzz passed"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Tier — fake-hardware coverage via umockdev. Modules that read sysfs/udev
# (backlight, its slider, battery) can't run in a headless runner, so they were
# never smoke-tested and a construct/update crash like #5179 (backlight/slider)
# could ship unseen. umockdev-run intercepts sysfs/udev and feeds waybar a mock
# device, so the real code path runs under ASan.
#
# Best-effort: skipped if umockdev isn't installed. The .umockdev descriptions
# below may need tuning per distro; keep this continue-on-error in CI.
#
# Usage: hardware.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "$DIR/lib.sh"
if ! command -v umockdev-run >/dev/null; then
echo "(umockdev not installed, skipping hardware tier)"; exit 0
fi
smoke::setup
smoke::start_compositor
trap smoke::stop EXIT
fail=0
BL_DEV="$(mktemp --suffix=.umockdev)"
cat > "$BL_DEV" <<'EOF'
P: /devices/platform/fake_backlight/backlight/intel_backlight
E: SUBSYSTEM=backlight
A: brightness=500
A: max_brightness=1000
A: actual_brightness=500
A: bl_power=0
EOF
BAT_DEV="$(mktemp --suffix=.umockdev)"
cat > "$BAT_DEV" <<'EOF'
P: /devices/platform/fake_battery/power_supply/BAT0
E: SUBSYSTEM=power_supply
E: POWER_SUPPLY_NAME=BAT0
A: type=Battery
A: present=1
A: status=Discharging
A: capacity=55
A: energy_now=5500000
A: energy_full=10000000
A: power_now=1000000
EOF
# run_mock <name> <umockdev-file> <config-json>
run_mock() {
local name="$1" dev="$2" mod="$3" cfg
echo "::group::hardware: $name"
cfg="$(mktemp --suffix=.json)"
cat > "$cfg" <<EOF
{ "layer": "top", "position": "top", "height": 30,
"modules-center": ["$name"], $mod }
EOF
WAYBAR_WRAP="umockdev-run --device $dev --" smoke::launch_waybar "$cfg" "$DIR/style.css" 5
smoke::assert_alive || fail=1
smoke::assert_clean || fail=1
smoke::stop; WAYBAR_PID=""
echo "::endgroup::"
}
run_mock "backlight" "$BL_DEV" '"backlight": {"format":"{percent}%"}'
run_mock "backlight/slider" "$BL_DEV" '"backlight/slider": {"min":0,"max":100,"orientation":"horizontal"}'
run_mock "battery" "$BAT_DEV" '"battery": {"format":"{capacity}%"}'
# Slider inside a group -- the exact shape from #5179 (box#backlight-group).
echo "::group::hardware: backlight/slider in a group"
cfg="$(mktemp --suffix=.json)"
cat > "$cfg" <<'EOF'
{ "layer": "top", "position": "top", "height": 30,
"modules-center": ["group/bl"],
"group/bl": { "modules": ["backlight/slider"] },
"backlight/slider": { "min": 0, "max": 100, "orientation": "horizontal" } }
EOF
WAYBAR_WRAP="umockdev-run --device $BL_DEV --" smoke::launch_waybar "$cfg" "$DIR/style.css" 5
smoke::assert_alive || fail=1
smoke::assert_clean || fail=1
smoke::stop; WAYBAR_PID=""
echo "::endgroup::"
[ "$fail" = 0 ] || { echo "::error::hardware tier failed"; exit 1; }
echo "✓ fake-hardware tier passed"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Report-only leak check. Runs the deterministic config, exits cleanly (SIGINT,
# so LeakSanitizer's atexit hook actually runs -- a SIGTERM kill would skip it),
# and surfaces any leak report. Never fails the job: leaks in GTK/GLib are too
# noisy to gate on, but regressions in our own code are worth seeing.
#
# Usage: leakcheck.sh
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "$DIR/lib.sh"
export SMOKE_DETECT_LEAKS=1
smoke::setup
smoke::start_compositor
trap smoke::stop EXIT
smoke::launch_waybar "$DIR/config.jsonc" "$DIR/style.css" 5
smoke::assert_alive || true
# Clean exit so LSan runs.
kill -INT "$WAYBAR_PID" 2>/dev/null || true
for _ in $(seq 1 20); do kill -0 "$WAYBAR_PID" 2>/dev/null || break; sleep 0.5; done
kill -9 "$WAYBAR_PID" 2>/dev/null || true
if grep -iqE 'LeakSanitizer|Direct leak|Indirect leak' "$SMOKE_LOG"; then
echo "::warning::LeakSanitizer reported leaks (report-only)"
echo "::group::leak report"
grep -iE 'leak|SUMMARY|#[0-9]+ 0x' "$SMOKE_LOG" || true
echo "::endgroup::"
else
echo "✓ no leaks reported"
fi
WAYBAR_PID=""
+64 -2
View File
@@ -18,6 +18,9 @@ HEIGHT="${HEIGHT:-1080}"
SCALE="${SCALE:-1}"
COMPOSITOR="${COMPOSITOR:-sway}"
WAYBAR_BIN="${WAYBAR_BIN:-waybar}"
# Optional command prefix for the waybar launch (e.g. `umockdev-run -d dev --`),
# used by the hardware tier to fake sysfs/udev devices. Empty by default.
WAYBAR_WRAP="${WAYBAR_WRAP:-}"
SMOKE_LOG=""
COMP_PID=""
@@ -32,7 +35,9 @@ smoke::setup() {
export LIBGL_ALWAYS_SOFTWARE=1
# Keep ASan/UBSan noise low but fatal on real errors (leaks in GTK/glib are
# too noisy to gate on, so only memory-corruption and UB abort the run).
export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1:detect_odr_violation=0"
# Leak detection is opt-in (leakcheck.sh sets SMOKE_DETECT_LEAKS=1) and needs
# a clean exit for LSan's atexit hook to run.
export ASAN_OPTIONS="detect_leaks=${SMOKE_DETECT_LEAKS:-0}:halt_on_error=1:abort_on_error=1:detect_odr_violation=0"
export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1"
}
@@ -84,7 +89,10 @@ smoke::launch_waybar() {
SMOKE_LOG="$(mktemp)"
local args=(-c "$config" -l debug)
[ -n "$style" ] && args+=(-s "$style")
"$WAYBAR_BIN" "${args[@]}" >"$SMOKE_LOG" 2>&1 &
local run=()
[ -n "$WAYBAR_WRAP" ] && read -ra run <<<"$WAYBAR_WRAP"
run+=("$WAYBAR_BIN" "${args[@]}")
"${run[@]}" >"$SMOKE_LOG" 2>&1 &
WAYBAR_PID=$!
sleep "$settle"
}
@@ -129,6 +137,60 @@ smoke::assert_not_blank() {
echo "✓ bar rendered content"
}
# Send a signal to waybar and give it a moment to act on it.
# smoke::signal <SIGNAME|num> [settle_seconds]
smoke::signal() {
kill -"$1" "$WAYBAR_PID" 2>/dev/null || true
sleep "${2:-2}"
}
# Reload waybar's config in place (recreates bars + modules).
smoke::reload() { smoke::signal SIGUSR2 "${1:-3}"; }
# Create a headless output at runtime (sway only). wlroots' headless backend
# lets sway hotplug outputs, which makes waybar build a new Bar + module set.
smoke::add_output() {
[ "$COMPOSITOR" = sway ] || { echo "(add_output: sway only, skipped)"; return 0; }
swaymsg create_output >/dev/null 2>&1 || echo "(create_output unsupported, skipped)"
sleep 2
}
# Remove every headless output except the primary HEADLESS-1. Destroying an
# output tears down its Bar -> ~Bar -> GtkWindow unmap -> toggleSuspend(), i.e.
# the exact runtime path that segfaulted in #5182 -- exercised here under ASan.
smoke::remove_output() {
[ "$COMPOSITOR" = sway ] || return 0
local o
for o in $(swaymsg -t get_outputs 2>/dev/null | jq -r '.[].name' 2>/dev/null | grep -v '^HEADLESS-1$' || true); do
swaymsg output "$o" unplug >/dev/null 2>&1 || true
done
sleep 2
}
# Shut waybar down the clean way (SIGINT -> Client::reset -> gtk quit ->
# bars.clear -> ~Bar) and assert the destructor path neither crashed nor tripped
# a sanitizer report. A plain SIGTERM/kill never runs this path, which is why the
# exit-time use-after-free (#5182) went unnoticed. Call instead of smoke::stop's
# kill when you want the teardown itself checked.
smoke::assert_clean_exit() {
[ -n "$WAYBAR_PID" ] || { echo "(no waybar running)"; return 0; }
kill -INT "$WAYBAR_PID" 2>/dev/null || true
local _ ; for _ in $(seq 1 20); do kill -0 "$WAYBAR_PID" 2>/dev/null || break; sleep 0.5; done
if kill -0 "$WAYBAR_PID" 2>/dev/null; then
echo "::error::waybar did not exit on SIGINT (hung during teardown)"
echo "::group::waybar log"; smoke::log; echo "::endgroup::"
kill -9 "$WAYBAR_PID" 2>/dev/null || true; WAYBAR_PID=""; return 1
fi
local st=0; wait "$WAYBAR_PID" 2>/dev/null || st=$?
WAYBAR_PID=""
if [ "$st" -ge 128 ]; then
echo "::error::waybar crashed on exit (killed by signal $((st - 128)))"
echo "::group::waybar log"; smoke::log; echo "::endgroup::"; return 1
fi
smoke::assert_clean || return 1
echo "✓ clean exit (status $st)"
}
smoke::stop() {
kill "$WAYBAR_PID" 2>/dev/null || true
swaymsg -q exit 2>/dev/null || kill "$COMP_PID" 2>/dev/null || true
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Tier 4 — lifecycle / teardown safety net.
#
# Everything above renders in steady state and kills waybar with SIGTERM, which
# never runs the C++ destructors. The crash classes that hurt most (exit,
# DPMS/output unmap, reload) all live in that create/destroy path, so exercise it
# explicitly under ASan:
# 1. clean exit on SIGINT (the real Bar teardown path, #5182)
# 2. runtime output hotplug (bar + module destruction while running, #5182)
# 3. visibility toggle + config reload churn (SIGUSR1 / SIGUSR2)
# 4. fast-interval modules torn down mid-tick (thread vs destructor races)
#
# Usage: lifecycle.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "$DIR/lib.sh"
CFG="$DIR/config.jsonc"
STYLE="$DIR/style.css"
smoke::setup
smoke::start_compositor
trap smoke::stop EXIT
fail=0
# --- 1) clean exit on SIGINT (destructor path, #5182) --------------------
echo "::group::clean exit (SIGINT)"
smoke::launch_waybar "$CFG" "$STYLE" 5
smoke::assert_alive || fail=1
smoke::assert_clean_exit || fail=1
echo "::endgroup::"
# --- 2) output hotplug at runtime (bar/module destroy, #5182) ------------
if [ "$COMPOSITOR" = sway ]; then
echo "::group::output hotplug"
smoke::launch_waybar "$CFG" "$STYLE" 5
smoke::assert_alive || fail=1
for _ in 1 2 3; do
smoke::add_output
smoke::assert_alive || { fail=1; break; }
smoke::remove_output # destroys that output's Bar -> ~Bar -> unmap
smoke::assert_alive || { fail=1; break; }
done
smoke::assert_clean || fail=1
smoke::assert_clean_exit || fail=1
echo "::endgroup::"
fi
# --- 3) visibility toggle + reload churn ---------------------------------
echo "::group::signal churn (toggle + reload)"
smoke::launch_waybar "$CFG" "$STYLE" 5
smoke::assert_alive || fail=1
for _ in 1 2 3; do smoke::signal SIGUSR1 1; done # hide/show mode churn
smoke::reload # SIGUSR2: recreate bars/modules
smoke::assert_alive || fail=1
smoke::assert_clean_exit || fail=1
echo "::endgroup::"
# --- 4) fast-interval modules torn down mid-tick -------------------------
echo "::group::fast-interval teardown race"
cfg="$(mktemp --suffix=.json)"
cat > "$cfg" <<'EOF'
{
"layer": "top", "position": "top", "height": 30,
"modules-center": ["clock", "cpu", "memory", "custom/tick"],
"clock": { "interval": 1 },
"cpu": { "interval": 1 },
"memory": { "interval": 1 },
"custom/tick": { "exec": "date +%s", "interval": 1 }
}
EOF
smoke::launch_waybar "$cfg" "$STYLE" 8
smoke::assert_alive || fail=1
smoke::assert_clean_exit || fail=1 # SIGINT while worker threads are mid-tick
echo "::endgroup::"
[ "$fail" = 0 ] || { echo "::error::lifecycle tier failed"; exit 1; }
echo "✓ lifecycle tier passed"
+25 -3
View File
@@ -47,13 +47,35 @@ EOF
smoke::assert_not_blank "$SHOTDIR/$safe.png" || ok=0
fi
[ "$ok" = 1 ] || fails+=("$mod")
kill "$WAYBAR_PID" 2>/dev/null || true
sleep 1
# SIGINT teardown (not SIGTERM) so each module's destructor path is ASan-checked.
smoke::assert_clean_exit || fails+=("$mod (exit)")
echo "::endgroup::"
done
# --- all modules inside a group -----------------------------------------
# Group children have a different construct/destroy path than top-level modules
# (#5179 crashed inside a group), so render the whole matrix wrapped in one.
echo "::group::modules inside a group"
mods_json="$(printf '%s\n' "${!MODULES[@]}" | jq -R . | jq -s .)"
opts_json='{}'
for mod in "${!MODULES[@]}"; do
opts_json="$(jq --arg k "$mod" --argjson v "${MODULES[$mod]}" '. + {($k):$v}' <<<"$opts_json")"
done
gcfg="$(mktemp --suffix=.json)"
jq -n --argjson mods "$mods_json" --argjson opts "$opts_json" '
{layer:"top",position:"top",height:30,"modules-center":["group/g"],"group/g":{modules:$mods}} + $opts' \
> "$gcfg"
smoke::launch_waybar "$gcfg" "$DIR/style.css" 5
ok=1
smoke::assert_alive || ok=0
smoke::assert_clean || ok=0
[ "$ok" = 1 ] && { smoke::screenshot "$SHOTDIR/_group.png"; smoke::assert_not_blank "$SHOTDIR/_group.png" || ok=0; }
smoke::assert_clean_exit || ok=0
[ "$ok" = 1 ] || fails+=("group")
echo "::endgroup::"
if [ "${#fails[@]}" -gt 0 ]; then
echo "::error::modules failed to render cleanly: ${fails[*]}"
exit 1
fi
echo "✓ all ${#MODULES[@]} modules rendered cleanly"
echo "✓ all ${#MODULES[@]} modules rendered cleanly (standalone + grouped)"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Tier — state-transition coverage with real daemons. Steady-state rendering
# never hits the empty/edge states where modules crash. Drive an actual mpd and
# PulseAudio through those states under ASan:
# - mpd: connect with an EMPTY queue (no current song -> null song_, #5183),
# then clear/add/stop/play while waybar is live
# - pulseaudio + pulseaudio/slider against a null sink
#
# Best-effort: each sub-tier is skipped (not failed) if its daemon isn't
# installed. Run continue-on-error in CI while the fixtures settle.
#
# Usage: state.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "$DIR/lib.sh"
smoke::setup
smoke::start_compositor
trap 'smoke::stop; [ -n "${MPD_PID:-}" ] && kill "$MPD_PID" 2>/dev/null; [ -n "${PA_STARTED:-}" ] && pulseaudio --kill 2>/dev/null; true' EXIT
fail=0
MPD_PID=""
PA_STARTED=""
# ---------------------------------------------------------------- mpd -----
mpd_tier() {
if ! command -v mpd >/dev/null || ! command -v mpc >/dev/null; then
echo "(mpd/mpc not installed, skipping mpd tier)"; return 0
fi
echo "::group::mpd: empty queue + transitions (#5183)"
local d conf; d="$(mktemp -d)"; conf="$d/mpd.conf"
mkdir -p "$d/music" "$d/playlists"
cat > "$conf" <<EOF
music_directory "$d/music"
playlist_directory "$d/playlists"
db_file "$d/db"
state_file "$d/state"
pid_file "$d/pid"
bind_to_address "127.0.0.1"
port "6600"
audio_output { type "null" name "null" }
EOF
mpd --no-daemon "$conf" >/tmp/mpd.log 2>&1 &
MPD_PID=$!
local up=0 _
for _ in $(seq 1 20); do
mpc -h 127.0.0.1 -p 6600 status >/dev/null 2>&1 && { up=1; break; }
sleep 0.5
done
if [ "$up" != 1 ]; then
echo "::warning::mpd did not come up; skipping"; cat /tmp/mpd.log || true; echo "::endgroup::"; return 0
fi
mpc -h 127.0.0.1 -p 6600 clear >/dev/null 2>&1 || true # empty queue: the crash trigger
local cfg; cfg="$(mktemp --suffix=.json)"
cat > "$cfg" <<'EOF'
{
"layer": "top", "position": "top", "height": 30,
"modules-center": ["mpd"],
"mpd": { "server": "127.0.0.1", "port": 6600, "interval": 1,
"format": "{album} - {artist} - {title}", "format-stopped": "stopped" }
}
EOF
smoke::launch_waybar "$cfg" "$DIR/style.css" 5
smoke::assert_alive || fail=1 # would have segfaulted pre-#5183 fix
smoke::assert_clean || fail=1
# Churn state while live: clear again, then stop/play.
mpc -h 127.0.0.1 -p 6600 clear >/dev/null 2>&1 || true; sleep 2
mpc -h 127.0.0.1 -p 6600 stop >/dev/null 2>&1 || true; sleep 2
smoke::assert_alive || fail=1
smoke::assert_clean_exit || fail=1
echo "::endgroup::"
}
# -------------------------------------------------------- pulseaudio ------
pulse_tier() {
if ! command -v pulseaudio >/dev/null; then
echo "(pulseaudio not installed, skipping pulse tier)"; return 0
fi
echo "::group::pulseaudio + pulseaudio/slider (null sink)"
export PULSE_RUNTIME_PATH="$XDG_RUNTIME_DIR/pulse"
if pulseaudio --start --exit-idle-time=-1 --log-target=stderr >/tmp/pa.log 2>&1; then
PA_STARTED=1
else
echo "::warning::pulseaudio failed to start; skipping"; cat /tmp/pa.log || true; echo "::endgroup::"; return 0
fi
pactl load-module module-null-sink sink_name=smoke_null >/dev/null 2>&1 || true
local cfg; cfg="$(mktemp --suffix=.json)"
cat > "$cfg" <<'EOF'
{
"layer": "top", "position": "top", "height": 30,
"modules-center": ["pulseaudio", "pulseaudio/slider"],
"pulseaudio": { "format": "{volume}%" },
"pulseaudio/slider": { "min": 0, "max": 100, "orientation": "horizontal" }
}
EOF
smoke::launch_waybar "$cfg" "$DIR/style.css" 5
smoke::assert_alive || fail=1
smoke::assert_clean || fail=1
pactl set-sink-volume @DEFAULT_SINK@ 40% >/dev/null 2>&1 || true; sleep 1
pactl set-sink-mute @DEFAULT_SINK@ 1 >/dev/null 2>&1 || true; sleep 1
smoke::assert_alive || fail=1
smoke::assert_clean_exit || fail=1
echo "::endgroup::"
}
mpd_tier
pulse_tier
[ "$fail" = 0 ] || { echo "::error::state tier failed"; exit 1; }
echo "✓ state-transition tier passed"