diff --git a/bin/omarchy-debug-idle b/bin/omarchy-debug-idle index 308df73f..73db24bd 100755 --- a/bin/omarchy-debug-idle +++ b/bin/omarchy-debug-idle @@ -27,6 +27,9 @@ section "Recent idle logs" quickshell --no-color log -p "$OMARCHY_PATH/shell" --any-display --tail "$lines" --log-times -r 'quickshell.wayland.idle_notify=true' 2>&1 \ | grep -Ei 'omarchy idle|idle_notify|screensaver|lock|error|warn|failed' || true +section "Persisted shell log" +journalctl -t omarchy-shell -n "$lines" --no-pager --quiet 2>/dev/null || true + section "Relevant processes" ps -eo pid=,args= \ | grep -E 'quickshell -n -p|omarchy-system-sleep-monitor|systemd-inhibit.*Lock screen before suspend|org\.omarchy\.screensaver|omarchy-screensaver|(^|/| )tte( |$)' \ diff --git a/bin/omarchy-launch-shell b/bin/omarchy-launch-shell new file mode 100755 index 00000000..3a6fb74a --- /dev/null +++ b/bin/omarchy-launch-shell @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Launch the Omarchy shell with its log kept in the journal +# omarchy:hidden=true + +# Quickshell only logs to its instance runtime dir (tmpfs), so when the shell +# dies the idle/lock event trail is gone after a reboot. The journal keeps it +# across sessions, bounded and timestamped, under the omarchy-shell tag. +exec systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index 7847aac9..09b20d23 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -18,11 +18,49 @@ if [[ -z ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then [[ -n $hypr_dir ]] && export HYPRLAND_INSTANCE_SIGNATURE=${hypr_dir##*/} fi +# Restarting a live lock client would kill the lock screen and strand the +# session behind Hyprland's failsafe. But a LOCK session without an active +# locker — the shell died, or its crash handler re-execed a fresh instance +# that holds no lock — sits in that failsafe with no way to authenticate, +# and a restart plus re-lock is the only way back in without a reboot. So +# ask the lock service rather than merely pinging the shell: only a locker +# that reports the lock secure or in progress is worth preserving. +relock=0 if [[ $(hyprctl -j monitors 2>/dev/null) == *'"LOCK"'* ]]; then - echo "Refusing to restart Omarchy shell while the session is locked." >&2 - exit 1 + locking=$(OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell lock status 2>/dev/null | + jq -r '.secure or .requested' 2>/dev/null) + if [[ $locking == "true" ]]; then + echo "Refusing to restart Omarchy shell while the session is locked." >&2 + exit 1 + fi + relock=1 fi +# The lock plugin loads asynchronously, so a fresh shell answers ping before +# it can lock, and may even refuse early lock requests while its plugins or +# PAM config are still loading. Mirror omarchy-system-sleep-lock: request the +# lock and poll until the session reports secure, re-requesting as needed, so +# recovery never claims success while the failsafe is still up. The deadline +# is generous because slow plugin discovery delays the lock IPC target. +relock_session() { + local state deadline=$((SECONDS + 30)) + + while (( SECONDS < deadline )); do + state=$(OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell lock status 2>/dev/null | + jq -r 'if .secure == true then "secure" elif .requested == true then "locking" else "idle" end' 2>/dev/null) + + case $state in + secure) return 0 ;; + locking) ;; + *) OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell lock lock >/dev/null 2>&1 ;; + esac + + sleep 0.1 + done + + return 1 +} + # Each kill stops the oldest matching instance and only returns once it has # fully exited, so the no-duplicate launch below can't race a dying shell. # Requires our quickshell-git build; 0.3.0's kill returns immediately. @@ -30,10 +68,16 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; # Spawn from Hyprland so the shell inherits the canonical session environment, # not transient variables from a terminal, SSH connection, or development tool. -hyprctl dispatch 'hl.dsp.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell")' >/dev/null +hyprctl dispatch 'hl.dsp.exec_cmd("omarchy-launch-shell")' >/dev/null for (( attempt = 0; attempt < 20; attempt++ )); do if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then + # The session stays compositor-locked after the old lock client died, so + # re-acquire the lock and let the user authenticate out of it. + if (( relock )) && ! relock_session; then + echo "Omarchy shell restarted, but the session lock was not re-secured." >&2 + exit 1 + fi # Invitation toasts (like Voxtype/fingerprint setup) die with the old # shell, and their notify-send waiters hang forever: the dying server # never emits NotificationClosed. A still-running omarchy-*-invitation diff --git a/default/hypr/autostart.lua b/default/hypr/autostart.lua index 7d180a46..079ca18e 100644 --- a/default/hypr/autostart.lua +++ b/default/hypr/autostart.lua @@ -3,7 +3,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("systemctl --user import-environment $(env | cut -d'=' -f 1)") hl.exec_cmd("dbus-update-activation-environment --systemd --all") - hl.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell") + hl.exec_cmd("omarchy-launch-shell") hl.exec_cmd("omarchy-first-run") hl.exec_cmd("omarchy-powerprofiles-init") hl.exec_cmd(o.launch("omarchy-hyprland-monitor-watch")) diff --git a/default/hypr/looknfeel.lua b/default/hypr/looknfeel.lua index 396eb48c..345f9105 100644 --- a/default/hypr/looknfeel.lua +++ b/default/hypr/looknfeel.lua @@ -110,6 +110,9 @@ hl.config({ anr_missed_pings = 3, on_focus_under_fullscreen = 1, initial_workspace_tracking = 0, + -- Let a fresh shell re-acquire the session lock after the lock client + -- died, so omarchy-restart-shell can recover the LOCK failsafe. + allow_session_lock_restore = true, }, cursor = { diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index f7d579ff..6ca74744 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -61,6 +61,7 @@ runtime_dir="$test_tmp/runtime" mkdir -p "$restart_root/shell" "$restart_bin" "$runtime_dir" touch "$restart_root/shell/shell.qml" ln -s "$ROOT/bin/omarchy-shell" "$restart_bin/omarchy-shell" +ln -s "$ROOT/bin/omarchy-launch-shell" "$restart_bin/omarchy-launch-shell" ln -s "$ROOT/bin/omarchy-cmd-missing" "$restart_bin/omarchy-cmd-missing" cat >"$restart_bin/qs" <<'SH' @@ -74,6 +75,17 @@ case "$*" in grep -Fx '303' "$OMARCHY_TEST_QS_STATE" >/dev/null && printf 'ok\n' ;; + *'lock lock') + touch "$OMARCHY_TEST_QS_STATE.locked" + printf 'ok\n' + ;; + *'lock status') + if [[ -f $OMARCHY_TEST_QS_STATE.locked ]]; then + printf '{"secure": true, "requested": true}\n' + else + printf '{"secure": false, "requested": false}\n' + fi + ;; esac SH @@ -110,13 +122,24 @@ if [[ ${1:-} == "-j" && ${2:-} == "monitors" ]]; then elif [[ ${1:-} == "dispatch" && ${2:-} == hl.dsp.exec_cmd* ]]; then printf '%s\n' "${2:-}" >>"$OMARCHY_TEST_DISPATCH_LOG" OMARCHY_PATH="$OMARCHY_TEST_SESSION_PATH" \ - env -u OMARCHY_TEST_TRANSIENT_ENV quickshell -n -p "$OMARCHY_TEST_SESSION_PATH/shell" + env -u OMARCHY_TEST_TRANSIENT_ENV omarchy-launch-shell printf 'ok\n' elif [[ ${1:-} == "dispatch" ]]; then exit 1 fi SH +# Keep the test hermetic where journald has no usable stream socket. +cat >"$restart_bin/systemd-cat" <<'SH' +#!/bin/bash + +while (( $# > 0 )); do + [[ $1 == "--" ]] && { shift; break; } + shift +done +exec "$@" +SH + cat >"$restart_bin/systemctl" <<'SH' #!/bin/bash @@ -129,7 +152,7 @@ else fi SH -chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemctl" +chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" sleep 30 & restart_pid_one=$! @@ -167,12 +190,13 @@ restart_pid_two="" [[ $(grep -c '^-n -p ' "$restart_log") == 1 ]] || fail "restart launches one fresh shell process" grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null || fail "restart stops the shell from the session checkout" [[ $(<"$restart_env_log") == "unset" ]] || fail "restart uses the Hyprland session environment for the fresh shell" -grep -F 'hl.dsp.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland" +grep -F 'hl.dsp.exec_cmd("omarchy-launch-shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland" grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout" pass "restart replaces duplicate shell instances from the session checkout" : >"$restart_log" -printf '404\n' >"$restart_state" +printf '303\n' >"$restart_state" +touch "$restart_state.locked" locked_error=$(PATH="$restart_bin:$PATH" \ OMARCHY_PATH="$restart_root" \ @@ -186,6 +210,38 @@ locked_error=$(PATH="$restart_bin:$PATH" \ "$ROOT/bin/omarchy-restart-shell" 2>&1) && fail "restart refuses while the shell lock is active" [[ $locked_error == "Refusing to restart Omarchy shell while the session is locked." ]] || fail "locked restart explains why it was refused" "$locked_error" -[[ $(<"$restart_state") == 404 ]] || fail "locked restart preserves the running shell" +[[ $(<"$restart_state") == 303 ]] || fail "locked restart preserves the running shell" [[ ! -s $restart_log ]] || fail "locked restart does not stop or launch Quickshell" pass "restart preserves the shell while its lock is active" + +# A LOCK session without an active locker — dead shell or a crash-handler +# relaunch holding no lock — is the failsafe: restart must proceed, +# re-acquire the session lock, and wait for it to report secure. +sleep 30 & +restart_pid_one=$! +printf '%s\n' "$restart_pid_one" >"$restart_state" +rm -f "$restart_state.locked" +: >"$restart_log" +: >"$ipc_log" + +PATH="$restart_bin:$PATH" \ +OMARCHY_PATH="$restart_root" \ +XDG_RUNTIME_DIR="$runtime_dir" \ +OMARCHY_TEST_SESSION_LOCKED=1 \ +OMARCHY_TEST_QS_STATE="$restart_state" \ +OMARCHY_TEST_QS_LOG="$restart_log" \ +OMARCHY_TEST_QS_ENV_LOG="$restart_env_log" \ +OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \ +OMARCHY_TEST_IPC_LOG="$ipc_log" \ +OMARCHY_TEST_SESSION_PATH="$restart_root" \ + timeout 5 "$ROOT/bin/omarchy-restart-shell" || fail "locked restart recovers when the lock client is dead" + +if kill -0 "$restart_pid_one" 2>/dev/null; then + fail "dead-lock recovery stops the stale shell instance" +fi +wait "$restart_pid_one" 2>/dev/null || true +restart_pid_one="" +[[ $(<"$restart_state") == 303 ]] || fail "dead-lock recovery leaves one fresh shell instance" +grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "dead-lock recovery re-acquires the session lock" +grep -F "ipc -n -p $restart_root/shell call -- lock status" "$ipc_log" >/dev/null || fail "dead-lock recovery waits for the lock to become secure" +pass "restart recovers a locked session whose lock client died"