aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-24 23:16:56 -0500
committerCraig Jennings <c@cjennings.net>2026-07-24 23:16:56 -0500
commita9391c90f07a564693bb1994f4f8fe6470efb3a2 (patch)
tree73ac6d588491314615ef50dbffc8fdd07842019d /tests/installer-steps
parent082d870d6c4c3a3cce85506505582b17ef19487b (diff)
downloadarchsetup-a9391c90f07a564693bb1994f4f8fe6470efb3a2.tar.gz
archsetup-a9391c90f07a564693bb1994f4f8fe6470efb3a2.zip
fix(installer): harden the lock path against the AMD-iGPU DPMS lockout
An idle lock on this Strix Halo box wedged the whole session: hyprlock died and the compositor stayed locked with no prompt, recoverable only from a console. It's a documented AMD-integrated-Radeon failure (hyprlock#953, Hyprland#5822) -- a display power cycle via DPMS invalidates the GPU resources the lock client holds, so hyprlock loses its surface and exits without unlocking. No coredump, no OOM; the GPU pulls the rug out. Two installer changes, both scoped and tested: update_grub_cmdline adds amdgpu.runpm=0 on AMD machines only. Disabling GPU runtime power management keeps those resources valid across a display cycle -- the root fix. A no-op on Intel/NVIDIA, and it rides the existing merge so no boot-critical token is touched. configure_hyprlock_pam writes a complete PAM stack. The hyprlock package ships only `auth include login`, leaving account and session uninitialised so pam_end() crashes on cleanup -- a separate documented lockout cause. All three phases now resolve through login, inheriting the keyring the graphical login uses. CALL_SITES pins both new wirings. 372 unit tests, exit 0; each addition proven by reverting it.
Diffstat (limited to 'tests/installer-steps')
-rw-r--r--tests/installer-steps/test_grub_cmdline.py42
-rw-r--r--tests/installer-steps/test_hyprlock_pam.py85
-rw-r--r--tests/installer-steps/test_orchestrators.py2
3 files changed, 129 insertions, 0 deletions
diff --git a/tests/installer-steps/test_grub_cmdline.py b/tests/installer-steps/test_grub_cmdline.py
index 8c0b5b0..4c2d202 100644
--- a/tests/installer-steps/test_grub_cmdline.py
+++ b/tests/installer-steps/test_grub_cmdline.py
@@ -67,6 +67,48 @@ class MergeGrubCmdline(unittest.TestCase):
self.assertIn("zfs=zroot/ROOT/default", out)
+def run_with_gpu(body, gpu):
+ # detect_gpu_vendors is stubbed to a chosen vendor list so the AMD-only
+ # amdgpu.runpm=0 addition can be exercised without real /sys reads.
+ stub = 'detect_gpu_vendors() { printf "%s\\n" %s; }\n' % ("%s", "'" + gpu + "'")
+ stub = 'detect_gpu_vendors() { printf "%s" "$GPU"; [ -n "$GPU" ] && echo; }\n'
+ script = ('logfile=/dev/null\nerror_warn() { echo "WARN: $1"; return 1; }\n'
+ + stub + EXTRACT + "\n" + body + "\n")
+ return subprocess.run(["bash", "-c", script], capture_output=True, text=True,
+ timeout=10, env={**os.environ, "GPU": gpu})
+
+
+class AmdRunpmCmdline(unittest.TestCase):
+ """amdgpu.runpm=0 is added on AMD machines only: the AMD iGPU invalidates
+ hyprlock's GPU resources on a display power cycle, wedging the lock
+ (hyprlock#953). Disabling GPU runtime PM keeps them valid. A no-op on Intel
+ or NVIDIA."""
+
+ def _file(self, gpu):
+ import tempfile
+ fd, path = tempfile.mkstemp(prefix="grub-runpm-")
+ os.close(fd)
+ with open(path, "w") as f:
+ f.write('GRUB_CMDLINE_LINUX_DEFAULT="rw quiet"\n')
+ self.addCleanup(os.remove, path)
+ run_with_gpu(f'update_grub_cmdline "{path}"', gpu)
+ with open(path) as f:
+ return f.read()
+
+ def test_amd_gets_runpm_disabled(self):
+ self.assertIn("amdgpu.runpm=0", self._file("amd"))
+
+ def test_intel_does_not(self):
+ self.assertNotIn("amdgpu.runpm", self._file("intel"))
+
+ def test_no_gpu_detected_does_not(self):
+ self.assertNotIn("amdgpu.runpm", self._file(""))
+
+ def test_amd_with_nvidia_still_gets_it(self):
+ # A hybrid box with an AMD part present still wants the fix.
+ self.assertIn("amdgpu.runpm=0", self._file("amd\nnvidia"))
+
+
class UpdateGrubCmdline(unittest.TestCase):
def run_update(self, grub_body):
with tempfile.TemporaryDirectory() as d:
diff --git a/tests/installer-steps/test_hyprlock_pam.py b/tests/installer-steps/test_hyprlock_pam.py
new file mode 100644
index 0000000..8d7295f
--- /dev/null
+++ b/tests/installer-steps/test_hyprlock_pam.py
@@ -0,0 +1,85 @@
+"""Tests for configure_hyprlock_pam in the archsetup installer.
+
+hyprlock's packaged /etc/pam.d/hyprlock ships only `auth include login`, with no
+account or session sections. pam_end() then crashes cleaning up handles that
+were never initialised -- one of the documented causes of the hyprlock lockout
+where the session wedges with no prompt (hyprlock#953). configure_hyprlock_pam
+writes a complete stack so the account and session phases run.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time, against a fixture PAM path.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_hyprlock_pam
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(path):
+ script = (
+ 'logfile=/dev/null\n'
+ 'display() { :; }\n'
+ 'backup_system_file() { :; }\n'
+ 'error_warn() { echo "WARN:$1"; return 1; }\n'
+ "source <(sed -n '/^configure_hyprlock_pam() {/,/^}/p' \"%s\")\n"
+ 'configure_hyprlock_pam "%s"\n'
+ ) % (ARCHSETUP, path)
+ return subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=10)
+
+
+class ConfigureHyprlockPam(unittest.TestCase):
+ def _write(self, initial="auth include login\n"):
+ fd, path = tempfile.mkstemp(prefix="pam-hyprlock-")
+ os.close(fd)
+ with open(path, "w") as f:
+ f.write(initial)
+ self.addCleanup(os.remove, path)
+ return path
+
+ def _stack(self, path):
+ with open(path) as f:
+ return [l.split()[0] for l in f if l.strip()
+ and not l.lstrip().startswith("#")]
+
+ # ---------------------------------------------------------- normal ----
+ def test_writes_all_three_phases(self):
+ path = self._write()
+ run(path)
+ phases = set(self._stack(path))
+ self.assertEqual(phases, {"auth", "account", "session"},
+ "the complete stack must carry all three PAM phases")
+
+ def test_account_and_session_are_the_added_ones(self):
+ path = self._write()
+ run(path)
+ stack = self._stack(path)
+ self.assertIn("account", stack)
+ self.assertIn("session", stack)
+
+ # -------------------------------------------------------- boundary ----
+ def test_idempotent_on_rerun(self):
+ path = self._write()
+ run(path)
+ first = open(path).read()
+ run(path)
+ self.assertEqual(open(path).read(), first,
+ "a second run must not stack duplicate lines")
+
+ def test_overwrites_the_incomplete_package_default(self):
+ # The package default is exactly `auth include login`; a re-run replaces
+ # it wholesale rather than appending to it.
+ path = self._write("auth include login\n")
+ run(path)
+ self.assertEqual(self._stack(path).count("auth"), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
index 8de75cc..a2b235b 100644
--- a/tests/installer-steps/test_orchestrators.py
+++ b/tests/installer-steps/test_orchestrators.py
@@ -169,6 +169,8 @@ CALL_SITES = {
"configure_snapshots": ["configure_zfs_snapshots", "configure_btrfs_snapshots"],
"configure_zfs_snapshots": ["zfs_scrub_timer_units"],
"install_maintenance_config": ["install_maintenance_thresholds"],
+ "hyprland": ["configure_hyprlock_pam"],
+ "update_grub_cmdline": ["detect_gpu_vendors"],
}