diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-08 12:55:55 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-08 12:55:55 -0500 |
| commit | e8eff25043c12a7d30dc347be60ce516a5afd05d (patch) | |
| tree | b4533792b350d6fd15d5e0144c452aa762ee43c6 | |
| parent | da29e92aea51803234d25b6473b4d7fb82dbf7da (diff) | |
| download | archsetup-e8eff25043c12a7d30dc347be60ce516a5afd05d.tar.gz archsetup-e8eff25043c12a7d30dc347be60ce516a5afd05d.zip | |
fix(guard): allow same-version reinstalls through the live-update hook
The hypr-live-update-guard hook blocked on target names alone, so a same-version reinstall of hyprland (the maintenance console's integrity REINSTALL, right after a full update) aborted identically to a real upgrade. A pure reinstall writes identical bytes over identical bytes and can't crash the live compositor: the SIGABRT hazard needs the on-disk version to actually change.
The guard now compares each target's installed version (pacman -Q) against the sync-db candidate (expac -S %v). Both are read-only queries and safe under the transaction lock, pinned live with db.lck present. Targets that match pass as reinstalls, and the block message lists only the version-changing packages. Unresolvable versions (AUR targets, -U transactions, expac missing) still block conservatively.
Verified live on ratio: the previously blocked six-package reinstall ran clean with Hyprland up. The block path is pinned by the test suite (13 tests, four new). I deployed the script to /usr/local/bin on both hosts by hand since the installer only copies it on fresh systems.
| -rwxr-xr-x | scripts/hypr-live-update-guard | 90 | ||||
| -rw-r--r-- | tests/hypr-live-update-guard/test_hypr_live_update_guard.py | 53 |
2 files changed, 128 insertions, 15 deletions
diff --git a/scripts/hypr-live-update-guard b/scripts/hypr-live-update-guard index 4f561ae..614414b 100755 --- a/scripts/hypr-live-update-guard +++ b/scripts/hypr-live-update-guard @@ -3,15 +3,28 @@ # hypr-live-update-guard - abort a live GPU/compositor library upgrade. # # Installed as a pacman PreTransaction hook. When an upgrade transaction -# includes GPU/compositor runtime libraries (mesa, hyprland, wayland, GPU -# drivers, ...) AND a Hyprland session is running, this aborts the -# transaction BEFORE any package is swapped. Replacing those libraries out -# from under a live compositor makes the next GPU-lib call hit a now -# "(deleted)" file and SIGABRT, taking the Wayland clients down with it -# (hit on ratio 2026-06-07: mesa + hyprland upgraded live, Hyprland crashed -# and took awww/insync/emacs with it). Aborting at PreTransaction is the -# safe point: nothing has been replaced yet, so the running session is -# untouched and the user can re-run the upgrade from a TTY. +# would CHANGE the on-disk version of GPU/compositor runtime libraries +# (mesa, hyprland, wayland, GPU drivers, ...) AND a Hyprland session is +# running, this aborts the transaction BEFORE any package is swapped. +# Replacing those libraries out from under a live compositor makes the next +# GPU-lib call hit a now "(deleted)" file and SIGABRT, taking the Wayland +# clients down with it (hit on ratio 2026-06-07: mesa + hyprland upgraded +# live, Hyprland crashed and took awww/insync/emacs with it). Aborting at +# PreTransaction is the safe point: nothing has been replaced yet, so the +# running session is untouched and the user can re-run from a TTY. +# +# Version-aware (2026-07-08): a same-version reinstall writes identical +# bytes over identical bytes and is harmless to the live session — the +# maintenance console's integrity REINSTALL is exactly that after an +# update, and the name-only guard was blocking it. Each target's installed +# version (pacman -Q) is compared against the sync-db candidate +# (expac -S %v); targets that match are pure reinstalls and pass. Both are +# read-only queries and run fine inside a hook — the ALPM lock only gates +# other transactions (pinned live with db.lck present). A target whose +# versions can't be resolved (AUR package, a -U transaction installing a +# local file, expac absent) reads as unknown and blocks conservatively — +# a -U of a guard-listed package at repo-matching version would slip +# through, which is accepted as pathological. # # Pacman feeds the matched package names on stdin (NeedsTargets). # @@ -21,6 +34,9 @@ # HYPR_GUARD_SENTINEL path whose existence also proceeds anyway # (default /run/archsetup-allow-live-gpu-update, # cleared on reboot since /run is tmpfs) +# HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the +# pacman/expac lookups; when set, a package absent +# from the map reads as unknown (blocks) set -u @@ -43,17 +59,63 @@ hyprland_running() { # this is exactly the from-a-TTY-after-logout path the warning points to. hyprland_running || exit 0 -# Collect the triggering packages (stdin from NeedsTargets) for the message. -pkgs=$(cat 2>/dev/null | sort -u | tr '\n' ' ') +# Collect the triggering packages (stdin from NeedsTargets). +targets=$(cat 2>/dev/null | sort -u) + +# "installed candidate" for a package, or nothing when unknown. +versions_for() { + if [ -n "${HYPR_GUARD_VERSIONS+x}" ]; then + printf '%s\n' "$HYPR_GUARD_VERSIONS" \ + | awk -v p="$1" '$1 == p { print $2, $3; exit }' + else + inst=$(pacman -Q -- "$1" 2>/dev/null | awk '{ print $2 }') + # expac prints one line per repo carrying the package; the first is + # the repo pacman resolves from + cand=$(expac -S '%v' -- "$1" 2>/dev/null | head -n 1) + printf '%s %s\n' "$inst" "$cand" + fi +} + +# Split the targets into version-changing (dangerous live) and same-version +# reinstalls (harmless). Unknown versions count as dangerous. +changed="" +count=0 +for pkg in $targets; do + count=$((count + 1)) + # shellcheck disable=SC2046 # splitting "inst cand" into $1 $2 is the point + set -- $(versions_for "$pkg") + inst=${1:-} + cand=${2:-} + if [ -n "$inst" ] && [ -n "$cand" ] && [ "$inst" = "$cand" ]; then + continue # pure reinstall: identical bytes, no swap hazard + fi + changed="$changed $pkg" +done + +# Every guarded target is a pure reinstall -- nothing changes on disk that +# the live session has mapped. Let it through silently. +if [ "$count" -gt 0 ] && [ -z "$changed" ]; then + exit 0 +fi + +# An empty target list shouldn't normally happen (the hook only fires when +# dangerous targets exist); if Hyprland is up, stay safe and abort. +pkg_lines="" +for pkg in $changed; do + pkg_lines="${pkg_lines} - ${pkg} +" +done +[ -n "$pkg_lines" ] || pkg_lines=" (GPU/compositor runtime libraries) +" cat >&2 <<EOF ========================================================================== BLOCKED: live GPU/compositor library upgrade while Hyprland is running ========================================================================== - Packages in this upgrade can crash the running compositor if swapped now: - ${pkgs:-(GPU/compositor runtime libraries)} - + Packages in this upgrade change version and can crash the running + compositor if swapped now: +${pkg_lines} Replacing these out from under a live Hyprland session makes the next GPU-lib call hit a deleted library and SIGABRT, taking your Wayland apps down with it (and risking an unclean shutdown). diff --git a/tests/hypr-live-update-guard/test_hypr_live_update_guard.py b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py index 5ec5ce8..578edc7 100644 --- a/tests/hypr-live-update-guard/test_hypr_live_update_guard.py +++ b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py @@ -12,6 +12,10 @@ Test seams (env vars the production script honors): HYPR_GUARD_RUNNING 1/0 forces the Hyprland-running check (default: pgrep) HYPR_ALLOW_LIVE_UPDATE 1 overrides the guard (proceed anyway) HYPR_GUARD_SENTINEL path whose existence also overrides the guard + HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the + pacman -Q / expac -S version lookups; when set, + a package absent from the map reads as unknown + (conservative block) Run from repo root: python3 -m unittest tests.hypr-live-update-guard.test_hypr_live_update_guard @@ -26,8 +30,18 @@ import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) GUARD = os.path.join(REPO_ROOT, "scripts", "hypr-live-update-guard") +# Every package the legacy tests feed, mapped to a version CHANGE — those +# tests describe real upgrades, and the map keeps them hermetic (no +# pacman/expac calls against the test host). +CHANGING_VERSIONS = "\n".join(( + "mesa 25.1.0-1 26.0.0-1", + "hyprland 0.55.3-1 0.55.4-1", + "vulkan-radeon 25.1.0-1 26.0.0-1", +)) -def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None): + +def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None, + versions=CHANGING_VERSIONS): env = dict(os.environ) env["HYPR_GUARD_RUNNING"] = running if allow is not None: @@ -35,6 +49,7 @@ def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None): # Point the sentinel at a path that does not exist unless a test sets one, # so the host's real /run state can't leak into the result. env["HYPR_GUARD_SENTINEL"] = sentinel if sentinel else "/nonexistent/guard-sentinel" + env["HYPR_GUARD_VERSIONS"] = versions return subprocess.run( ["sh", GUARD], input=stdin, capture_output=True, text=True, timeout=10, env=env, @@ -75,6 +90,42 @@ class HyprLiveUpdateGuard(unittest.TestCase): r = run_guard(stdin="", running="1") self.assertEqual(r.returncode, 1) + # --- Version awareness ------------------------------------------------ + + def test_same_version_reinstall_allows(self): + # a pure reinstall replaces identical bytes with identical bytes — + # no live-swap hazard, the guard must let it through + r = run_guard(stdin="hyprland\n", running="1", + versions="hyprland 0.55.4-1 0.55.4-1") + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(r.stderr.strip(), "") + + def test_all_same_version_multi_pkg_allows(self): + versions = "\n".join(("mesa 26.1.4-1 26.1.4-1", + "hyprland 0.55.4-1 0.55.4-1", + "vulkan-radeon 26.1.4-1 26.1.4-1")) + r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1", + versions=versions) + self.assertEqual(r.returncode, 0, r.stderr) + + def test_mixed_blocks_naming_only_the_version_changing(self): + versions = "\n".join(("mesa 25.1.0-1 26.0.0-1", + "hyprland 0.55.4-1 0.55.4-1")) + r = run_guard(stdin="mesa\nhyprland\n", running="1", + versions=versions) + self.assertEqual(r.returncode, 1) + self.assertIn("- mesa", r.stderr) + # the prose mentions the Hyprland session, so assert on the + # package-list line format, not the bare word + self.assertNotIn("- hyprland", r.stderr) + + def test_unknown_versions_block_conservatively(self): + # seam set but package absent from the map = the lookup failed + # (AUR target, -U transaction, expac missing) — stay safe + r = run_guard(stdin="mesa\n", running="1", versions="") + self.assertEqual(r.returncode, 1) + self.assertIn("- mesa", r.stderr) + # --- Override / error cases ----------------------------------------- def test_env_override_proceeds_even_when_running(self): |
