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:
@@ -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
@@ -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 5–6 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
|
||||
```
|
||||
|
||||
Executable
+48
@@ -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"
|
||||
Executable
+43
@@ -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"
|
||||
Executable
+87
@@ -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"
|
||||
Executable
+34
@@ -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
@@ -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
|
||||
|
||||
Executable
+80
@@ -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
@@ -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)"
|
||||
|
||||
Executable
+114
@@ -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"
|
||||
Reference in New Issue
Block a user