diff --git a/bin/omarchy-hyprland-session-lock-stranded b/bin/omarchy-hyprland-session-lock-stranded new file mode 100755 index 00000000..05b8a56c --- /dev/null +++ b/bin/omarchy-hyprland-session-lock-stranded @@ -0,0 +1,19 @@ +#!/bin/bash + +# omarchy:summary=Returns true when the compositor holds a session lock no other locker is alive to own +# omarchy:args= +# omarchy:hidden=true + +# Hyprland lets any client take over a held lock (misc:allow_session_lock_restore), +# so a shell that recovers a lock it merely sees can take the live lock screen of +# another shell, and strand it when it exits. A lock is stranded only while no +# other Quickshell or hyprlock is running; until then the answer is undetermined, +# so the caller keeps asking until that locker exits or it gives up. +# +# Exits 0 stranded, 1 unlocked, 2 undetermined. The caller passes its own pid, +# as a crash re-execs the shell in place. +omarchy-hyprland-session-locked || exit + +if pgrep -u "$UID" -x 'quickshell|qs|hyprlock' | grep -qvxF "$1"; then + exit 2 +fi diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index c775ba02..ccd496a8 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -110,8 +110,11 @@ Item { } // ext-session-lock outlives its client, and a restart carries no lock over, so - // a session locked this early is an orphan behind Hyprland's failsafe. Outputs - // are often still absent here, so ask until the answer means something. + // a session locked this early may be an orphan behind Hyprland's failsafe. It + // may also be another shell's live lock, which Hyprland would let this one + // take over and then strand on exit, so the helper answers undetermined while + // another locker runs. Outputs are often still absent here, so ask until the + // answer means something. function checkStrandedLock() { if (strandedLockResolved || strandedLockCheckProc.running) return @@ -687,7 +690,8 @@ Item { Process { id: strandedLockCheckProc - command: ["bash", "-c", "omarchy-hyprland-session-locked"] + // $PPID is this shell: a crash re-execs in place, so its pid is the one to skip. + command: ["bash", "-c", "omarchy-hyprland-session-lock-stranded \"$PPID\""] onExited: function(exitCode) { // No output to read the lock off yet. if (exitCode === 2) return diff --git a/test/shell.d/hyprland-session-lock-stranded-test.sh b/test/shell.d/hyprland-session-lock-stranded-test.sh new file mode 100755 index 00000000..ba1db1f1 --- /dev/null +++ b/test/shell.d/hyprland-session-lock-stranded-test.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +fake_bin=$(mktemp -d) +trap 'rm -rf "$fake_bin"' EXIT + +cat >"$fake_bin/omarchy-hyprland-session-locked" <<'SH' +#!/bin/bash + +exit "$OMARCHY_TEST_LOCKED" +SH + +# The lockers running are given, so the live desktop's own shell stays out of it. +cat >"$fake_bin/pgrep" <<'SH' +#!/bin/bash + +[[ $* == "-u $UID -x quickshell|qs|hyprlock" ]] || exit 3 +[[ -n $OMARCHY_TEST_LOCKERS ]] || exit 1 +printf '%s\n' $OMARCHY_TEST_LOCKERS +SH +chmod +x "$fake_bin"/* + +assert_status() { + local expected="$1" locked="$2" lockers="$3" description="$4" actual=0 + + PATH="$fake_bin:$PATH" OMARCHY_TEST_LOCKED="$locked" OMARCHY_TEST_LOCKERS="$lockers" \ + "$ROOT/bin/omarchy-hyprland-session-lock-stranded" 100 || actual=$? + + (( actual == expected )) || fail "$description" "expected exit $expected, got $actual" + pass "$description" +} + +assert_status 0 0 "" "a lock with no locker alive is stranded" +assert_status 0 0 "100" "the asking shell does not count as another locker" +assert_status 1 1 "" "an unlocked session is not stranded" +assert_status 2 2 "" "a compositor that cannot say leaves it undetermined" +assert_status 2 0 "100 200" "a lock another locker may hold is left undetermined, so it is never taken" diff --git a/test/shell.d/lock-stranded-recovery-test.sh b/test/shell.d/lock-stranded-recovery-test.sh index d0e5cb81..1f27d1cf 100755 --- a/test/shell.d/lock-stranded-recovery-test.sh +++ b/test/shell.d/lock-stranded-recovery-test.sh @@ -15,8 +15,8 @@ assert( ) assert( - /id: strandedLockCheckProc[\s\S]*omarchy-hyprland-session-locked/.test(serviceQml), - 'the startup check goes through the shared session lock helper' + /id: strandedLockCheckProc[\s\S]*omarchy-hyprland-session-lock-stranded \\"\$PPID\\"/.test(serviceQml), + 'the startup check asks whether a lock has no live locker, skipping this shell' ) // "No output to read" taken for "unlocked" leaves the failsafe up for good.