aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xscripts/hypr-live-update-guard90
-rw-r--r--tests/hypr-live-update-guard/test_hypr_live_update_guard.py53
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):