Linux laptop power policy for vibecoding
I start a coding agent on a long task, close the lid, and put my Linux laptop in my backpack.
By default, systemd-logind suspends the machine when the lid closes. The agent stops mid-task, its network connections time out, and when I open the lid again the sessions have stopped. Or I disable suspend and risk running completely out of battery.
What I want is a laptop that ignores the lid and keeps working until the battery is nearly empty, and then hibernates instead of dying, but doesn’t hibernate when the lid is open. Then I can close the lid to indicate I’m absent and willing to hibernate, and I can code from my backpack.
tl;dr ¶
- Set logind’s
HandleLidSwitch,HandleLidSwitchExternalPowerandHandleLidSwitchDockedtoignore, and closing the lid no longer suspends anything. - A systemd service polls
/sys/class/power_supplyand the ACPI lid state every 15 seconds. - At 5% battery with the lid closed and no charger, it runs
systemctl hibernate. - At 5% battery with the lid open, it switches power-profiles-daemon to
power-saver, the same switch as GNOME’s Power Saver toggle. - Hibernation goes to a swapfile on a LUKS root, which takes
boot.resumeDeviceand aresume_offsetkernel parameter computed withfilefrag. - A stale
resume_offsetloses the hibernated session, not the filesystem, so the daemon re-checks the swapfile’s first block before hibernating and refuses if it moved.
logind lets go of the lid ¶
A daemon that decides what the lid means cannot share the job with logind, so logind is told to stay out of it:
services.logind.settings.Login = {
HandleLidSwitch = lib.mkDefault "ignore";
HandleLidSwitchExternalPower = lib.mkDefault "ignore";
HandleLidSwitchDocked = lib.mkDefault "ignore";
};
All three are needed. HandleLidSwitch alone only covers running on battery; on a charger or a
dock, logind reads the other two.
Four cases ¶
The policy is a shell loop. Each round it reads the lid state, the lowest capacity across all
BAT* supplies, and whether any Mains or USB supply is online. Stripped of logging, the
dry-run branch, and the offset guard, the decision is:
if [ -n "$capacity" ] && [ "$capacity" -le "$threshold" ]; then
if [ "$lid" = "closed" ] && { [ "$hibernate_on_ac" = "1" ] || [ "$ac_online" -eq 0 ]; }; then
systemctl hibernate && sleep "$post_hibernate_delay"
elif [ "$lid" != "closed" ]; then
set_power_saver
fi
fi
Above the threshold nothing happens, lid open or closed. That is the point: the agent keeps going.
At the threshold with the lid closed and a charger attached, nothing happens either, since the
battery is on its way up. hibernateOnAC overrides that for chargers too weak to keep up.
At the threshold with the lid open, I am at the keyboard and can decide for myself. The daemon
only switches to power-saver to buy some time.
The script reads every path from environment variables, so it runs against a fake sysfs. With
BAT0/capacity at 4 and the charger offline:
$ POWER_POLICY_SYSFS=./sys POWER_POLICY_LID_FILE=./lid \
POWER_POLICY_DRY_RUN=1 POWER_POLICY_ONESHOT=1 bash power-policy.sh
power-policy: starting (threshold=5%, poll=15s, profile=power-saver)
power-policy: battery 4%, lid closed, AC 0; would hibernate
And with the lid file changed to open:
power-policy: starting (threshold=5%, poll=15s, profile=power-saver)
power-policy: would set power profile to power-saver
Waking up at 4% ¶
After resume the battery is still at 4%. Without a pause the loop would see the same numbers and hibernate again before I find the charger.
So after a successful systemctl hibernate, the loop sleeps for postHibernateDelay, five minutes
by default. A separate oneshot unit, wanted by hibernate.target, switches to power-saver as soon
as the machine is back:
systemd.services.power-policy-resume = {
wantedBy = [ "hibernate.target" ];
after = [ "systemd-hibernate.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl set ${cfg.powerSaverProfile}";
};
};
If hibernation fails, the loop falls back to power-saver and keeps polling.
Hibernating to an encrypted swapfile ¶
My swap is a file on the encrypted root, not a partition. The kernel resumes from a block device and needs to know where in it the swapfile starts. The module exposes both:
boot.resumeDevice = lib.mkIf (cfg.resumeDevice != null) cfg.resumeDevice;
boot.kernelParams = lib.optional (cfg.resumeOffset != null) "resume_offset=${toString cfg.resumeOffset}";
The device is the unlocked LUKS mapping, /dev/mapper/cryptroot. The offset is the physical offset
of the swapfile’s first extent:
filefrag -v /var/lib/swapfile | awk '$1 == "0:" {print $4}'
resume_offset is a read pointer, not a write target. Hibernation writes the image through the
active swap area. At boot the kernel reads the offset to find that image. If the swapfile is
recreated at a different offset, the next boot reads the wrong block, fails the swap signature and
image checksum, and boots normally. The hibernated session is gone, but no other file is
overwritten.
That is still a bad way to lose a session, so the daemon does not trust the number. It compares the
swapfile’s current first block with the configured offset once at startup and again immediately
before hibernating. If they differ, it logs the mismatch and switches to power-saver instead of
hibernating:
power-policy: refusing to hibernate: swapfile offset is 3606528, configured resumeOffset is 3606529
The laptop sets the swapfile and leaves the guard on:
swapfile = "/var/lib/swapfile";
verifyResumeOffset = true;
The check needs filefrag from e2fsprogs. If it cannot read the file, the daemon refuses rather
than guess. A manual systemctl hibernate bypasses the daemon and the guard, so update the offset
and rebuild before doing that.
The first version of the guard piped filefrag into awk ... exit, which got SIGPIPE on a 16G
swapfile. With set -euo pipefail that killed the daemon every time it checked the offset. The
version below reads the whole extent list instead.
The whole module ¶
The examples are Nix because that is how I manage this laptop. The policy itself is plain Linux:
systemd-logind, ACPI, sysfs, and power-profiles-daemon. It is a dendritic flake-parts module
that exports nixosModules.power-policy. The shell script lives inside it as a
writeShellScript, and the options become environment variables:
# modules/power-policy.nix
{ ... }:
{
flake.nixosModules.power-policy =
{ config, lib, pkgs, ... }:
let
cfg = config.services.power-policy;
powerPolicy = pkgs.writeShellScript "power-policy" ''
set -euo pipefail
threshold="''${POWER_POLICY_THRESHOLD:-5}"
poll="''${POWER_POLICY_POLL_INTERVAL:-15}"
profile="''${POWER_POLICY_POWER_SAVER_PROFILE:-power-saver}"
lid_file="''${POWER_POLICY_LID_FILE:-}"
sysfs="''${POWER_POLICY_SYSFS:-/sys/class/power_supply}"
dry_run="''${POWER_POLICY_DRY_RUN:-0}"
hibernate_on_ac="''${POWER_POLICY_HIBERNATE_ON_AC:-0}"
post_hibernate_delay="''${POWER_POLICY_POST_HIBERNATE_DELAY:-300}"
swapfile="''${POWER_POLICY_SWAPFILE:-}"
resume_offset="''${POWER_POLICY_RESUME_OFFSET:-}"
verify_resume_offset="''${POWER_POLICY_VERIFY_RESUME_OFFSET:-1}"
log() {
printf 'power-policy: %s\n' "$*"
}
set_power_saver() {
if [ "$dry_run" = "1" ]; then
log "would set power profile to $profile"
return 0
fi
if [ "$(powerprofilesctl get 2>/dev/null || true)" = "$profile" ]; then
return 0
fi
if powerprofilesctl set "$profile"; then
log "set power profile to $profile"
else
log "could not set power profile to $profile"
fi
}
read_capacity() {
local b="$1" now full
if [ -r "$b/capacity" ]; then
cat "$b/capacity"
return 0
fi
if [ -r "$b/energy_now" ] && [ -r "$b/energy_full" ]; then
now="$(cat "$b/energy_now")"
full="$(cat "$b/energy_full")"
elif [ -r "$b/charge_now" ] && [ -r "$b/charge_full" ]; then
now="$(cat "$b/charge_now")"
full="$(cat "$b/charge_full")"
else
return 1
fi
if [ "$full" -gt 0 ]; then
echo $(( now * 100 / full ))
fi
}
swapfile_offset() {
filefrag -v "$swapfile" 2>/dev/null | awk '$1 == "0:" { sub(/\..*/, "", $4); print $4 }' || true
}
if [ "$verify_resume_offset" = "1" ] && [ -n "$swapfile" ] && [ -n "$resume_offset" ]; then
actual_offset="$(swapfile_offset)"
if [ "$actual_offset" != "$resume_offset" ]; then
log "warning: swapfile offset is ''${actual_offset:-unknown}, configured resumeOffset is $resume_offset; hibernation will be refused"
fi
fi
log "starting (threshold=$threshold%, poll=''${poll}s, profile=$profile)"
while :; do
if [ -z "$lid_file" ]; then
lid_file="$(find /proc/acpi/button/lid -name state -type f 2>/dev/null | head -n 1 || true)"
fi
lid="unknown"
if [ -n "$lid_file" ] && [ -r "$lid_file" ]; then
lid="$(awk '{print $2}' "$lid_file")"
fi
capacity=""
for b in "$sysfs"/BAT*; do
[ -d "$b" ] || continue
c="$(read_capacity "$b" 2>/dev/null || true)"
case "$c" in
"" | *[!0-9]*) continue ;;
esac
if [ -z "$capacity" ] || [ "$c" -lt "$capacity" ]; then
capacity="$c"
fi
done
ac_online=0
for a in "$sysfs"/*; do
[ -d "$a" ] || continue
supply_type="$(cat "$a/type" 2>/dev/null || true)"
case "$supply_type" in
Mains | USB)
if [ "$(cat "$a/online" 2>/dev/null || echo 0)" = "1" ]; then
ac_online=1
fi
;;
esac
done
if [ -n "$capacity" ] && [ "$capacity" -le "$threshold" ]; then
if [ "$lid" = "closed" ] && { [ "$hibernate_on_ac" = "1" ] || [ "$ac_online" -eq 0 ]; }; then
can_hibernate=1
if [ "$verify_resume_offset" = "1" ] && [ -n "$swapfile" ] && [ -n "$resume_offset" ]; then
actual_offset="$(swapfile_offset)"
if [ "$actual_offset" != "$resume_offset" ]; then
log "refusing to hibernate: swapfile offset is ''${actual_offset:-unknown}, configured resumeOffset is $resume_offset"
can_hibernate=0
fi
fi
if [ "$can_hibernate" = "1" ]; then
if [ "$dry_run" = "1" ]; then
log "battery ''${capacity}%, lid closed, AC ''${ac_online}; would hibernate"
else
log "battery ''${capacity}%, lid closed, AC ''${ac_online}; hibernating"
if systemctl hibernate; then
sleep "$post_hibernate_delay"
else
log "hibernate failed; falling back to power saver"
set_power_saver
sleep "$poll"
fi
fi
else
set_power_saver
fi
elif [ "$lid" != "closed" ]; then
set_power_saver
fi
fi
if [ "''${POWER_POLICY_ONESHOT:-0}" = "1" ]; then
break
fi
sleep "$poll"
done
'';
in
{
options.services.power-policy = {
enable = lib.mkEnableOption "lid/battery power policy";
batteryThreshold = lib.mkOption {
type = lib.types.ints.between 1 100;
default = 5;
description = "Battery percentage at or below which to hibernate when the lid is closed.";
};
pollInterval = lib.mkOption {
type = lib.types.ints.positive;
default = 15;
description = "Seconds between battery and lid checks.";
};
powerSaverProfile = lib.mkOption {
type = lib.types.str;
default = "power-saver";
description = "power-profiles-daemon profile to activate instead of hibernating.";
};
lidStateFile = lib.mkOption {
type = lib.types.str;
default = "";
description = "Lid state file. Empty means auto-detect under /proc/acpi/button/lid.";
};
hibernateOnAC = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Hibernate at the threshold even while AC power is connected.";
};
postHibernateDelay = lib.mkOption {
type = lib.types.ints.positive;
default = 300;
description = "Seconds to wait after resuming from hibernation before checking again.";
};
resumeDevice = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Block device containing the hibernation swapfile or partition (e.g. /dev/mapper/cryptroot).";
};
resumeOffset = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Physical offset of the swapfile's first block, as reported by filefrag's physical_offset column.";
};
swapfile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Swapfile used for hibernation. When set, the daemon verifies its first physical block against resumeOffset before hibernating.";
};
verifyResumeOffset = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Refuse to hibernate if the swapfile's current first block no longer matches resumeOffset.";
};
};
config = lib.mkIf cfg.enable {
# The policy daemon owns lid behavior; logind must not act on its own.
services.logind.settings.Login = {
HandleLidSwitch = lib.mkDefault "ignore";
HandleLidSwitchExternalPower = lib.mkDefault "ignore";
HandleLidSwitchDocked = lib.mkDefault "ignore";
};
# powerprofilesctl is the same switch as GNOME's Power Saver toggle.
services.power-profiles-daemon.enable = lib.mkDefault true;
boot.resumeDevice = lib.mkIf (cfg.resumeDevice != null) cfg.resumeDevice;
boot.kernelParams = lib.optional (cfg.resumeOffset != null) "resume_offset=${toString cfg.resumeOffset}";
systemd.services.power-policy = {
description = "Lid/battery power policy";
wantedBy = [ "multi-user.target" ];
wants = lib.optional config.services.power-profiles-daemon.enable "power-profiles-daemon.service";
after = lib.optional config.services.power-profiles-daemon.enable "power-profiles-daemon.service";
path = with pkgs; [
coreutils
e2fsprogs
findutils
gawk
power-profiles-daemon
systemd
];
environment = {
POWER_POLICY_THRESHOLD = toString cfg.batteryThreshold;
POWER_POLICY_POLL_INTERVAL = toString cfg.pollInterval;
POWER_POLICY_POWER_SAVER_PROFILE = cfg.powerSaverProfile;
POWER_POLICY_LID_FILE = cfg.lidStateFile;
POWER_POLICY_HIBERNATE_ON_AC = if cfg.hibernateOnAC then "1" else "0";
POWER_POLICY_POST_HIBERNATE_DELAY = toString cfg.postHibernateDelay;
POWER_POLICY_SWAPFILE = if cfg.swapfile != null then cfg.swapfile else "";
POWER_POLICY_RESUME_OFFSET = if cfg.resumeOffset != null then toString cfg.resumeOffset else "";
POWER_POLICY_VERIFY_RESUME_OFFSET = if cfg.verifyResumeOffset then "1" else "0";
};
serviceConfig = {
Type = "simple";
ExecStart = "${powerPolicy}";
Restart = "always";
RestartSec = 5;
};
};
systemd.services.power-policy-resume = {
description = "Activate power saver after resuming from hibernation";
wantedBy = [ "hibernate.target" ];
after = [ "systemd-hibernate.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl set ${cfg.powerSaverProfile}";
};
};
};
};
}
The laptop enables it like this:
# modules/nixos-configurations.nix
config.flake.nixosModules.power-policy
{
services.power-policy = {
enable = true;
# /var/lib/swapfile lives on the encrypted root. Recompute the offset
# if the swapfile is recreated:
# filefrag -v /var/lib/swapfile | awk '$1 == "0:" {print $4}'
resumeDevice = "/dev/mapper/cryptroot";
resumeOffset = 3606528;
swapfile = "/var/lib/swapfile";
};
}