aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-14 04:27:11 -0500
committerCraig Jennings <c@cjennings.net>2026-08-14 04:27:11 -0500
commit5f52d3887c610b93c6918d637f63cc326237aea5 (patch)
tree0b690960e97b3906a9149834ad40763f5c1355a9
parent2e78f55638ee80da6ce4998d37996bea73076efa (diff)
downloadarchsetup-5f52d3887c610b93c6918d637f63cc326237aea5.tar.gz
archsetup-5f52d3887c610b93c6918d637f63cc326237aea5.zip
feat(installer): set a 90-minute suspend-to-hibernate ceiling
systemctl suspend-then-hibernate reads HibernateDelaySec, and unset it depends on the hardware: a machine with a battery hibernates on a low-battery alarm at an hour nobody chose, and one without falls back to systemd's 2h default. 90 minutes states a ceiling both machines share. A laptop can still hibernate earlier, because systemd runs both timers and takes whichever fires first. The number is a tradeoff and the drop-in carries it: long enough that stepping out of a meeting costs a screen unlock rather than a passphrase and a full resume, short enough that a laptop left in a bag drops its encryption keys out of RAM. A suspended machine is still holding them. Numbered 60- because systemd reserves 10-40 for vendor drop-ins under /usr and 60-90 for local ones under /etc. A 10- file here would sort below a vendor drop-in and quietly lose to it.
-rwxr-xr-xarchsetup44
-rw-r--r--tests/installer-steps/test_configure_hibernate_delay.py131
-rw-r--r--tests/installer-steps/test_orchestrators.py2
3 files changed, 176 insertions, 1 deletions
diff --git a/archsetup b/archsetup
index 2555234..9b42f32 100755
--- a/archsetup
+++ b/archsetup
@@ -1689,6 +1689,7 @@ essential_services() {
configure_randomness
configure_networking
configure_power
+ configure_hibernate_delay
configure_backlight_access
configure_ssh_server
configure_fail2ban
@@ -1851,6 +1852,49 @@ UDEVEOF
error_warn "triggering leds udev rules" "$?"
}
+configure_hibernate_delay() {
+ # How long a suspended machine waits before hibernating. Read only by
+ # systemctl suspend-then-hibernate, so it does nothing until something
+ # asks for that — a desktop panel's idle stage, a lid rule, or a hand
+ # invocation.
+ #
+ # Unset, the delay depends on the hardware: a machine with a battery
+ # hibernates on a low-battery alarm at an hour nobody chose, and one
+ # without a battery falls back to systemd's 2h default. 90 minutes
+ # makes it a stated ceiling instead — on a laptop systemd still
+ # hibernates early if the battery gets there first, since it runs both
+ # timers and takes whichever fires soonest. The ceiling is long enough
+ # that stepping away costs a screen unlock rather than a passphrase
+ # and a full resume, and short enough that a laptop in a bag stops
+ # holding its encryption keys in RAM (a suspended machine does; a
+ # hibernated one does not).
+ #
+ # Harmless on a machine that cannot hibernate: nothing reads it there.
+ # Numbered 60- because systemd reserves 10-40 for vendor drop-ins under
+ # /usr and 60-90 for local ones under /etc; a 10- file here would sort
+ # below a vendor drop-in and quietly lose to it.
+ # $1 is the drop-in directory, defaulting to the system's so tests can
+ # run against a temp dir.
+ local confdir="${1:-/etc/systemd/sleep.conf.d}"
+ local dropin="$confdir/60-hibernate-delay.conf"
+
+ action="setting the suspend-to-hibernate delay" && display "task" "$action"
+
+ mkdir -p "$confdir" 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+ cat > "$dropin" << 'SLEEPEOF' 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+# Wait 90 minutes after suspending before hibernating.
+#
+# Unset, the delay depends on the hardware: a machine with a battery
+# hibernates on a low-battery alarm, one without falls back to systemd's
+# 2h default. 90 minutes keeps a short absence cheap (a screen unlock, not
+# a passphrase and a resume) while making sure a laptop left in a bag
+# drops its encryption keys out of RAM rather than holding them for hours.
+[Sleep]
+HibernateDelaySec=90min
+SLEEPEOF
+ chmod 644 "$dropin" 2>> "$logfile" || error_warn "$action" "$?"
+}
+
configure_power() {
# Power
diff --git a/tests/installer-steps/test_configure_hibernate_delay.py b/tests/installer-steps/test_configure_hibernate_delay.py
new file mode 100644
index 0000000..95f2dcb
--- /dev/null
+++ b/tests/installer-steps/test_configure_hibernate_delay.py
@@ -0,0 +1,131 @@
+"""Test configure_hibernate_delay — the suspend-to-hibernate handoff.
+
+The desktop panel can upgrade its idle suspend stage to
+suspend-then-hibernate, but the delay between the two is systemd's
+HibernateDelaySec, which lives in a root-owned drop-in and so cannot be a
+panel setting. Left unset the delay depends on the hardware: a machine
+with a battery hibernates on a low-battery alarm at an hour nobody chose,
+and one without falls back to systemd's 2h default.
+
+90 minutes is the seeded value: long enough that stepping out of a meeting
+costs a screen unlock rather than a passphrase and a resume, short enough
+that a laptop in a bag is not holding its encryption keys in RAM all
+afternoon (a suspended machine is; a hibernated one is not).
+
+Method: sed-extract configure_hibernate_delay from the real `archsetup`,
+point it at a temp config dir, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_hibernate_delay
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(confdir):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^configure_hibernate_delay() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_hibernate_delay "{confdir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def body(confdir):
+ with open(os.path.join(confdir, "60-hibernate-delay.conf")) as f:
+ return f.read()
+
+
+class ConfigureHibernateDelay(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_sleep_drop_in_with_the_delay(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ text = body(d)
+ self.assertIn("[Sleep]", text)
+ self.assertIn("HibernateDelaySec=90min", text)
+
+ def test_drop_in_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(
+ os.stat(os.path.join(d, "60-hibernate-delay.conf")).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_explains_why_the_value_is_not_left_to_systemd(self):
+ # The number is a judgement call, so the file has to carry its
+ # reasoning: whoever changes it next needs the tradeoff, not just
+ # a number to overwrite. Assert on the tradeoff's own terms —
+ # "contains a hash" would pass on any comment at all.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ text = body(d).lower()
+ self.assertIn("2h default", text) # what unset would give
+ self.assertIn("encryption keys", text) # why not longer
+ self.assertIn("passphrase", text) # why not shorter
+
+ def test_drop_in_sorts_above_vendor_drop_ins(self):
+ # systemd reserves 10-40 for /usr and 60-90 for /etc. A 10- file
+ # in /etc sorts below a vendor 20- file and silently loses to it.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ name = os.listdir(d)[0]
+ prefix = int(name.split("-")[0])
+ self.assertGreaterEqual(prefix, 60)
+ self.assertLessEqual(prefix, 90)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_correct_drop_in(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = body(d)
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ self.assertEqual(first, body(d))
+
+ def test_absent_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "systemd", "sleep.conf.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertIn("HibernateDelaySec=90min", body(nested))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ confdir = os.path.join(d, "ro")
+ os.mkdir(confdir)
+ os.chmod(confdir, 0o500)
+ try:
+ r = run(confdir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(confdir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
index 34c46d1..c2eb710 100644
--- a/tests/installer-steps/test_orchestrators.py
+++ b/tests/installer-steps/test_orchestrators.py
@@ -28,7 +28,7 @@ ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
ORCHESTRATORS = {
"essential_services": [
"configure_randomness", "configure_networking", "configure_power",
- "configure_backlight_access",
+ "configure_hibernate_delay", "configure_backlight_access",
"configure_ssh_server", "configure_fail2ban", "configure_firewall",
"configure_service_discovery", "configure_job_scheduling",
"configure_package_cache", "configure_snapshots",