aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup42
-rwxr-xr-xscripts/hypr-live-update-guard70
-rw-r--r--tests/hypr-live-update-guard/test_hypr_live_update_guard.py95
3 files changed, 207 insertions, 0 deletions
diff --git a/archsetup b/archsetup
index 264ea0f..7531821 100755
--- a/archsetup
+++ b/archsetup
@@ -1903,6 +1903,48 @@ UDEVEOF
sed -i "s/ARCHSETUP_USERNAME/${username}/" /etc/udev/rules.d/99-logitech-brio.rules
chmod 644 /etc/udev/rules.d/99-logitech-brio.rules
fi
+
+ # Live-update guard: a pacman PreTransaction hook that aborts an upgrade of
+ # GPU/compositor runtime libraries while a Hyprland session is running, so
+ # the live compositor doesn't SIGABRT when a library is swapped underneath
+ # it (hit ratio 2026-06-07: live mesa + hyprland upgrade crashed Hyprland and
+ # its clients). Re-run the upgrade from a TTY with Hyprland stopped and the
+ # guard stays quiet.
+ action="Live-Update Guard" && display "subtitle" "$action"
+ run_task "installing the live GPU/compositor update guard" \
+ cp "$user_archsetup_dir/scripts/hypr-live-update-guard" /usr/local/bin/hypr-live-update-guard
+ chmod 755 /usr/local/bin/hypr-live-update-guard
+
+ action="installing the live-update guard pacman hook" && display "task" "$action"
+ mkdir -p /etc/pacman.d/hooks
+ cat > /etc/pacman.d/hooks/hypr-live-update-guard.hook << 'HOOKEOF'
+[Trigger]
+Operation = Upgrade
+Type = Package
+Target = mesa
+Target = mesa-*
+Target = wayland
+Target = libdrm
+Target = libglvnd
+Target = hyprland
+Target = aquamarine
+Target = hyprutils
+Target = hyprgraphics
+Target = vulkan-radeon
+Target = vulkan-intel
+Target = vulkan-mesa-layers
+Target = nvidia-utils
+Target = lib32-nvidia-utils
+Target = xorg-xwayland
+
+[Action]
+Description = Checking for a live Hyprland session before swapping GPU/compositor libs...
+When = PreTransaction
+Exec = /usr/local/bin/hypr-live-update-guard
+AbortOnFail
+NeedsTargets
+HOOKEOF
+ chmod 644 /etc/pacman.d/hooks/hypr-live-update-guard.hook
}
### Display Server (conditional)
diff --git a/scripts/hypr-live-update-guard b/scripts/hypr-live-update-guard
new file mode 100755
index 0000000..4f561ae
--- /dev/null
+++ b/scripts/hypr-live-update-guard
@@ -0,0 +1,70 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# 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.
+#
+# Pacman feeds the matched package names on stdin (NeedsTargets).
+#
+# Test seams / overrides (env):
+# HYPR_GUARD_RUNNING 1/0 forces the running check (default: pgrep Hyprland)
+# HYPR_ALLOW_LIVE_UPDATE 1 proceeds anyway (skip the guard)
+# HYPR_GUARD_SENTINEL path whose existence also proceeds anyway
+# (default /run/archsetup-allow-live-gpu-update,
+# cleared on reboot since /run is tmpfs)
+
+set -u
+
+sentinel="${HYPR_GUARD_SENTINEL:-/run/archsetup-allow-live-gpu-update}"
+
+# Explicit override: the user knows what they're doing.
+if [ "${HYPR_ALLOW_LIVE_UPDATE:-0}" = "1" ] || [ -e "$sentinel" ]; then
+ exit 0
+fi
+
+hyprland_running() {
+ if [ -n "${HYPR_GUARD_RUNNING:-}" ]; then
+ [ "$HYPR_GUARD_RUNNING" = "1" ]
+ return
+ fi
+ pgrep -x Hyprland >/dev/null 2>&1
+}
+
+# No live session means no live swap to worry about. Let the upgrade run --
+# 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' ' ')
+
+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)}
+
+ 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).
+
+ Do it safely instead -- from a TTY with Hyprland stopped:
+ 1. Log out of Hyprland, or switch to a console (Ctrl+Alt+F2) and log in.
+ 2. Re-run the upgrade there: sudo pacman -Syu
+
+ To override and proceed anyway (not recommended while Hyprland runs):
+ sudo touch $sentinel && sudo pacman -Syu
+==========================================================================
+
+EOF
+exit 1
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
new file mode 100644
index 0000000..5ec5ce8
--- /dev/null
+++ b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py
@@ -0,0 +1,95 @@
+"""Tests for the hypr-live-update-guard pacman PreTransaction hook script.
+
+The guard aborts a live pacman upgrade of GPU/compositor runtime libraries
+(mesa, hyprland, wayland, GPU drivers) while a Hyprland session is running,
+so the compositor doesn't SIGABRT when a now-"(deleted)" library is next
+called. It reads the triggering package names on stdin (pacman NeedsTargets)
+and exits non-zero to abort the transaction (AbortOnFail) before any package
+is swapped. When Hyprland isn't running, or an override is set, it exits 0
+and the upgrade proceeds.
+
+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
+
+Run from repo root:
+ python3 -m unittest tests.hypr-live-update-guard.test_hypr_live_update_guard
+"""
+
+import os
+import subprocess
+import tempfile
+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")
+
+
+def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None):
+ env = dict(os.environ)
+ env["HYPR_GUARD_RUNNING"] = running
+ if allow is not None:
+ env["HYPR_ALLOW_LIVE_UPDATE"] = allow
+ # 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"
+ return subprocess.run(
+ ["sh", GUARD],
+ input=stdin, capture_output=True, text=True, timeout=10, env=env,
+ )
+
+
+class HyprLiveUpdateGuard(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_running_with_dangerous_pkg_aborts(self):
+ r = run_guard(stdin="mesa\n", running="1")
+ self.assertEqual(r.returncode, 1, r.stderr)
+
+ def test_abort_message_names_the_package_and_tty_remedy(self):
+ r = run_guard(stdin="mesa\n", running="1")
+ self.assertIn("mesa", r.stderr)
+ self.assertIn("TTY", r.stderr)
+
+ def test_not_running_allows(self):
+ r = run_guard(stdin="mesa\n", running="0")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_not_running_is_silent(self):
+ r = run_guard(stdin="mesa\nhyprland\n", running="0")
+ self.assertEqual(r.stderr.strip(), "")
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_multiple_packages_all_listed(self):
+ r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1")
+ self.assertEqual(r.returncode, 1)
+ for pkg in ("mesa", "hyprland", "vulkan-radeon"):
+ self.assertIn(pkg, r.stderr)
+
+ def test_running_with_empty_stdin_still_guards(self):
+ # The hook only fires when dangerous targets exist, so an empty target
+ # list shouldn't normally happen; if Hyprland is up, stay safe (abort).
+ r = run_guard(stdin="", running="1")
+ self.assertEqual(r.returncode, 1)
+
+ # --- Override / error cases -----------------------------------------
+
+ def test_env_override_proceeds_even_when_running(self):
+ r = run_guard(stdin="mesa\n", running="1", allow="1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_sentinel_file_override_proceeds(self):
+ with tempfile.NamedTemporaryFile(prefix="guard-allow-") as f:
+ r = run_guard(stdin="mesa\n", running="1", sentinel=f.name)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_override_env_zero_does_not_bypass(self):
+ r = run_guard(stdin="mesa\n", running="1", allow="0")
+ self.assertEqual(r.returncode, 1, r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()