From 9a10949938b3bcf827345687f6a137d556bfdd0f Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 5 Jul 2026 22:21:46 +0200 Subject: [PATCH] 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. --- .github/workflows/smoke.yml | 39 +++++++++++- test/smoke/README.md | 32 ++++++++-- test/smoke/coverage.sh | 48 +++++++++++++++ test/smoke/fuzz.sh | 43 ++++++++++++++ test/smoke/hardware.sh | 87 +++++++++++++++++++++++++++ test/smoke/leakcheck.sh | 34 +++++++++++ test/smoke/lib.sh | 66 ++++++++++++++++++++- test/smoke/lifecycle.sh | 80 +++++++++++++++++++++++++ test/smoke/modules.sh | 28 ++++++++- test/smoke/state.sh | 114 ++++++++++++++++++++++++++++++++++++ 10 files changed, 558 insertions(+), 13 deletions(-) create mode 100755 test/smoke/coverage.sh create mode 100755 test/smoke/fuzz.sh create mode 100755 test/smoke/hardware.sh create mode 100755 test/smoke/leakcheck.sh create mode 100755 test/smoke/lifecycle.sh create mode 100755 test/smoke/state.sh diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 8de5ffe6..001b7ecf 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -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. diff --git a/test/smoke/README.md b/test/smoke/README.md index 60800362..86c3dcae 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -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 ``` diff --git a/test/smoke/coverage.sh b/test/smoke/coverage.sh new file mode 100755 index 00000000..9969d679 --- /dev/null +++ b/test/smoke/coverage.sh @@ -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" diff --git a/test/smoke/fuzz.sh b/test/smoke/fuzz.sh new file mode 100755 index 00000000..29f6a767 --- /dev/null +++ b/test/smoke/fuzz.sh @@ -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 +run_case() { + local name="$1" mod="$2" cfg + echo "::group::fuzz: $name" + cfg="$(mktemp --suffix=.json)" + cat > "$cfg" </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 +run_mock() { + local name="$1" dev="$2" mod="$3" cfg + echo "::group::hardware: $name" + cfg="$(mktemp --suffix=.json)" + cat > "$cfg" < "$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" diff --git a/test/smoke/leakcheck.sh b/test/smoke/leakcheck.sh new file mode 100755 index 00000000..36f5d21e --- /dev/null +++ b/test/smoke/leakcheck.sh @@ -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="" diff --git a/test/smoke/lib.sh b/test/smoke/lib.sh index bed35abb..5ba02d5b 100755 --- a/test/smoke/lib.sh +++ b/test/smoke/lib.sh @@ -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 [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 diff --git a/test/smoke/lifecycle.sh b/test/smoke/lifecycle.sh new file mode 100755 index 00000000..d8210b89 --- /dev/null +++ b/test/smoke/lifecycle.sh @@ -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" diff --git a/test/smoke/modules.sh b/test/smoke/modules.sh index 46f13c88..b7e2541a 100755 --- a/test/smoke/modules.sh +++ b/test/smoke/modules.sh @@ -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)" diff --git a/test/smoke/state.sh b/test/smoke/state.sh new file mode 100755 index 00000000..80c1b04f --- /dev/null +++ b/test/smoke/state.sh @@ -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" </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"