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>
This commit is contained in:
David Heinemeier Hansson
2026-07-25 12:35:17 -07:00
co-authored by Claude Opus 5
parent f9bd0fd9d5
commit 9ddcec272d
8 changed files with 596 additions and 27 deletions
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# omarchy:summary=Lock and reconcile displays when the laptop lid closes
# omarchy:group=system
# omarchy:hidden=true
# Locking here rather than waiting for PrepareForSleep is what keeps the lock
# off the critical path. logind's delay inhibitor is a timer that expires
# whether or not the session is secure, so starting the lock the moment the lid
# closes gives Quickshell a head start before logind even decides to suspend.
# omarchy-system-sleep-lock then usually finds the session already secure.
#
# A docked lid close does not suspend (HandleLidSwitchDocked defaults to
# ignore), so it must not lock either: that is clamshell mode, still in use on
# the external display.
if omarchy-hw-laptop-closed && ! omarchy-hw-external-monitors; then
omarchy-system-lock >/dev/null 2>&1 || true
fi
omarchy-hyprland-monitor-clamshell
+108 -16
View File
@@ -4,29 +4,121 @@
# omarchy:group=system
# omarchy:hidden=true
wait_attempts=${1:-100}
# Overrunning the budget is the failure this whole path exists to prevent:
# logind stops honouring the inhibitor and suspends mid-lock. Every call below
# is bounded by what is left of the budget, so the deadline enforces itself
# rather than depending on an estimate of how long a step ought to take.
budget_cap_ms=12000
lock_timeout_ms=1000
status_timeout_ms=500
poll_interval=0.1
if [[ ! $wait_attempts =~ ^[0-9]+$ ]] || (( wait_attempts < 1 )); then
wait_attempts=100
# logind decides how long a delay inhibitor may hold the machine, and the
# shipped drop-in only counts once logind has reloaded it, so ask rather than
# assume. Leaving logind a fifth of its own window to deliver PrepareForSleep
# and act on the release gives 4s at the 5s default and 12s at the shipped 15s.
# The cap keeps a hand-raised window from stranding a closed laptop in a bag.
derive_budget_ms() {
local window
window=$(timeout --kill-after=0.1s 1s busctl get-property \
org.freedesktop.login1 /org/freedesktop/login1 \
org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null)
window=${window##* }
# An unreadable window means we cannot know, so assume logind's own default.
[[ $window =~ ^[0-9]+$ ]] && (( window > 0 )) || window=5000000
window=$((window / 1000))
# Never leave logind less than a second, however small its window is.
window=$((window - (window / 5 > 1000 ? window / 5 : 1000)))
(( window < budget_cap_ms )) && echo "$window" || echo "$budget_cap_ms"
}
budget_ms=${1:-$(derive_budget_ms)}
if [[ ! $budget_ms =~ ^[0-9]+$ ]] || (( budget_ms < 1 || budget_ms > budget_cap_ms )); then
budget_ms=$(derive_budget_ms)
fi
# EPOCHREALTIME renders with the locale's decimal separator, so drop every
# non-digit rather than assuming a period. A comma would otherwise read as
# bash's comma operator and silently void the deadline.
deadline_ms=$((10#${EPOCHREALTIME//[!0-9]/} / 1000 + budget_ms))
remaining_ms() {
echo $((deadline_ms - 10#${EPOCHREALTIME//[!0-9]/} / 1000))
}
# Clamping to what is left as well as to the call's own limit is what lets the
# loop below stay a plain "while there is time" without predicting step costs.
lock_ipc() {
local limit=$1 remaining seconds
shift
remaining=$(remaining_ms)
(( remaining > 0 )) || return 1
(( limit < remaining )) || limit=$remaining
printf -v seconds '%d.%03d' $((limit / 1000)) $((limit % 1000))
OMARCHY_SHELL_IPC_TIMEOUT="$seconds" \
timeout --kill-after=0.1s "$seconds" omarchy-shell "$@"
}
# The shell answers refusals on stdout with a zero exit, so the reply is the
# only way to spot a lock it can never perform. Nothing else needs inspecting:
# the status poll is what confirms success, and re-requesting is idempotent, so
# a request that may not have landed costs nothing to repeat.
request_lock() {
case $(lock_ipc "$lock_timeout_ms" lock lock 2>/dev/null) in
missing-pam) report_unsecured "no lock screen is configured" ;;
esac
}
# secure: done. locking: the shell has the request and is working on it, so
# leave it alone. Anything else, unreadable replies included, means ask again.
lock_state() {
jq -r 'if .secure == true then "secure"
elif .requested == true then "locking"
else "idle" end' \
<<<"$(lock_ipc "$status_timeout_ms" lock status 2>/dev/null)" 2>/dev/null
}
sync_clamshell() {
# Lid transitions can temporarily stall Hyprland IPC. This is best-effort:
# the lid binding and monitor watcher also reconcile clamshell state.
timeout --kill-after=0.1s 0.4s \
omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true
}
sync_clamshell
omarchy-shell lock lock >/dev/null 2>&1 || exit 1
# logind suspends whether or not this wait succeeded, so a failure here means
# the machine slept with the session exposed. The notification is the only way
# anyone finds out, and it lands on the screen they unlock into.
report_unsecured() {
printf 'omarchy-system-sleep-lock: suspending without a secure lock (%s)\n' \
"$1" >&2
for (( attempt = 0; attempt < wait_attempts; attempt++ )); do
(( attempt % 5 == 0 )) && sync_clamshell
status=$(omarchy-shell lock status 2>/dev/null || true)
if jq -e '.secure == true' <<<"$status" >/dev/null 2>&1; then
exit 0
fi
sleep 0.05
done
omarchy-notification-send -u critical -g 󰌾 \
"Screen did not lock before suspend" \
"The session was left unlocked ($1)." >/dev/null 2>&1 || true
exit 1
}
# Request the lock before touching monitor state, so a stuck Hyprland IPC call
# cannot consume the window before Quickshell has begun securing the session.
request_lock
sync_clamshell
# The trailing sleep can overshoot the deadline by one interval, which is well
# inside the reserve derive_budget_ms already held back for logind.
while (( $(remaining_ms) > 0 )); do
case $(lock_state) in
secure) exit 0 ;;
locking) ;;
*) request_lock ;;
esac
sleep "$poll_interval"
done
report_unsecured "the shell did not secure the session within ${budget_ms}ms"
+1 -1
View File
@@ -29,7 +29,7 @@ o.bind_toggle("SUPER + CTRL + I", "Toggle locking on idle", "idle")
o.bind_toggle("SUPER + CTRL + N", "Toggle nightlight", "nightlight")
o.bind("SUPER + CTRL + Delete", "Toggle laptop display", "omarchy-hyprland-monitor-internal toggle")
o.bind("SUPER + CTRL + ALT + Delete", "Toggle laptop display mirroring", "omarchy-hyprland-monitor-internal-mirror toggle")
o.bind("switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true })
o.bind("switch:on:Lid Switch", nil, "omarchy-system-lid-close", { locked = true })
o.bind("switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true })
o.bind("PRINT", "Screenshot", "omarchy-capture-screenshot")
@@ -0,0 +1,10 @@
# omarchy-sleep-lock.service holds a delay inhibitor so the session is locked
# before the machine suspends. A delay inhibitor is a timer, not a promise:
# logind suspends anyway once the window expires, locked or not. Five seconds
# is not enough when closing the lid also reconfigures displays, because
# Quickshell waits for the screen set to settle before it can secure.
#
# This only costs anything when locking is broken. A healthy lock releases the
# inhibitor the moment the session reports secure, well under a second.
[Login]
InhibitDelayMaxSec=15
+22
View File
@@ -0,0 +1,22 @@
echo "Give the pre-suspend lock a window it can actually finish in"
# logind's five second default expires while Quickshell is still securing the
# session on lid close, and it suspends regardless. The shipped drop-in raises
# InhibitDelayMaxSec, but logind only reads it on reload.
#
# Reload rather than restart: restarting systemd-logind tears down the session.
sudo systemctl reload systemd-logind >/dev/null 2>&1 || true
# Check the property logind actually enforces, not the reload's exit status: a
# reload that returns success while the drop-in is missing or unparsed leaves
# the old five second window in place. omarchy-system-sleep-lock reads this same
# property at runtime, so it stays correct either way -- the reboot flag is only
# about getting the wider window to take effect.
dropin=/etc/systemd/logind.conf.d/20-inhibit-delay.conf
expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null)
effective_us=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}')
if [[ -z $expected_s || $effective_us != $((expected_s * 1000000)) ]]; then
omarchy-state set reboot-required
fi
+93
View File
@@ -0,0 +1,93 @@
#!/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"
+5 -9
View File
@@ -9,11 +9,11 @@ monitor_internal="$ROOT/bin/omarchy-hyprland-monitor-internal"
monitor_mirror="$ROOT/bin/omarchy-hyprland-monitor-internal-mirror"
monitor_laptop="$ROOT/bin/omarchy-hyprland-monitor-laptop"
monitor_external_active="$ROOT/bin/omarchy-hyprland-monitor-external-active"
sleep_lock="$ROOT/bin/omarchy-system-sleep-lock"
system_wake="$ROOT/bin/omarchy-system-wake"
clamshell="$ROOT/bin/omarchy-hyprland-monitor-clamshell"
lock_service="$ROOT/shell/plugins/lock/Service.qml"
hw_clamshell="$ROOT/bin/omarchy-hw-clamshell"
hw_laptop_closed="$ROOT/bin/omarchy-hw-laptop-closed"
utilities="$ROOT/default/hypr/bindings/utilities.lua"
grep -F 'sleep "$delay"' "$monitor_watch" >/dev/null
@@ -36,8 +36,8 @@ grep -F 'sync_poll_state' "$monitor_watch" >/dev/null
grep -F 'done < <(socat' "$monitor_watch" >/dev/null
pass "clamshell poll only runs on a docked laptop, not desktops or undocked laptops"
grep -F '/proc/acpi/button/lid/*/state' "$hw_clamshell" >/dev/null
grep -F 'omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null
grep -F 'omarchy-hw-laptop-closed && omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null
grep -F '/proc/acpi/button/lid/*/state' "$hw_laptop_closed" >/dev/null
pass "clamshell helper detects closed-lid external monitor state"
grep -F 'hyprctl monitors -j' "$monitor_external_active" >/dev/null
@@ -72,13 +72,9 @@ pass "internal monitor recovery only wakes displays when it re-enables one"
grep -F 'omarchy-hyprland-monitor-external-active' "$monitor_mirror" >/dev/null
pass "internal mirror helper recovers when no active external display remains"
grep -F 'switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null
grep -F 'switch:on:Lid Switch", nil, "omarchy-system-lid-close"' "$utilities" >/dev/null
grep -F 'switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null
pass "lid switch bindings reconcile clamshell display state"
grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$sleep_lock" >/dev/null
grep -F '(( attempt % 5 == 0 )) && sync_clamshell' "$sleep_lock" >/dev/null
pass "sleep lock syncs clamshell display state while waiting for secure lock"
pass "lid switch bindings lock on close and reconcile clamshell display state"
grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$system_wake" >/dev/null
pass "system wake resyncs clamshell display state"
+336
View File
@@ -0,0 +1,336 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
sleep_lock="$ROOT/bin/omarchy-system-sleep-lock"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Each scenario gets its own mock PATH and call log, then runs the sleep lock
# with a short budget so a stalled shell cannot slow the suite down.
setup_scenario() {
scenario_dir="$tmpdir/$1"
mock_bin="$scenario_dir/bin"
call_log="$scenario_dir/calls"
state_dir="$scenario_dir/state"
notify_log="$scenario_dir/notifications"
journal_log="$scenario_dir/journal"
mkdir -p "$mock_bin" "$state_dir"
: >"$notify_log"
: >"$journal_log"
# The budget is derived from logind, so pin the window rather than letting the
# host's own configuration decide what these scenarios are testing.
mock_logind_window 5000000
# Capture the desktop warning instead of firing a real one at whoever is
# running the suite.
cat >"$mock_bin/omarchy-notification-send" <<SH
#!/bin/bash
printf '%s\n' "\$*" >>"$notify_log"
SH
chmod +x "$mock_bin/omarchy-notification-send"
}
mock_logind_window() {
cat >"$mock_bin/busctl" <<SH
#!/bin/bash
printf 't %s\n' $1
SH
chmod +x "$mock_bin/busctl"
}
mock_clamshell() {
cat >"$mock_bin/omarchy-hyprland-monitor-clamshell" <<SH
#!/bin/bash
echo clamshell >>"\$CALL_LOG"
sleep ${1:-0}
SH
chmod +x "$mock_bin/omarchy-hyprland-monitor-clamshell"
}
# Called with no budget to exercise the value derived from logind's window.
run_sleep_lock() {
local args=()
[[ -n ${1:-} ]] && args=("$1")
start_us=${EPOCHREALTIME//[!0-9]/}
set +e
CALL_LOG="$call_log" STATE_DIR="$state_dir" PATH="$mock_bin:$PATH" \
"$sleep_lock" "${args[@]}" 2>"$journal_log"
exit_status=$?
set -e
elapsed_us=$((10#${EPOCHREALTIME//[!0-9]/} - 10#$start_us))
mapfile -t calls <"$call_log"
}
# A responsive shell locks immediately, even when the clamshell sync stalls.
setup_scenario responsive
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'ok\n'
elif [[ $* == "lock status" ]]; then
printf '{"secure":true}\n'
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell 2
run_sleep_lock 4000
(( exit_status == 0 )) ||
fail "sleep lock succeeds once the session reports secure" "exit: $exit_status"
pass "sleep lock succeeds once the session reports secure"
[[ ${calls[0]} == "shell lock lock" ]] ||
fail "sleep lock requests the session lock first" "first call: ${calls[0]}"
pass "sleep lock requests the session lock first"
[[ ${calls[1]} == "clamshell" && ${calls[2]} == "shell lock status" ]] ||
fail "sleep lock checks security after clamshell reconciliation"
pass "sleep lock checks security after clamshell reconciliation"
(( elapsed_us < 1500000 )) ||
fail "sleep lock bounds a stalled clamshell sync" "elapsed: ${elapsed_us}us"
pass "sleep lock bounds a stalled clamshell sync"
# A shell that never secures the session must give up inside the budget rather
# than hold logind's delay inhibitor open.
setup_scenario never_secure
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'ok\n'
elif [[ $* == "lock status" ]]; then
printf '{"secure":false}\n'
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 1500
(( exit_status != 0 )) ||
fail "sleep lock reports failure when the session never secures"
pass "sleep lock reports failure when the session never secures"
# The contract is the budget plus at most one poll interval, since the pause
# between polls is not itself clipped. Derived budgets hold back a full second
# for logind, so that overshoot is always well inside the reserve.
(( elapsed_us <= 1600000 )) ||
fail "sleep lock gives up within its budget" "elapsed: ${elapsed_us}us"
pass "sleep lock gives up within its budget"
polls=0
for call in "${calls[@]}"; do
[[ $call == "shell lock status" ]] && (( ++polls ))
done
(( polls > 1 )) ||
fail "sleep lock keeps polling until the deadline" "polls: $polls"
pass "sleep lock keeps polling until the deadline"
# A lock request that times out may never have landed, so the wait retries it
# instead of suspending an unlocked session over one slow IPC call.
setup_scenario retry_lock
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
if [[ -f $STATE_DIR/requested ]]; then
touch "$STATE_DIR/locked"
printf 'ok\n'
exit 0
fi
touch "$STATE_DIR/requested"
exit 1
fi
if [[ $* == "lock status" ]]; then
if [[ -f $STATE_DIR/locked ]]; then
printf '{"secure":true}\n'
else
printf '{"secure":false}\n'
fi
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 4000
(( exit_status == 0 )) ||
fail "sleep lock retries a failed lock request" "exit: $exit_status"
pass "sleep lock retries a failed lock request"
requests=0
for call in "${calls[@]}"; do
[[ $call == "shell lock lock" ]] && (( ++requests ))
done
(( requests == 2 )) ||
fail "sleep lock stops requesting once the lock lands" "requests: $requests"
pass "sleep lock stops requesting once the lock lands"
# A request can land even when its IPC response times out. Pending status proves
# that Quickshell is already securing the session, so do not spend the remaining
# inhibitor budget sending the same request again.
setup_scenario pending_lock
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
exit 1
fi
if [[ $* == "lock status" ]]; then
if [[ -f $STATE_DIR/pending_seen ]]; then
printf '{"secure":true}\n'
else
touch "$STATE_DIR/pending_seen"
printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n'
fi
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 4000
(( exit_status == 0 )) ||
fail "sleep lock succeeds after observing a pending lock" "exit: $exit_status"
pass "sleep lock succeeds after observing a pending lock"
requests=0
for call in "${calls[@]}"; do
[[ $call == "shell lock lock" ]] && (( ++requests ))
done
(( requests == 1 )) ||
fail "sleep lock does not retry an observed pending lock" "requests: $requests"
pass "sleep lock does not retry an observed pending lock"
# The shell reports a refusal on stdout with a zero exit, so a lock it can never
# perform has to end the wait instead of burning the rest of the window on it.
setup_scenario missing_pam
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'missing-pam\n'
fi
exit 0
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 4000
(( exit_status != 0 )) ||
fail "sleep lock fails fast when the shell cannot lock at all"
(( elapsed_us < 500000 )) ||
fail "sleep lock fails fast when the shell cannot lock at all" "elapsed: ${elapsed_us}us"
pass "sleep lock fails fast when the shell cannot lock at all"
[[ ${calls[*]} != *"lock status"* ]] ||
fail "sleep lock stops polling a shell that refused to lock" "calls: ${calls[*]}"
pass "sleep lock stops polling a shell that refused to lock"
# logind suspends regardless of this exit status, so an unlocked suspend is
# otherwise invisible. The warning is the only trace the user ever sees, and the
# journal line is what makes it diagnosable after the fact.
grep -qF "did not lock before suspend" "$notify_log" ||
fail "sleep lock warns that the session was left unlocked" \
"notifications: $(< "$notify_log")"
pass "sleep lock warns that the session was left unlocked"
grep -qF "suspending without a secure lock" "$journal_log" ||
fail "sleep lock records the unlocked suspend in the journal" \
"journal: $(< "$journal_log")"
pass "sleep lock records the unlocked suspend in the journal"
# A never-securing shell is the scenario that runs out the whole budget, so it
# is also the one that shows which budget was derived.
never_secures() {
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'ok\n'
elif [[ $* == "lock status" ]]; then
printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n'
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
}
# The drop-in only counts once logind has reloaded it, and a machine can carry
# its own override, so the budget follows whatever logind actually enforces.
setup_scenario derived_short_window
mock_logind_window 2000000
never_secures
run_sleep_lock
(( elapsed_us <= 1300000 )) ||
fail "sleep lock derives its budget from logind's window" "elapsed: ${elapsed_us}us"
pass "sleep lock derives its budget from logind's window"
# Without a readable window there is no way to know what logind will tolerate,
# so fall back to the budget that was safe before the drop-in existed.
setup_scenario unreadable_window
cat >"$mock_bin/busctl" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$mock_bin/busctl"
never_secures
run_sleep_lock
(( elapsed_us > 1300000 && elapsed_us <= 4300000 )) ||
fail "sleep lock falls back to a conservative budget" "elapsed: ${elapsed_us}us"
pass "sleep lock falls back to a conservative budget when logind cannot be read"
# A hand-raised window must not strand a closed laptop awake in a bag. This
# scenario runs for the whole capped budget by design.
setup_scenario capped_window
mock_logind_window 600000000
never_secures
run_sleep_lock
(( exit_status != 0 )) ||
fail "sleep lock caps the budget a huge logind window would allow"
(( elapsed_us <= 12500000 )) ||
fail "sleep lock caps the budget a huge logind window would allow" \
"elapsed: ${elapsed_us}us"
pass "sleep lock caps the budget a huge logind window would allow"
# The cap is only reachable because the shipped drop-in widens logind's window
# past it. Ship one without the other and the cap is dead weight.
inhibit_delay=$(sed -n 's/^InhibitDelayMaxSec=//p' "$ROOT/etc/systemd/logind.conf.d/20-inhibit-delay.conf")
budget_cap_ms=$(sed -n 's/^budget_cap_ms=//p' "$sleep_lock")
[[ -n $inhibit_delay && -n $budget_cap_ms ]] ||
fail "sleep lock cap and logind window are both declared" \
"window: ${inhibit_delay:-unset} cap: ${budget_cap_ms:-unset}"
(( budget_cap_ms < inhibit_delay * 1000 )) ||
fail "sleep lock cap leaves logind room to act" \
"cap: ${budget_cap_ms}ms window: ${inhibit_delay}s"
pass "sleep lock cap stays inside the shipped logind inhibitor window"