Files
omarchycn/test/shell.d/lid-close-test.sh
David Heinemeier HanssonandClaude Opus 5 9ddcec272d Lock the screen before suspend instead of racing logind for it
Closing the lid could suspend the machine with the session still exposed.
omarchy-sleep-lock held a delay inhibitor and waited for Quickshell to report
the session secure, but a delay inhibitor is a timer rather than a promise:
logind suspends once InhibitDelayMaxSec expires, locked or not. The default is
five seconds, and closing the lid also reconfigures displays -- exactly when
the lock service is waiting for the screen set to settle before it can secure.

The race is now off the critical path. switch:on:Lid Switch runs
omarchy-system-lid-close, which requests the lock the moment the lid closes,
before logind has decided to suspend at all, so the inhibitor window usually
finds the session already secure. A docked lid close does not suspend
(HandleLidSwitchDocked defaults to ignore) and must not lock either, since that
is clamshell mode still in use on the external display, so the handler gates on
the same closed-and-undocked pair logind itself keys on.

Suspends that never touch the lid -- idle timeout, the system menu, low battery
-- still arrive through the inhibitor, so that path gets room to work too. The
shipped drop-in raises InhibitDelayMaxSec to 15s, and the helper derives its
budget from logind's live InhibitDelayMaxUSec rather than assuming the drop-in
landed: a machine that has not reloaded logind yet, or that carries its own
override, gets a budget that fits what logind will actually allow. It leaves
logind a fifth of its own window and caps at 12s, so a hand-raised window
cannot strand a closed laptop awake in a bag.

The wait itself had three defects. Its deadline arithmetic read EPOCHREALTIME
assuming a period, so under any comma-decimal locale the subtraction parsed as
bash's comma operator and silently voided the deadline, leaving only the
attempt counter to stop it. The lock request shared the status polls' timeout
and exited on first failure, so a shell 300ms slow meant suspending unlocked;
it now has its own budget and is simply retried, since asking again is
idempotent. And a refusal the shell reports on stdout with a zero exit --
missing-pam -- read as success, burning the whole window on a lock that could
never happen.

Every call is bounded by what is left of the budget rather than by an estimate
of what the step should cost, so the deadline holds on hardware slower than
anything the constants were fitted to.

Failure is still possible and it used to be silent. It now writes to the
journal and raises a critical notification, which lands on the screen the user
unlocks into.

Incidentally, monitor-recovery-test asserted lid probing against
omarchy-hw-clamshell after that logic moved to omarchy-hw-laptop-closed. It
aborted the file under set -e, skipping the nine assertions behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:35:17 -07:00

94 lines
2.9 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
lid_close="$ROOT/bin/omarchy-system-lid-close"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# closed/docked are the two facts logind uses to decide whether a lid close
# suspends, so each scenario pins them and records what the lid handler did.
setup_scenario() {
scenario_dir="$tmpdir/$1"
mock_bin="$scenario_dir/bin"
call_log="$scenario_dir/calls"
mkdir -p "$mock_bin"
: >"$call_log"
local closed="$2" docked="$3"
cat >"$mock_bin/omarchy-hw-laptop-closed" <<SH
#!/bin/bash
exit $closed
SH
cat >"$mock_bin/omarchy-hw-external-monitors" <<SH
#!/bin/bash
exit $docked
SH
for command in omarchy-system-lock omarchy-hyprland-monitor-clamshell; do
cat >"$mock_bin/$command" <<SH
#!/bin/bash
echo $command >>"\$CALL_LOG"
SH
done
chmod +x "$mock_bin"/*
}
run_lid_close() {
CALL_LOG="$call_log" PATH="$mock_bin:$PATH" "$lid_close"
mapfile -t calls <"$call_log"
}
# An undocked lid close is about to suspend, and logind's inhibitor window is a
# timer rather than a promise, so the lock has to start now instead of waiting
# for PrepareForSleep.
setup_scenario undocked 0 1
run_lid_close
[[ ${calls[0]} == "omarchy-system-lock" ]] ||
fail "undocked lid close locks before anything else" "calls: ${calls[*]}"
pass "undocked lid close locks before anything else"
[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] ||
fail "undocked lid close still reconciles displays" "calls: ${calls[*]}"
pass "undocked lid close still reconciles displays"
# A docked lid close is clamshell mode: logind leaves the machine awake and the
# session stays in use on the external display, so locking it would be wrong.
setup_scenario docked 0 0
run_lid_close
[[ ${calls[*]} != *omarchy-system-lock* ]] ||
fail "docked lid close does not lock the session" "calls: ${calls[*]}"
pass "docked lid close does not lock the session"
[[ ${calls[0]} == "omarchy-hyprland-monitor-clamshell" ]] ||
fail "docked lid close reconciles displays" "calls: ${calls[*]}"
pass "docked lid close reconciles displays"
# Hyprland can replay a switch binding when the lid is already open, and an
# open lid must never lock the machine the user is sitting at.
setup_scenario open 1 1
run_lid_close
[[ ${calls[*]} != *omarchy-system-lock* ]] ||
fail "an open lid never locks the session" "calls: ${calls[*]}"
pass "an open lid never locks the session"
# The lid handler runs from a Hyprland binding, so a lock that hangs or fails
# must not stop the display reconciliation behind it.
setup_scenario failing_lock 0 1
cat >"$mock_bin/omarchy-system-lock" <<'SH'
#!/bin/bash
echo omarchy-system-lock >>"$CALL_LOG"
exit 1
SH
chmod +x "$mock_bin/omarchy-system-lock"
run_lid_close
[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] ||
fail "a failing lock still reconciles displays" "calls: ${calls[*]}"
pass "a failing lock still reconciles displays"