diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-24 23:16:56 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-24 23:16:56 -0500 |
| commit | a9391c90f07a564693bb1994f4f8fe6470efb3a2 (patch) | |
| tree | 73ac6d588491314615ef50dbffc8fdd07842019d /tests/installer-steps | |
| parent | 082d870d6c4c3a3cce85506505582b17ef19487b (diff) | |
| download | archsetup-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.py | 42 | ||||
| -rw-r--r-- | tests/installer-steps/test_hyprlock_pam.py | 85 | ||||
| -rw-r--r-- | tests/installer-steps/test_orchestrators.py | 2 |
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"], } |
