When using a single external monitor with multiple HDMI sources, things can get awkward quickly. In my setup, the monitor is connected to two machines:

  • my Arch Linux workstation running Hyprland/Wayland
  • a customer laptop running Windows 11

Switching the monitor input from Arch to the Windows laptop worked perfectly. Windows immediately used the external monitor. But switching back to the Arch workstation often resulted in a black screen or a “no source” message. The monitor LED briefly changed from standby to active, then went back again.

Interestingly, the Arch session was still alive. I could recover the desktop manually by switching virtual terminals:

Alt + F2
Alt + F1

After that, Hyprland displayed the desktop again.

This blog post documents the investigation, the failed approaches, and the final working solution: listening for DRM hotplug events and performing a controlled VT refresh using chvt.


The Problem

The symptoms were:

Switch monitor input from Arch Linux to Windows laptop: works
Switch monitor input back to Arch Linux: monitor stays dark / no source
Press Alt+F2, then Alt+F1: Arch desktop appears again

The Arch workstation was not frozen. Hyprland was still running. The monitor was also still known to Hyprland.

Running:

hyprctl monitors

showed that Hyprland believed everything was fine:

Monitor HDMI-A-2 (ID 0):
    3840x2160@60.00000 at 0x0
    focused: yes
    dpmsStatus: 1
    disabled: false
    active workspace: 1 (1)

So from Hyprland’s perspective:

  • the monitor was active
  • DPMS was on
  • the workspace was assigned
  • the monitor was focused
  • the output was not disabled

Still, the physical monitor showed no usable image until a VT switch happened.


What Did Not Work

The first attempt was a simple systemd timer that periodically ran a monitor recovery script.

The early version tried both wlr-randr and xrandr:

wlr-randr --output HDMI-A-1 --on
xrandr --auto

This was wrong for several reasons.

First, xrandr is for X11. Under Hyprland/Wayland, it is not the correct tool for managing real outputs. It also produced errors like:

Authorization required, but no authorization protocol specified
Can't open display :0

Second, the service was originally configured with:

Restart=always
RestartSec=10

That caused the script to restart continuously, even though it was supposed to be run by a timer.

The correct systemd service type for a one-shot script is:

Type=oneshot

But even after cleaning that up, polling every few seconds was not ideal. Some recovery attempts caused the screen to briefly go dark and come back, which is unacceptable during normal desktop usage.


Correct Monitor Name

Another important discovery was that the monitor was not called HDMI-A-1.

Hyprland reported:

hyprctl monitors

Output:

Monitor HDMI-A-2 (ID 0):
    3840x2160@60.00000 at 0x0
    description: Iiyama North America PL3288UH

So all scripts had to use:

MONITOR="HDMI-A-2"

instead of:

MONITOR="HDMI-A-1"

Why a Simple Hyprland Recovery Was Not Enough

The next attempt was to use hyprctl:

hyprctl dispatch dpms on HDMI-A-2
hyprctl dispatch focusmonitor HDMI-A-2
hyprctl keyword monitor "HDMI-A-2,3840x2160@60,0x0,1"
hyprctl reload

All of these commands succeeded:

ok

But the monitor still did not recover automatically.

The logs showed:

focused: yes
dpmsStatus: 1
disabled: false
active workspace: 1

That told us something important:

Hyprland already believed the monitor was active and correctly configured.

The problem was not a missing Hyprland monitor rule. It was deeper in the graphics stack, somewhere around the HDMI/DRM/VT state.


Hyprland Events Were Not Enough Either

Hyprland exposes an event socket:

$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock

We listened to it using:

socat -U - UNIX-CONNECT:"$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" \
  | grep --line-buffered -E '^(monitoradded|monitoraddedv2|monitorremoved|monitorremovedv2|focusedmon|focusedmonv2|workspace|workspacev2)>>'

But switching monitor inputs did not produce useful Hyprland monitor events.

There were no events like:

monitorremoved>>HDMI-A-2
monitoradded>>HDMI-A-2

So Hyprland itself did not notice the input switch in a useful way.


The Useful Signal: DRM Hotplug Events

The breakthrough came from monitoring DRM events:

udevadm monitor --kernel --property --subsystem-match=drm

When switching the monitor input, the kernel emitted events like:

KERNEL[...] change /devices/.../drm/card1 (drm)
ACTION=change
SUBSYSTEM=drm
HOTPLUG=1
DEVNAME=/dev/dri/card1
DEVTYPE=drm_minor

That was the missing trigger.

Hyprland did not emit a usable monitor event, but the kernel did report a DRM hotplug event.

So the final solution became:

DRM HOTPLUG=1 event
    -> wait a few seconds for HDMI sync
    -> run a controlled recovery script
    -> perform VT switch away and back
    -> return to Hyprland

