From ec3734c5ef9fcfe97c21cd19f198ec779ab5f052 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Fri, 29 May 2026 11:18:15 +0100 Subject: refactor(suspend): gate suspend on AC, drop bespoke zellij inhibit New, simpler suspend policy: AC plugged in -> never auto-suspends (lid close ignored, idle no-op) On battery only -> lid close suspends, swayidle suspends at 30 min idle This replaces the SSH/zellij-aware inhibit machinery with a rule that matches the user's mental model: if you don't want the machine to sleep, plug it in. Long-running tasks (builds, downloads, SSH sessions, headless services) just need AC. Changes: * etc/systemd/logind.conf.d/20-lid-ac.conf: set HandleLidSwitchExternalPower=ignore so logind itself handles the AC case at the source. No userspace daemon, no race, no rate-limit risk. * dot_local/bin/on-battery-suspend: tiny POSIX wrapper that exits 0 when any /sys/class/power_supply/{AC,ADP}*/online == 1, else execs `systemctl suspend`. * dot_config/systemd/user/swayidle.service: add `timeout 1800 on-battery-suspend`. Idle suspend now exists, but only when on battery. * Delete zellij-inhibit-suspend.{path,service} + watcher script and remove the entry from systemd-units/user.txt. The .path re-trigger storm bug is moot because the whole mechanism is gone. Manual suspends (sway XF86Sleep keybind, sway power submode `s`, `systemctl suspend` over SSH) still always work regardless of AC -- explicit user intent wins. Also drop /migrate-podman-to-btrfs.sh from .gitignore; the one-off migration script has been deleted now that the user has switched their podman storage to the btrfs driver. On-host steps to apply: chezmoi apply -v systemctl --user daemon-reload systemctl --user reset-failed zellij-inhibit-suspend.service zellij-inhibit-suspend.path || true systemctl --user stop zellij-inhibit-suspend.path zellij-inhibit-suspend.service || true systemctl --user disable zellij-inhibit-suspend.path || true systemctl --user restart swayidle.service # logind drop-in is reloaded automatically by the etc deploy script. Verify: systemctl status systemd-logind | grep -i lid loginctl show-session $XDG_SESSION_ID | grep -i lid # Unplug AC -> close lid -> should suspend. # Plug AC -> close lid -> nothing happens. --- .gitignore | 1 - dot_config/systemd/user/swayidle.service | 1 + .../systemd/user/zellij-inhibit-suspend.path | 15 ---- .../systemd/user/zellij-inhibit-suspend.service | 23 ------ dot_local/bin/executable_on-battery-suspend | 18 +++++ dot_local/bin/executable_zellij-inhibit-watcher | 81 ---------------------- etc/systemd/logind.conf.d/20-lid-ac.conf | 14 ++++ systemd-units/user.txt | 6 -- 8 files changed, 33 insertions(+), 126 deletions(-) delete mode 100644 dot_config/systemd/user/zellij-inhibit-suspend.path delete mode 100644 dot_config/systemd/user/zellij-inhibit-suspend.service create mode 100644 dot_local/bin/executable_on-battery-suspend delete mode 100755 dot_local/bin/executable_zellij-inhibit-watcher create mode 100644 etc/systemd/logind.conf.d/20-lid-ac.conf diff --git a/.gitignore b/.gitignore index 9a33068..3c74dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ .ruff_cache/ node_modules/ *.swp -/migrate-podman-to-btrfs.sh diff --git a/dot_config/systemd/user/swayidle.service b/dot_config/systemd/user/swayidle.service index 478c8f8..acd0196 100644 --- a/dot_config/systemd/user/swayidle.service +++ b/dot_config/systemd/user/swayidle.service @@ -10,6 +10,7 @@ ExecStart=/usr/bin/swayidle -w \ timeout 300 'swaymsg "output * power off"' \ resume 'swaymsg "output * power on"' \ timeout 330 'swaylock -f -e -c 000000' \ + timeout 1800 '%h/.local/bin/on-battery-suspend' \ before-sleep 'playerctl -a pause; swaylock -f -e -c 000000' \ lock 'swaylock -f -e -c 000000' Restart=on-failure diff --git a/dot_config/systemd/user/zellij-inhibit-suspend.path b/dot_config/systemd/user/zellij-inhibit-suspend.path deleted file mode 100644 index 2a4be21..0000000 --- a/dot_config/systemd/user/zellij-inhibit-suspend.path +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Activate suspend inhibitor whenever zellij has a live session - -[Path] -# %t expands to $XDG_RUNTIME_DIR (typically /run/user/$UID); zellij keeps -# its per-version session sockets under this directory. Whenever the dir -# transitions from empty to non-empty, the service is (re)activated. -# The service's watcher then decides whether to actually hold the lock -# (only if at least one zellij was spawned from an SSH session); if not, -# it exits immediately and the service stops with no harm done. -DirectoryNotEmpty=%t/zellij -Unit=zellij-inhibit-suspend.service - -[Install] -WantedBy=default.target diff --git a/dot_config/systemd/user/zellij-inhibit-suspend.service b/dot_config/systemd/user/zellij-inhibit-suspend.service deleted file mode 100644 index 7c73c64..0000000 --- a/dot_config/systemd/user/zellij-inhibit-suspend.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=Stay alive while any zellij session exists; inhibit suspend if SSH-spawned -Documentation=man:systemd-inhibit(1) man:zellij(1) -# Independent of any graphical session: this is meant to run on -# headless SSH-attached hosts too. The watcher itself decides whether -# the current zellij activity warrants inhibiting (SSH-spawned only), -# and acquires/releases its own systemd-inhibit lock dynamically. It -# stays alive for the whole zellij dir lifetime so the .path unit does -# not retrigger us in a busy loop when only local zellij sessions are -# active. -# Disable systemd's default start-rate limiter: even though the -# refactored watcher should not cycle anymore, a zero rate-limit makes -# this unit resilient if the user kills it manually. -StartLimitIntervalSec=0 - -[Service] -Type=simple -ExecStart=%h/.local/bin/zellij-inhibit-watcher -# Don't auto-restart: the .path unit reactivates us on the next session. -Restart=no - -[Install] -WantedBy=default.target diff --git a/dot_local/bin/executable_on-battery-suspend b/dot_local/bin/executable_on-battery-suspend new file mode 100644 index 0000000..2f39cc7 --- /dev/null +++ b/dot_local/bin/executable_on-battery-suspend @@ -0,0 +1,18 @@ +#!/bin/sh +# Suspend the system, but only if running on battery. Used as the +# swayidle `timeout` action so idle suspends save battery without ever +# firing while the laptop is plugged in. +# +# Manual suspends (sway keybinds, `systemctl suspend` over SSH) bypass +# this script and always work -- explicit user intent wins. +set -eu + +for ac in /sys/class/power_supply/AC*/online \ + /sys/class/power_supply/ADP*/online; do + [ -r "$ac" ] || continue + if [ "$(cat "$ac")" = "1" ]; then + exit 0 + fi +done + +exec systemctl suspend diff --git a/dot_local/bin/executable_zellij-inhibit-watcher b/dot_local/bin/executable_zellij-inhibit-watcher deleted file mode 100755 index 7537b36..0000000 --- a/dot_local/bin/executable_zellij-inhibit-watcher +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/sh -# Stay alive while any zellij session exists; hold a systemd-inhibit -# lock only while at least one of those zellij sessions was spawned from -# an SSH context. -# -# Rationale: a zellij session started locally (e.g. from a sway terminal) -# is the user actively sitting in front of the laptop — that should NOT -# inhibit suspend. Only zellij sessions started while SSH'd in deserve -# the lock, so the host stays awake across detach + disconnect but -# normal local-attended suspend still works. -# -# Detection: zellij's daemonised server is exec'd by the client and -# inherits the client's environment. Linux preserves that exec-time -# environment in /proc//environ for the life of the process, even -# after the original SSH session is gone. So an "ssh-spawned" zellij is -# one whose environ contains SSH_CONNECTION=. -# -# Lifecycle: the .path unit starts this script when the zellij socket -# directory becomes non-empty. The script then polls and stays alive as -# long as any zellij socket exists, so the .path unit never re-triggers -# the service while zellij is up (which previously caused a start-rate -# limit storm when only local zellij was around). When the last zellij -# exits, this script exits, the service stops, and the .path resumes -# watching for the next session. -set -eu - -poll=${ZELLIJ_INHIBIT_POLL:-15} -sock_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zellij" - -has_ssh_zellij() { - pids=$(pgrep -x zellij 2>/dev/null) || return 1 - for pid in $pids; do - [ -r "/proc/$pid/environ" ] || continue - if tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | - grep -q '^SSH_CONNECTION='; then - return 0 - fi - done - return 1 -} - -any_zellij_socket() { - [ -d "$sock_dir" ] || return 1 - found=$(ls -A "$sock_dir" 2>/dev/null || true) - [ -n "$found" ] -} - -inhibit_pid= -release_inhibit() { - pid=$inhibit_pid - inhibit_pid= - [ -n "$pid" ] || return 0 - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true -} -trap release_inhibit EXIT INT TERM - -inhibit_alive() { - [ -n "$inhibit_pid" ] || return 1 - kill -0 "$inhibit_pid" 2>/dev/null -} - -acquire_inhibit() { - if inhibit_alive; then return 0; fi - systemd-inhibit \ - --what=sleep:idle:handle-lid-switch \ - --who=zellij \ - --why='Active SSH-spawned zellij sessions' \ - --mode=block \ - sleep infinity & - inhibit_pid=$! -} - -while any_zellij_socket; do - if has_ssh_zellij; then - acquire_inhibit - else - release_inhibit - fi - sleep "$poll" -done diff --git a/etc/systemd/logind.conf.d/20-lid-ac.conf b/etc/systemd/logind.conf.d/20-lid-ac.conf new file mode 100644 index 0000000..08e5014 --- /dev/null +++ b/etc/systemd/logind.conf.d/20-lid-ac.conf @@ -0,0 +1,14 @@ +[Login] +# Suspend policy: closing the lid suspends ONLY when on battery. When +# AC is connected the lid switch is ignored entirely. The rationale is +# simple and matches user mental model -- if you don't want the +# machine to sleep, plug it in. +# +# This obsoletes the previous SSH/zellij-aware inhibit machinery: any +# long-running task (build, download, SSH session, server) just needs +# AC and will never be suspended out from under itself. +# +# `HandleLidSwitch` (default = suspend) still applies on battery. +# `HandleLidSwitchDocked` (default = ignore) still applies when an +# external monitor / dock is attached. +HandleLidSwitchExternalPower=ignore diff --git a/systemd-units/user.txt b/systemd-units/user.txt index 77ba788..009b2b3 100644 --- a/systemd-units/user.txt +++ b/systemd-units/user.txt @@ -18,9 +18,3 @@ wob.service # --- mail (overridden via drop-ins in dot_config/systemd/user/) --- protonmail-bridge.service - -# --- suspend inhibitor: keep host awake while any zellij session exists --- -# Enable the .path unit; it activates the service on-demand when zellij's -# socket dir becomes non-empty. The service wraps a poller in -# systemd-inhibit and exits when no sessions remain, releasing the lock. -zellij-inhibit-suspend.path -- cgit v1.3.1