From 3b08a85ad17f897e630ff2580cc1bb2672d818df Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 11 Aug 2026 13:26:24 +0200 Subject: [PATCH] Recover monitors Hyprland brought up with no mode (#6701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A monitor powered off when the machine boots — a smart strip cutting AC, the PC coming back on its own — still answers DDC, but with a partial EDID that carries no video modes. Hyprland takes the connector as present and brings the monitor up at 0x0. Powering it on afterwards changes nothing: the connector never dropped at DRM level, so no hotplug fires, nothing re-reads the EDID, and the screen stays black until a reboot. Only a reload re-reads it. Forcing a DRM re-probe would work too but needs root, and the kernel's cached mode list stays empty without one, so there is nothing cheaper to poll: the reload is both the fix and the only way to learn whether it was needed. Poll only while a monitor is in that state, back off from three seconds to a minute, and stop as soon as one reports a mode — the machine can sit black all night, and powering the monitor on fires no event to stop on. Nothing will ask again if this loop gives up, so an unreadable answer is not taken for a healthy monitor. It is also not waited on forever: a compositor that stays silent has gone, and with it the session and any reason to keep asking. Reloading on our own schedule means minding the reload guard, which exists to keep Hyprland out of package-owned config mid-transaction, and which only disables the automatic reloads. The guard can now be asked, and recovery holds off while a transaction is in flight. A reload lands a monitor at 0x0 the same way a boot does, so configreloaded is watched alongside the hotplug events. The recovery's own reload comes back through it, and a lock keeps that from stacking a second loop. Contention waits rather than drops: a trigger arriving while a loop is exiting is the last one that will come, and one arriving while a loop is running is answered by its next pass anyway. Mirrors are dropped from `hyprctl monitors`, so the check asks for all of them and filters the disabled ones itself. Monitors turned off on purpose sit at 0x0 too, and re-applying config would fight the user over those. The existing poll here recovers internal panels on docked laptops and never runs on a desktop, which is where this happens. Reported in #6668. Co-authored-by: Claude Opus 5 --- bin/omarchy-hyprland-monitor-modeless | 21 ++ bin/omarchy-hyprland-monitor-watch | 46 ++++ bin/omarchy-hyprland-reload-guard | 7 +- test/shell.d/hyprland-reload-guard-test.sh | 15 ++ test/shell.d/monitor-modeless-test.sh | 281 +++++++++++++++++++++ test/shell.d/monitor-recovery-test.sh | 23 ++ 6 files changed, 391 insertions(+), 2 deletions(-) create mode 100755 bin/omarchy-hyprland-monitor-modeless create mode 100755 test/shell.d/monitor-modeless-test.sh 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"