Final Architecture

The final setup consists of:

  1. a recovery script
  2. a DRM hotplug watcher script
  3. a systemd user service
  4. a restricted sudoers rule for chvt

The old timer is no longer needed.


Step 1: Disable the Old Timer

If you previously used a timer, disable it:

systemctl --user disable --now revive-monitor.timer
systemctl --user stop revive-monitor.service
systemctl --user reset-failed revive-monitor.service

Verify:

systemctl --user list-timers --all | grep revive

There should be no active timer for the monitor recovery anymore.


Step 2: Create the Recovery Script

Create:

nano ~/Scripts/revive-monitor-fix.sh

Content:

#!/usr/bin/env bash

MONITOR="HDMI-A-2"
LOGTAG="revive-monitor-fix"

# Adjust these if your Hyprland session runs on another VT.
HYPRLAND_VT="1"
TEMP_VT="2"

STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/revive-monitor"
LOCK_FILE="$STATE_DIR/fix.lock"

mkdir -p "$STATE_DIR"

log() {
    logger -t "$LOGTAG" "$1"
}

run() {
    "$@" >/dev/null 2>&1
}

# Prevent overlapping recovery runs.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
    log "Recovery already running - skipping"
    exit 0
fi

log "Recovery started for $MONITOR"

# Give the HDMI handshake some time.
sleep 5

# Make sure Hyprland still sees and enables the output.
run hyprctl dispatch dpms on "$MONITOR"
run hyprctl dispatch focusmonitor "$MONITOR"

sleep 1

log "VT refresh: $TEMP_VT -> $HYPRLAND_VT"

if sudo -n /usr/bin/chvt "$TEMP_VT"; then
    sleep 1
    sudo -n /usr/bin/chvt "$HYPRLAND_VT"
    sleep 2
else
    log "Error: sudo chvt $TEMP_VT failed"
    exit 1
fi

# After returning to Hyprland, make sure the monitor is active again.
run hyprctl dispatch dpms on "$MONITOR"
run hyprctl dispatch focusmonitor "$MONITOR"

log "Recovery completed for $MONITOR"

Make it executable:

chmod +x ~/Scripts/revive-monitor-fix.sh

Finding the Correct VT

To check which VT your Hyprland session uses:

loginctl show-session "$XDG_SESSION_ID" -p VTNr -p Type -p State

Example:

VTNr=1
Type=wayland
State=active

If your Hyprland session runs on VT 1, keep:

HYPRLAND_VT="1"
TEMP_VT="2"

If it runs on VT 2, use something like:

HYPRLAND_VT="2"
TEMP_VT="3"

Step 3: Allow chvt Safely via sudoers

The recovery script needs to switch virtual terminals. That requires root privileges.

Instead of giving broad passwordless sudo access, allow only /usr/bin/chvt.

Create a dedicated sudoers file:

sudo visudo -f /etc/sudoers.d/revive-monitor-chvt

Add:

johannes ALL=(root) NOPASSWD: /usr/bin/chvt

Replace johannes with your username if needed.

Test it:

sudo -n /usr/bin/chvt 2
sleep 1
sudo -n /usr/bin/chvt 1

The -n flag is important. It makes sudo fail immediately if a password would be required. This is exactly what we want for a non-interactive systemd user service.

This sudoers rule is intentionally narrow:

Only this user may run only /usr/bin/chvt as root without a password.

It does not grant general passwordless sudo access.


Step 4: Create the DRM Hotplug Watcher

Create:

nano ~/Scripts/revive-monitor-drm-watch.sh

Content:

#!/usr/bin/env bash

LOGTAG="revive-monitor-drm-watch"
FIX_SCRIPT="$HOME/Scripts/revive-monitor-fix.sh"

STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/revive-monitor"
LAST_RUN_FILE="$STATE_DIR/last-drm-hotplug-run"

DEBOUNCE_SECONDS=30

mkdir -p "$STATE_DIR"

log() {
    logger -t "$LOGTAG" "$1"
}

run_recovery() {
    local now
    local last
    local diff

    now="$(date +%s)"
    last="0"

    if [ -f "$LAST_RUN_FILE" ]; then
        last="$(cat "$LAST_RUN_FILE" 2>/dev/null || echo 0)"
    fi

    diff=$((now - last))

    if [ "$diff" -lt "$DEBOUNCE_SECONDS" ]; then
        log "DRM hotplug ignored because of debounce (${diff}s)"
        return
    fi

    echo "$now" > "$LAST_RUN_FILE"

    log "DRM hotplug detected - starting recovery"

    "$FIX_SCRIPT"

    log "Recovery after DRM hotplug finished"
}

