diff --git a/bin/omarchy-hyprland-monitor-modeless b/bin/omarchy-hyprland-monitor-modeless new file mode 100755 index 00000000..cb55e3b8 --- /dev/null +++ b/bin/omarchy-hyprland-monitor-modeless @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Returns true when Hyprland has an enabled monitor with no mode +# omarchy:hidden=true + +# A monitor powered off at boot answers with a partial EDID carrying no video +# modes, so Hyprland brings it up at 0x0 and the screen stays black. Mirrors are +# absent from plain `monitors`, hence `all` plus an explicit disabled filter. +# +# Exits 0 modeless, 1 not, 2 when the compositor cannot say. Nothing fires an +# event for this state, so a caller that gave up on an unanswered query would +# leave the screen black for good. +monitors=$(hyprctl monitors all -j 2>/dev/null) || exit 2 + +state=$(jq 'if any(.[]; .disabled != true and (.width == 0 or .height == 0)) then 0 else 1 end' \ + <<<"$monitors" 2>/dev/null) + +case $state in + 0 | 1) exit "$state" ;; + *) exit 2 ;; +esac diff --git a/bin/omarchy-hyprland-monitor-watch b/bin/omarchy-hyprland-monitor-watch index e362f571..33e8f2c5 100755 --- a/bin/omarchy-hyprland-monitor-watch +++ b/bin/omarchy-hyprland-monitor-watch @@ -4,6 +4,7 @@ SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-monitor-clamshell.lock" +MODELESS_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-monitor-modeless.lock" sync_clamshell() { ( @@ -23,6 +24,42 @@ sync_clamshell_after_monitor_change() { ) & } +# Powering the monitor on fires no DRM hotplug, and forcing a re-probe needs +# root, so only a reload re-reads the EDID and only a reload reveals whether it +# worked. Back off: the machine can sit like this all night. The lock keeps one +# loop running across the events that call this; the wait is for an event landing +# in the moment one is exiting, which would otherwise be the last one to come. +recover_modeless() { + ( + flock -w 1 9 || exit 0 + + local delay=3 state reloaded unanswered=0 + + while true; do + omarchy-hyprland-monitor-modeless + state=$? + + # A monitor reporting a mode is recovered. One the compositor cannot speak + # for is not an answer, and nothing else will ask again -- but a compositor + # that stays silent has gone, taking the session and this loop's reason + # with it. + (( state == 1 )) && break + (( state == 2 )) && (( ++unanswered > 20 )) && break + (( state == 0 )) && unanswered=0 + + reloaded=0 + # Reloading into half-replaced package config is what the guard prevents. + if (( state == 0 )) && ! omarchy-hyprland-reload-guard paused; then + hyprctl reload >/dev/null 2>&1 || true + reloaded=1 + fi + + sleep "$delay" + (( reloaded )) && (( delay = delay * 2 > 60 ? 60 : delay * 2 )) + done + ) 9>"$MODELESS_LOCK" & +} + poll_clamshell_state() { while true; do sleep 2 @@ -51,6 +88,7 @@ sync_poll_state() { sync_clamshell_after_monitor_change sync_poll_state +recover_modeless # Process substitution (not a pipe) keeps this loop in the main shell, so # sync_poll_state can start and stop the background poll as monitors come and go. @@ -59,10 +97,18 @@ while read -r event; do monitoradded\>\>*|monitoraddedv2\>\>*) sync_clamshell_after_monitor_change sync_poll_state + recover_modeless ;; monitorremoved\>\>*|monitorremovedv2\>\>*) sync_clamshell_after_monitor_change sync_poll_state + recover_modeless + ;; + # A reload while the monitor is unpowered leaves it at 0x0 with no hotplug + # to notice, same as at boot. Our own recovery reload lands here too, and is + # a no-op while its loop still holds the pid. + configreloaded\>\>*) + recover_modeless ;; esac done < <(socat -U - "UNIX-CONNECT:$SOCKET") diff --git a/bin/omarchy-hyprland-reload-guard b/bin/omarchy-hyprland-reload-guard index c0b5868c..2dff5175 100755 --- a/bin/omarchy-hyprland-reload-guard +++ b/bin/omarchy-hyprland-reload-guard @@ -7,9 +7,9 @@ set -euo pipefail command="${1:-}" case "$command" in - pause | resume) ;; + pause | resume | paused) ;; *) - echo "Usage: omarchy-hyprland-reload-guard pause|resume" >&2 + echo "Usage: omarchy-hyprland-reload-guard pause|resume|paused" >&2 exit 1 ;; esac @@ -96,6 +96,9 @@ resume_instance() { } case "$command" in + paused) + compgen -G "$state_dir/*" >/dev/null + ;; pause) while IFS=$'\t' read -r runtime_dir signature; do pause_instance "$runtime_dir" "$signature" diff --git a/test/shell.d/hyprland-reload-guard-test.sh b/test/shell.d/hyprland-reload-guard-test.sh index 05ab23ee..ca979247 100755 --- a/test/shell.d/hyprland-reload-guard-test.sh +++ b/test/shell.d/hyprland-reload-guard-test.sh @@ -69,3 +69,18 @@ FAKE_HYPRCTL_LOG="$hyprctl_log" \ grep -F -- '--instance test-signature reload' "$hyprctl_log" >/dev/null || fail "reload guard forces one Hyprland reload after package transaction" grep -F 'hl.config({ misc = { disable_autoreload = false }, debug = { suppress_errors = false } })' "$hyprctl_log" >/dev/null || fail "reload guard restores previous Hyprland reload settings" pass "reload guard resumes live Hyprland reloads" + +# The modeless monitor recovery loop reloads on its own schedule, so it needs to +# ask whether a transaction is in flight. +rm -rf "$state_dir" +OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR="$state_dir" "$ROOT/bin/omarchy-hyprland-reload-guard" paused && + fail "reload guard reports itself unpaused before any transaction" + +mkdir -p "$state_dir" +OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR="$state_dir" "$ROOT/bin/omarchy-hyprland-reload-guard" paused && + fail "an empty state directory is not a paused transaction" + +touch "$state_dir/some-signature" +OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR="$state_dir" "$ROOT/bin/omarchy-hyprland-reload-guard" paused || + fail "reload guard reports itself paused during a transaction" +pass "reload guard reports whether a transaction is holding reloads" diff --git a/test/shell.d/monitor-modeless-test.sh b/test/shell.d/monitor-modeless-test.sh new file mode 100755 index 00000000..decee06c --- /dev/null +++ b/test/shell.d/monitor-modeless-test.sh @@ -0,0 +1,281 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +test_tmp=$(mktemp -d) +watch_pid="" +events_fd="" + +# The watcher forks delayed clamshell retries and the recovery loop. Orphaning +# those would leave them running against a deleted test tree. +# Closing the event stream first would let the watcher exit on its own and +# reparent the recovery loop, which then outlives the test and keeps reloading. +stop_watcher() { + local child descendants + + if [[ -n $watch_pid ]]; then + # Note them down before the watcher dies, then kill it first so it never + # gets to announce the loss of its jobs. + descendants=$(pgrep -P "$watch_pid" 2>/dev/null || true) + for child in $descendants; do + descendants+=" $(pgrep -P "$child" 2>/dev/null || true)" + done + + kill -KILL "$watch_pid" 2>/dev/null || true + wait "$watch_pid" 2>/dev/null || true + for child in $descendants; do + kill -KILL "$child" 2>/dev/null || true + done + watch_pid="" + fi + + if [[ -n $events_fd ]]; then + exec {events_fd}>&- + events_fd="" + fi + + return 0 +} + +cleanup() { + stop_watcher + rm -rf "$test_tmp" +} +trap cleanup EXIT + +fake_bin="$test_tmp/bin" +mkdir -p "$fake_bin" + +monitors_file="$test_tmp/monitors.json" +reload_log="$test_tmp/reload.log" +recovered="$test_tmp/recovered.json" +events="$test_tmp/events" +modeless_lock="$test_tmp/omarchy-monitor-modeless.lock" +hyprctl_fail="$test_tmp/hyprctl-fails" + +# The monitor list lives in a file so a reload can "fix" it mid-run. +cat >"$fake_bin/hyprctl" <<'SH' +#!/bin/bash + +# Mirrors are absent from plain `monitors`, so the helper must ask for `all`. +if [[ ${1:-} == "monitors" && ${2:-} == "all" && ${3:-} == "-j" ]]; then + [[ -f $OMARCHY_TEST_HYPRCTL_FAIL_FLAG ]] && exit 4 + cat "$OMARCHY_TEST_MONITORS_FILE" +elif [[ ${1:-} == "reload" ]]; then + printf 'reload\n' >>"$OMARCHY_TEST_RELOAD_LOG" + [[ -f $OMARCHY_TEST_RECOVERED_MONITORS ]] && + cp "$OMARCHY_TEST_RECOVERED_MONITORS" "$OMARCHY_TEST_MONITORS_FILE" +fi +SH + +cat >"$fake_bin/omarchy-hyprland-reload-guard" <<'SH' +#!/bin/bash + +[[ ${1:-} == "paused" && ${OMARCHY_TEST_GUARD_PAUSED:-0} == 1 ]] +SH + +cat >"$fake_bin/socat" <<'SH' +#!/bin/bash + +exec cat "$OMARCHY_TEST_EVENTS" +SH + +# A desktop, so the clamshell half of the watcher stays idle. +cat >"$fake_bin/omarchy-hw-laptop" <<'SH' +#!/bin/bash + +exit 1 +SH + +cat >"$fake_bin/omarchy-hyprland-monitor-clamshell" <<'SH' +#!/bin/bash + +exit 0 +SH + +chmod +x "$fake_bin"/* +ln -s "$ROOT/bin/omarchy-hyprland-monitor-modeless" "$fake_bin/omarchy-hyprland-monitor-modeless" + +MODELESS=0 +WORKING=1 +UNDETERMINED=2 + +assert_state() { + local expected="$1" actual=0 + + printf '%s' "$2" >"$monitors_file" + PATH="$fake_bin:$PATH" OMARCHY_TEST_MONITORS_FILE="$monitors_file" \ + OMARCHY_TEST_HYPRCTL_FAIL_FLAG="$hyprctl_fail" \ + "$ROOT/bin/omarchy-hyprland-monitor-modeless" || actual=$? + + (( actual == expected )) || fail "$3" "expected exit $expected, got $actual" + pass "$3" +} + +reloads() { + wc -l <"$reload_log" | tr -d ' ' +} + +# A monitor powered off at boot reports an EDID with no modes, so Hyprland +# brings it up at 0x0. +assert_state $MODELESS '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' \ + "a monitor brought up with no mode is detected" + +assert_state $MODELESS \ + '[{"name":"DP-1","width":2560,"height":1440,"disabled":false},{"name":"DP-2","width":0,"height":0,"disabled":false}]' \ + "one modeless monitor among working ones is detected" + +# Hyprland drops mirrors from `monitors`, so only `monitors all` sees them. +assert_state $MODELESS \ + '[{"name":"HDMI-A-1","width":0,"height":0,"disabled":false,"mirrorOf":"eDP-1"}]' \ + "a modeless mirrored monitor is detected" + +assert_state $WORKING '[{"name":"DP-1","width":2560,"height":1440,"disabled":false}]' \ + "a working monitor is not reported as modeless" + +# A monitor turned off on purpose is 0x0 too, and re-applying config would fight +# the user over it. +assert_state $WORKING '[{"name":"DP-1","width":0,"height":0,"disabled":true}]' \ + "a deliberately disabled monitor is not reported as modeless" + +assert_state $WORKING '[]' "a session with no monitors is not reported as modeless" + +# Nothing fires an event for this state, so taking an unanswered query for a +# healthy monitor would leave the screen black for good. +assert_state $UNDETERMINED 'not json' "an unreadable monitor payload cannot say" + +printf '%s' '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' >"$monitors_file" +touch "$hyprctl_fail" +unreachable=0 +PATH="$fake_bin:$PATH" OMARCHY_TEST_MONITORS_FILE="$monitors_file" \ +OMARCHY_TEST_HYPRCTL_FAIL_FLAG="$hyprctl_fail" \ + "$ROOT/bin/omarchy-hyprland-monitor-modeless" || unreachable=$? +rm -f "$hyprctl_fail" +(( unreachable == UNDETERMINED )) || fail "an unreachable compositor cannot say" "got $unreachable" +pass "an unreachable compositor cannot say" + +start_watcher() { + : >"$reload_log" + rm -f "$events" + mkfifo "$events" + + PATH="$fake_bin:$PATH" \ + XDG_RUNTIME_DIR="$test_tmp" \ + HYPRLAND_INSTANCE_SIGNATURE=test \ + OMARCHY_TEST_EVENTS="$events" \ + OMARCHY_TEST_MONITORS_FILE="$monitors_file" \ + OMARCHY_TEST_RELOAD_LOG="$reload_log" \ + OMARCHY_TEST_RECOVERED_MONITORS="$recovered" \ + OMARCHY_TEST_GUARD_PAUSED="${1:-0}" \ + OMARCHY_TEST_HYPRCTL_FAIL_FLAG="$hyprctl_fail" \ + "$ROOT/bin/omarchy-hyprland-monitor-watch" & + watch_pid=$! + + exec {events_fd}>"$events" +} + +await_reloads() { + local waited + + for (( waited = 0; waited < 100; waited++ )); do + (( $(reloads) >= $1 )) && return 0 + sleep 0.05 + done + + return 1 +} + +# The watcher reloads until the monitor reports a mode, then stops. It has to +# stop on its own: powering the monitor on fires no event to stop it. +printf '%s' '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' >"$monitors_file" +printf '%s' '[{"name":"DP-1","width":2560,"height":1440,"disabled":false}]' >"$recovered" +start_watcher + +await_reloads 1 || fail "a monitor with no mode is reloaded at startup" +sleep 1 +(( $(reloads) == 1 )) || fail "recovery stops once the monitor reports a mode" "$(<"$reload_log")" +pass "recovery reloads until the monitor comes back, then stops on its own" + +# A reload lands a monitor at 0x0 the same way a boot does, and no hotplug +# follows. The watcher's own recovery reload comes back through this event too, +# which the lock makes a no-op rather than a second loop. +printf '%s' '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' >"$monitors_file" +rm -f "$recovered" +printf 'configreloaded>>\n' >&"$events_fd" + +await_reloads 2 || fail "a monitor left at 0x0 by a reload is recovered" +pass "a monitor left at 0x0 by a reload is recovered" + +before=$(reloads) +printf 'configreloaded>>\n' >&"$events_fd" +printf 'configreloaded>>\n' >&"$events_fd" +sleep 2 +(( $(reloads) - before <= 2 )) || + fail "an echoed configreloaded does not stack a second recovery loop" "$before -> $(reloads)" +pass "an echoed configreloaded does not stack a second recovery loop" + +stop_watcher + +# A package transaction has config half-replaced; reloading into that is exactly +# what the reload guard exists to prevent. +printf '%s' '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' >"$monitors_file" +start_watcher 1 + +sleep 1 +[[ ! -s $reload_log ]] || fail "recovery holds off while the reload guard is paused" "$(<"$reload_log")" +pass "recovery holds off while the reload guard is paused" + +stop_watcher + +# An unanswerable query is not a healthy monitor. Giving up on one would strand +# the screen, because nothing fires an event to start recovery again. +printf '%s' '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' >"$monitors_file" +rm -f "$recovered" +touch "$hyprctl_fail" +start_watcher + +sleep 1 +[[ ! -s $reload_log ]] || fail "an unanswerable query does not trigger a reload" "$(<"$reload_log")" +rm -f "$hyprctl_fail" + +await_reloads 1 || fail "recovery keeps asking after the compositor could not answer" +pass "recovery keeps asking after the compositor could not answer" + +stop_watcher + +# An event landing while a worker is on its way out must not be dropped: it is +# the last one that will ever come for this state. +printf '%s' '[{"name":"DP-1","width":2560,"height":1440,"disabled":false}]' >"$monitors_file" +rm -f "$recovered" +start_watcher + +# The healthy startup worker has to be gone before the lock stands in for one on +# its way out, or it would hold it here and loop on the 0x0 state below instead. +flock -w 5 "$modeless_lock" -c true || fail "the startup worker released the recovery lock" + +flock "$modeless_lock" -c 'sleep 0.5' & +lock_holder=$! +sleep 0.1 + +printf '%s' '[{"name":"DP-1","width":0,"height":0,"disabled":false}]' >"$monitors_file" +printf 'configreloaded>>\n' >&"$events_fd" +wait "$lock_holder" 2>/dev/null || true + +await_reloads 1 || fail "a trigger contending with the recovery lock is not dropped" +pass "a trigger contending with the recovery lock is not dropped" + +stop_watcher + +# Nothing to recover means nothing to run. +printf '%s' '[{"name":"DP-1","width":2560,"height":1440,"disabled":false}]' >"$monitors_file" +start_watcher + +sleep 1 +[[ ! -s $reload_log ]] || fail "a healthy machine is left alone" "$(<"$reload_log")" +pass "a healthy machine is never reloaded" + +stop_watcher diff --git a/test/shell.d/monitor-recovery-test.sh b/test/shell.d/monitor-recovery-test.sh index 024fb2b4..52a0863a 100755 --- a/test/shell.d/monitor-recovery-test.sh +++ b/test/shell.d/monitor-recovery-test.sh @@ -36,6 +36,29 @@ 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" +# Recovery costs a reload per attempt, so it must not run on a healthy machine, +# and only one loop may run across the events that start it. +grep -F '(( state == 1 )) && break' "$monitor_watch" >/dev/null +grep -F 'delay = delay * 2 > 60 ? 60 : delay * 2' "$monitor_watch" >/dev/null +grep -F '9>"$MODELESS_LOCK"' "$monitor_watch" >/dev/null +grep -F 'flock -w 1 9 || exit 0' "$monitor_watch" >/dev/null +pass "modeless monitor recovery runs one backing-off loop while a monitor has no mode" + +# Nothing fires an event for this state, so an unanswered query must not end +# recovery -- but a compositor that never answers has gone with the session. +grep -F '(( state == 2 )) && (( ++unanswered > 20 )) && break' "$monitor_watch" >/dev/null +pass "modeless recovery retries unanswered queries without waiting on a dead compositor" + +grep -F 'configreloaded\>\>*)' "$monitor_watch" >/dev/null +pass "modeless recovery also runs after a config reload" + +grep -F 'omarchy-hyprland-reload-guard paused' "$monitor_watch" >/dev/null +pass "modeless recovery does not reload into a package transaction" + +grep -F '.disabled != true and (.width == 0 or .height == 0)' "$ROOT/bin/omarchy-hyprland-monitor-modeless" >/dev/null +grep -F 'hyprctl monitors all -j' "$ROOT/bin/omarchy-hyprland-monitor-modeless" >/dev/null +pass "modeless helper sees mirrors and ignores monitors disabled on purpose" + 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"