From 754d2d04b3828ae5ccfff4c4ab9f0c3b5c14dc7b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 11 Oct 2026 14:26:49 +0200 Subject: [PATCH] Only recover a session lock no other locker may hold (#15020) Hyprland lets any client take over a held lock, so a second shell started beside the desktop judged the real shell's live lock stranded, took it, and then exited, leaving the session behind the failsafe while the real shell still reported itself secure. Recovery now waits while any other Quickshell or hyprlock runs, and gives up if one never exits. Co-authored-by: Claude Opus 5.5 --- bin/omarchy-hyprland-session-lock-stranded | 19 +++++++++ shell/plugins/lock/Service.qml | 10 +++-- .../hyprland-session-lock-stranded-test.sh | 40 +++++++++++++++++++ test/shell.d/lock-stranded-recovery-test.sh | 4 +- 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100755 bin/omarchy-hyprland-session-lock-stranded create mode 100755 test/shell.d/hyprland-session-lock-stranded-test.sh 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.