if ! command -v udevadm >/dev/null 2>&1; then
    log "udevadm not found"
    exit 1
fi

if [ ! -x "$FIX_SCRIPT" ]; then
    log "Fix script is not executable: $FIX_SCRIPT"
    exit 1
fi

log "Listening for DRM hotplug events"

stdbuf -oL udevadm monitor --kernel --property --subsystem-match=drm | while IFS= read -r line; do
    case "$line" in
        HOTPLUG=1)
            run_recovery
            ;;
    esac
done

Make it executable:

chmod +x ~/Scripts/revive-monitor-drm-watch.sh

The debounce is important because one physical input switch can produce multiple DRM events.

Start with:

DEBOUNCE_SECONDS=30

If you still see duplicate recoveries, increase it to:

DEBOUNCE_SECONDS=60

Step 5: Create the systemd User Service

Create:

nano ~/.config/systemd/user/revive-monitor-drm-watch.service

Content:

[Unit]
Description=Watch DRM hotplug events and revive HDMI monitor
After=graphical-session.target

[Service]
Type=simple
ExecStart=%h/Scripts/revive-monitor-drm-watch.sh
Restart=always
RestartSec=2
Environment=XDG_RUNTIME_DIR=%t

[Install]
WantedBy=default.target

Enable and start it:

systemctl --user daemon-reload
systemctl --user enable --now revive-monitor-drm-watch.service

Check status:

systemctl --user status revive-monitor-drm-watch.service

Check whether it is enabled:

systemctl --user is-enabled revive-monitor-drm-watch.service

Expected result:

enabled

Step 6: Test the Setup

Follow this test flow:

  1. Make sure Arch/Hyprland is visible on the monitor.
  2. Switch the monitor input to the Windows laptop.
  3. Wait a few seconds.
  4. Switch the monitor input back to Arch.
  5. Wait for the DRM watcher and recovery script to run.

Watch logs:

journalctl --user -u revive-monitor-drm-watch.service -f

Or:

journalctl --user -t revive-monitor-fix -f

A successful run looks similar to this:

DRM hotplug detected - starting recovery
Recovery started for HDMI-A-2
VT refresh: 2 -> 1
Recovery completed for HDMI-A-2
Recovery after DRM hotplug finished

You may also see sudo log entries like:

COMMAND=/usr/bin/chvt 2
COMMAND=/usr/bin/chvt 1

That is expected.


Why This Works

The important finding was that Hyprland was not actually “missing” the monitor.

Hyprland reported:

focused: yes
dpmsStatus: 1
disabled: false
active workspace: 1

So the compositor believed the output was fine.

The physical monitor, however, did not show the signal correctly after switching HDMI inputs. A manual VT switch fixed the problem, which indicated that the graphics stack needed a deeper refresh than a normal Hyprland workspace or DPMS command.

The final solution therefore does not poll constantly and does not toggle the display every few seconds. Instead, it waits for a real kernel DRM hotplug event:

HOTPLUG=1

Then it runs a controlled recovery:

wait for HDMI sync
enable/focus monitor in Hyprland
switch VT away
switch VT back to Hyprland
enable/focus monitor again

This reproduces the manual workaround automatically.


Security Considerations

The only privileged operation is chvt.

The sudoers rule is intentionally limited:

johannes ALL=(root) NOPASSWD: /usr/bin/chvt

This avoids giving the user broad passwordless sudo access.

The script also uses:

sudo -n /usr/bin/chvt

so it never waits for a password prompt inside systemd.

Additionally, the recovery script uses a lock file:

flock -n

to avoid overlapping recovery runs.

The watcher also uses debounce logic to avoid running the recovery repeatedly for a burst of DRM hotplug events.


Summary

The issue was caused by switching a physical monitor input between an Arch Linux Hyprland workstation and a Windows laptop. After switching back to Arch, the monitor sometimes stayed black or reported no source, even though Hyprland still believed the monitor was active.

Several approaches did not fully solve the problem:

  • xrandr was not suitable under Wayland/Hyprland.
  • wlr-randr and hyprctl dpms on were not enough.
  • Hyprland monitor events did not fire reliably for this input switch.
  • Polling with a systemd timer caused unwanted display interruptions.

The working solution was:

  • listen for kernel DRM hotplug events using udevadm monitor
  • trigger recovery only on HOTPLUG=1
  • wait for HDMI sync
  • perform a controlled VT switch using chvt
  • return to the Hyprland VT
  • restrict passwordless sudo to /usr/bin/chvt only

The result is a reliable automatic recovery without periodic screen flickering and without broad sudo permissions.

Views: 0

Recovering a Black HDMI Monitor on Arch Linux + Hyprland After Switching Inputs

Johannes Rest


.NET Architekt und Entwickler


Beitragsnavigation


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert