Harden HDMI recovery and add tested user installer release tooling

This commit is contained in:
admin committed 2026-09-23 09:16:57 +02:00
1 parent 73e727ef4c
commit d963224cb7
17 files changed
+902 -645

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
name: Checks
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install check tools
run: sudo apt-get update && sudo apt-get install -y shellcheck jq kbd
- name: Shell checks
run: |
for script in scripts/*.sh lib/*.sh; do bash -n "$script"; done
shellcheck -x scripts/*.sh lib/*.sh
- name: Behavior tests (unprivileged)
run: |
if [ "$(id -u)" = 0 ]; then
useradd -m revive-test
chmod o+x "$HOME"
chmod -R a+rX .
runuser -u revive-test -- python3 -B -m unittest discover -s tests -v
else
python3 -B -m unittest discover -s tests -v
fi
+4
View File
@@ -22,3 +22,7 @@ Thumbs.db
# Shell tooling
.shellcheckrc.local
# Generated release files and test caches
dist/
__pycache__/
+16
View File
@@ -2,6 +2,22 @@
All notable changes to this project will be documented here.
## 0.2.0 - 2026-09-23
### Fixed
- Session revalidation and Hyprland IPC/output checks before VT switching.
- Emergency return on catchable signals and failed VT returns.
- Private runtime directories, lock error handling and configuration validation.
- Watcher restart on event-stream loss and honest recovery failure logs.
- Explicit activation, safe update restart, consistent XDG paths and scoped uninstall.
### Added
- Regression tests with mocked hardware/system services and Gitea CI checks.
- Reproducible committed-source installer archives, source ZIP, Base64 and checksums.
- Review findings, hardware acceptance notes and Hyprland environment startup hook.
## 0.1.0 - 2026-09-23
### Added
+110 -278
View File
@@ -1,131 +1,65 @@
# Hyprland HDMI Revive
Automatically recover a Wayland/Hyprland desktop after switching a physical monitor input away from an Arch Linux workstation and back again.
Recover a black HDMI display after switching a shared physical monitor away from
an Arch Linux / Hyprland workstation and back. A DRM hotplug event triggers a
short switch away from the active graphical VT and back. There is no periodic
DPMS toggle or polling timer.
This project was built for a setup where one external HDMI monitor is shared between:
- an Arch Linux workstation running Hyprland/Wayland
- a Windows laptop connected to another HDMI input on the same monitor
On some monitors/GPU combinations, switching back to the Arch input leaves the display black or showing `No source`, even though Hyprland still reports the monitor as enabled, focused, and DPMS-on.
The workaround that reliably restores the image is a short VT switch away from the active Hyprland VT and back again. This repository automates that workaround only when the kernel reports a DRM hotplug event and only while an active Wayland session exists.
## How it works
```text
Monitor input switch
|
v
Kernel DRM HOTPLUG=1 event
|
v
User service receives the event
|
v
Is there an active Wayland session for this user?
| |
no yes
| |
ignore detect active VT
|
v
wait for HDMI sync
|
v
Hyprland DPMS/focus refresh
|
v
temporary VT switch
|
v
switch back to Hyprland
```
The important detail is that the recovery is **event-driven**. There is no polling timer and no periodic DPMS off/on cycle, so the screen does not blink during normal desktop use.
## Repository layout
```text
.
├── config/
│ └── revive-monitor.env.example
├── docs/
│ ├── architecture.md
│ └── troubleshooting.md
├── scripts/
│ ├── install.sh
│ ├── revive-monitor-drm-watch.sh
│ ├── revive-monitor-fix.sh
│ └── uninstall.sh
├── sudoers/
│ ├── revive-monitor-chvt.example
│ └── revive-monitor-chvt.strict.example
├── systemd/
│ └── user/
│ └── revive-monitor-drm-watch.service
├── .gitea/
│ ├── issue_template/
│ └── pull_request_template.md
├── .editorconfig
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── SECURITY.md
└── README.md
```
**Version 0.2.0.** The initial implementation and the review fixes are separate
commits. See [the code review](docs/code-review.md) and [changelog](CHANGELOG.md).
## Requirements
- Arch Linux or another systemd-based Linux distribution
- Hyprland
- `systemd` / `systemd-logind`
- `udevadm`
- `hyprctl`
- `flock` from `util-linux`
- `chvt` from `kbd`
- `sudo`
On Arch Linux, the relevant packages are typically already present. If needed:
A systemd-based Linux desktop with Hyprland, a local `seat0` Wayland session,
Bash, coreutils (`timeout`, `stdbuf`, `stat`), `jq`, `flock`, `sudo` and
`/usr/bin/chvt`. On Arch Linux:
```bash
sudo pacman -S --needed hyprland systemd util-linux kbd sudo
sudo pacman -S --needed hyprland systemd bash coreutils jq util-linux kbd sudo
```
## 1. Clone the repository
The intended setup is one local Hyprland session per user. The monitor must emit
DRM `HOTPLUG=1` events when its physical input changes. No event means no automatic
recovery. Hyprland must still expose the configured monitor over its IPC socket.
Example for Gitea:
## Install from the package
Download `hyprland-hdmi-revive-0.2.0.tar.gz` and `SHA256SUMS` from the
[Gitea release](https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive/releases).
Run as your desktop user, **without sudo**:
```bash
git clone ssh://git@gitea.example.com/your-user/hyprland-hdmi-revive.git
sha256sum --ignore-missing -c SHA256SUMS
tar -xzf hyprland-hdmi-revive-0.2.0.tar.gz
cd hyprland-hdmi-revive-0.2.0
./scripts/install.sh
```
This is a portable user installer bundle, not a pacman system package. It installs:
- executables under `~/.local/bin/`;
- shared code under `~/.local/lib/hyprland-hdmi-revive/`;
- the unit under `${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/`;
- a new config under `${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env`.
Existing configuration is preserved. A running instance is stopped before its
files are replaced. Installation alone does not enable or start the service.
Alternatively, clone the source and use the same installer:
```bash
git clone https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive.git
cd hyprland-hdmi-revive
./scripts/install.sh
```
## 2. Configure the monitor
## Configure
Copy the example configuration:
```bash
mkdir -p ~/.config/revive-monitor
cp config/revive-monitor.env.example ~/.config/revive-monitor/config.env
```
Determine the Hyprland output name:
Find the monitor name from a terminal inside Hyprland:
```bash
hyprctl monitors
```
Example:
```text
Monitor HDMI-A-2 (ID 0):
```
Then edit:
```bash
nano ~/.config/revive-monitor/config.env
nano "${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env"
```
Example:
@@ -138,204 +72,102 @@ VT_SWITCH_DELAY_SECONDS=1
VT_RETURN_SETTLE_SECONDS=2
```
## 3. Install the scripts and systemd user service
Timing values are whole seconds: 0–300 for waits and 0–3600 for debounce. Config is
trusted Bash sourced as your user; do not copy untrusted shell code into it.
Run:
### Allow the VT switch
```bash
./scripts/install.sh
```
Edit with `sudo visudo -f /etc/sudoers.d/revive-monitor-chvt`. Prefer explicit VT
arguments when the machine always uses VT1 and VT2:
The installer copies the scripts to:
```text
~/.local/bin/revive-monitor-fix
~/.local/bin/revive-monitor-drm-watch
```
and installs the user service at:
```text
~/.config/systemd/user/revive-monitor-drm-watch.service
```
The service is intentionally attached to `default.target`, because not every Hyprland setup activates `graphical-session.target`.
The watcher itself performs an additional safety check and refuses to switch VTs unless it finds an **active Wayland session for the current user**. This prevents DRM hotplug events during boot from switching the display manager away from its VT.
## 4. Configure sudo safely
`chvt` requires root privileges. Do **not** give the script unrestricted passwordless sudo.
### Recommended general rule
Open a dedicated sudoers file:
```bash
sudo visudo -f /etc/sudoers.d/revive-monitor-chvt
```
Add:
```text
YOUR_USER ALL=(root) NOPASSWD: /usr/bin/chvt
```
Replace `YOUR_USER` with your username.
This grants passwordless sudo only for `/usr/bin/chvt`, not for arbitrary root commands.
### More restrictive rule
If your graphical session always uses VT1 and the temporary VT is always VT2, use:
```text
```sudoers
YOUR_USER ALL=(root) NOPASSWD: /usr/bin/chvt 1, /usr/bin/chvt 2
```
A template is included in:
Replace `YOUR_USER`. For changing graphical VTs, a broader chvt-only rule is in
`sudoers/revive-monitor-chvt.example`. Never grant sudo access to these scripts.
The helper checks permission for both directions before leaving the desktop and
uses `sudo -n` with command timeouts. A return failure gets one emergency retry.
```text
sudoers/revive-monitor-chvt.strict.example
### Connect the Hyprland session
The user service manager does not automatically inherit a terminal's Hyprland
environment. Add this line to your Hyprland configuration (commonly
`~/.config/hypr/hyprland.conf`):
```ini
exec-once = sh -c 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"; systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE XDG_CONFIG_HOME && systemctl --user restart revive-monitor-drm-watch.service'
```
The scripts use `sudo -n`, so they never block waiting for a password prompt.
It imports the current IPC instance at each Hyprland login, including after
logging out and back in. A custom `XDG_CONFIG_HOME` must also be consistent with
the user manager's unit search path. See the official
[hyprctl documentation](https://wiki.hypr.land/configuring/core/advanced-configuration/using-hyprctl/)
and [systemd environment documentation](https://www.freedesktop.org/software/systemd/man/latest/environment.d.html).
Test the rule manually:
```bash
sudo -n /usr/bin/chvt 2
sleep 1
sudo -n /usr/bin/chvt 1
```
## 5. Start the service
The installer enables it automatically. To do it manually:
```bash
systemctl --user daemon-reload
systemctl --user enable --now revive-monitor-drm-watch.service
```
Check status:
For the current login, after configuring the output and sudoers:
```bash
./scripts/install.sh --start
systemctl --user status revive-monitor-drm-watch.service
journalctl --user -u revive-monitor-drm-watch.service -b
```
Watch logs:
`--start` imports the current instance, reloads systemd, enables and restarts the
unit. Use it for updates as well. The service remains attached to `default.target`
because not every Hyprland setup activates `graphical-session.target`; session
and IPC guards prevent pre-login VT switching.
```bash
journalctl --user -u revive-monitor-drm-watch.service -f
```
## Behavior and limits
## Why not xrandr?
`xrandr` is an X11 tool. Under Hyprland/Wayland it is not the correct interface for controlling physical outputs and may only see Xwayland state. Typical symptoms include:
```text
Authorization required, but no authorization protocol specified
Can't open display :0
```
This project uses `hyprctl` for compositor-side state and DRM/udev events for the actual hotplug trigger.
## Why not poll every 20 or 30 seconds?
A polling approach can accidentally run disruptive operations while the desktop is already working. In early versions, a periodic DPMS toggle caused the monitor to go dark and come back every few seconds.
This implementation does not use a timer. Recovery is triggered only by:
```text
SUBSYSTEM=drm
HOTPLUG=1
```
and only if an active Wayland session exists.
## Why the active Wayland-session check matters
During boot, GPUs and displays can generate DRM hotplug events before the display manager or Hyprland session is ready.
Without a session guard, a recovery script that always runs:
```bash
chvt 2
chvt 1
```
can leave the machine on tty1 while the display manager is actually running on tty2.
The watcher therefore queries `systemd-logind` via `loginctl` and only runs recovery when it finds a session matching all of these conditions:
```text
Name=<current user>
Type=wayland
Active=yes
VTNr>0
```
The current graphical VT is detected dynamically and passed to the recovery script. No VT is hard-coded.
## Example successful log
```text
revive-monitor-drm-watch: DRM hotplug detected - Wayland session 3 on VT 1
revive-monitor-fix: Recovery started for HDMI-A-2 on VT 1
revive-monitor-fix: VT refresh: 2 -> 1
sudo: COMMAND=/usr/bin/chvt 2
sudo: COMMAND=/usr/bin/chvt 1
revive-monitor-fix: Recovery completed for HDMI-A-2
revive-monitor-drm-watch: Recovery after DRM hotplug finished
```
## Verify the boot-safety fix
After rebooting, check:
```bash
journalctl -b --no-pager | grep -E 'revive-monitor|COMMAND=/usr/bin/chvt|chvt'
```
Before logging into Hyprland, you should either see no recovery at all or a message similar to:
```text
No active Wayland session - DRM hotplug ignored
```
There should be no `chvt` call before the graphical user session exists.
- Events are accepted only for this UID's active local Wayland session on seat0.
A reported non-Hyprland desktop is excluded.
- The helper independently verifies the requested VT and rechecks the session
after settling and permission checks.
- A private `XDG_RUNTIME_DIR` is required. Locks prevent duplicate watchers and
overlapping recoveries; there is no shared `/tmp` fallback.
- Missing IPC, invalid monitor names, command failures and lost event streams
produce error logs. A terminated event stream causes systemd to restart the watcher.
- Signals trigger a best-effort return to the graphical VT. SIGKILL, power loss
and persistent sudo/kernel failures cannot be recovered by a shell trap.
- DRM events are not reliably attributed to a particular monitor. Another
output's hotplug can trigger this workaround too.
- Debounce is a cooldown measured after a recovery attempt. It suppresses queued
events, including events caused by the VT switch. It can also suppress a real
rapid input return; if that happens, try a shorter `DEBOUNCE_SECONDS` or run
the helper manually with the correct graphical VT.
## Uninstall
Run:
From the unpacked bundle or checkout:
```bash
./scripts/uninstall.sh
```
Then remove the sudoers file manually:
The user manager must be reachable. Config and sudoers remain intact. Remove the
Hyprland startup line and, if no longer needed, the dedicated sudoers entry with
`visudo`. Failure state for unrelated services is preserved.
## Build and validate
Python 3 is needed only for tests; ShellCheck is a development dependency.
```bash
sudo rm /etc/sudoers.d/revive-monitor-chvt
for script in scripts/*.sh lib/*.sh; do bash -n "$script" || exit; done
shellcheck -x scripts/*.sh lib/*.sh
python3 -B -m unittest discover -s tests -v
./scripts/build-release.sh
```
The uninstall script intentionally does not remove sudoers configuration automatically.
The release builder requires clean tracked files and packages **committed HEAD**.
It creates a `.tar.gz` installer bundle, source ZIP, Base64 ZIP and `SHA256SUMS` in
`dist/`. Untracked files and local config are excluded. The Base64 ZIP can also
be decoded on Windows with `certutil -decode <file.zip.b64> <file.zip>`.
## Troubleshooting
Tests mock loginctl, Hyprland IPC, udev, sudo and systemd. They never switch the
real display. A hardware input-switch test after installation remains necessary.
CI runs the shell checks and behavioral tests on a configured Gitea Actions runner.
See [docs/troubleshooting.md](docs/troubleshooting.md).
## Security notes
- The service runs as a normal systemd **user service**.
- Only `chvt` requires root privileges.
- `sudo -n` prevents unattended password prompts.
- A lock file prevents overlapping recovery runs.
- Debouncing suppresses repeated DRM hotplug events.
- The recovery runs only for an active Wayland session belonging to the current user.
- The sudoers rule should be limited to `/usr/bin/chvt`, or ideally only the specific VTs used on the machine.
## License
MIT. See [LICENSE](LICENSE).
See [architecture](docs/architecture.md), [troubleshooting](docs/troubleshooting.md)
and [security notes](SECURITY.md). MIT license: [LICENSE](LICENSE).
+14 -1
View File
@@ -31,8 +31,21 @@ This prevents the systemd user service from hanging on a password prompt.
## Boot safety
DRM hotplug events can occur during boot. The watcher therefore verifies that the current user has an active Wayland session with a valid VT before any recovery or VT switch is attempted.
DRM hotplug events can occur during boot. The watcher and helper verify the current UID, an active Wayland session on seat0, and a valid VT. The helper rechecks the session after waiting and immediately before switching. IPC must identify an available configured Hyprland output. This check is not atomic with the VT switch; use one local Hyprland session per user.
## Reporting security issues
If this repository is published publicly, report security-sensitive findings privately through the repository owner's preferred security contact rather than opening a public issue containing exploitable details.
## Runtime and configuration
Runtime state requires a private, user-owned mode-0700 XDG_RUNTIME_DIR; there is
no shared /tmp fallback. Configuration is trusted Bash executed as the user.
Never run the installer or recovery scripts as root or allow untrusted users to
edit the scripts/config. Only chvt belongs in sudoers.
## Best-effort recovery
The helper checks both VT permissions and attempts to return on catchable signals
or failures. SIGKILL, power loss and persistent kernel/sudo failures can still
leave another VT active. The temporary VT can briefly expose another console.
+1
View File
@@ -0,0 +1 @@
0.2.0
+26 -76
View File
@@ -1,81 +1,31 @@
# Architecture
## Problem
The workaround resets the display below the compositor by briefly leaving the
active graphical VT. It is intended for one local Hyprland session on `seat0`.
A monitor shared between multiple physical HDMI inputs can keep its EDID/output state visible to Linux even while the monitor is displaying another source. When switching back, Hyprland may report the output as completely healthy while the physical monitor remains black or reports no source.
1. A systemd user service runs `udevadm monitor --kernel --property
--subsystem-match=drm`. The watcher owns a per-user lock.
2. Each `HOTPLUG=1` line causes a loginctl lookup for the current UID, Wayland,
active state, seat0 and a valid VT. A known different desktop is excluded.
3. Events inside the cooldown are ignored. The clock is Bash's elapsed seconds;
timestamps are not read from shared files or affected by wall-clock changes.
4. The synchronous recovery helper locks, independently identifies the active
session, waits for HDMI settling, checks the Hyprland IPC output and refreshes
DPMS/focus. The instance signature comes from the Hyprland startup hook.
5. Before switching, the helper rechecks session identity and VT and checks sudo
permission for both directions. It rechecks session identity after those calls.
6. The helper sets an EXIT/signal trap, switches to VT2 (when graphical VT=1) or
VT1 (otherwise), and returns to the detected graphical VT.
7. Failed returns and catchable signals cause an emergency return attempt. Success
is logged only after the final DPMS/focus refresh succeeds.
8. The watcher logs the helper's actual exit status and starts the cooldown at
completion. Unexpected event-stream EOF always returns nonzero to systemd.
Typical Hyprland state in the broken condition:
Shared validation and session detection live in `lib/common.sh`. Runtime state
is restricted to a user-owned mode-0700 `XDG_RUNTIME_DIR/revive-monitor`. Root is
used only for `chvt`; user scripts are never invoked through sudo.
```text
focused: yes
dpmsStatus: 1
disabled: false
active workspace: 1
```
Commands such as `hyprctl reload`, `hyprctl dispatch dpms on`, and `hyprctl dispatch focusmonitor` may all return `ok` without restoring the image.
## Discovery
Listening to Hyprland's event socket did not provide a reliable monitor add/remove event for the physical input switch.
The kernel DRM subsystem did, however, emit:
```text
SUBSYSTEM=drm
HOTPLUG=1
```
via:
```bash
udevadm monitor --kernel --property --subsystem-match=drm
```
A manual VT switch restored the image, indicating that the required reset happens below the compositor level.
## Final design
The long-running watcher consumes kernel DRM events. For every `HOTPLUG=1` event it:
1. checks `systemd-logind` for an active Wayland session belonging to the current user;
2. ignores the event if no such session exists;
3. dynamically determines that session's VT;
4. applies debounce protection;
5. calls the recovery script with the detected VT.
The recovery script:
1. uses `flock` to prevent overlap;
2. waits for the HDMI handshake to settle;
3. asks Hyprland to enable/focus the output;
4. uses `chvt` to move to another VT;
5. returns to the dynamically detected graphical VT;
6. refreshes Hyprland DPMS/focus once more.
## Why the Wayland-session guard is critical
GPU initialization during boot can generate DRM hotplug events. If the recovery script blindly executes a hard-coded `chvt 2` followed by `chvt 1`, the machine can end up showing tty1 even though the display manager is running on tty2.
The current implementation prevents this by requiring:
```text
Name=<current user>
Type=wayland
Active=yes
VTNr>0
```
before performing any recovery.
## Privilege boundary
The watcher and recovery script run as the normal user. Only `chvt` needs elevated privileges.
The recommended sudoers rule limits passwordless sudo to:
```text
/usr/bin/chvt
```
The script always calls it through `sudo -n`, which fails instead of prompting for a password.
The session check is not atomic with the kernel VT switch. The temporary VT can
contain another console, and the workaround can briefly expose it. Multi-seat
setups, multiple concurrent desktops for one user and hardware that emits no DRM
event are outside the supported design. See README for cooldown tradeoffs.
+44
View File
@@ -0,0 +1,44 @@
# Code-Review – 23.09.2026
Geprüft wurde der ursprüngliche Stand `73e727e` (Version 0.1.0). Die folgenden
Befunde wurden für Version 0.2.0 behoben. Es gab noch keine vorhandene Git-Historie;
der gelieferte Stand wurde deshalb vor Änderungen als eigener Commit gesichert.
| Priorität | Befund im Ausgangscode | Auswirkung | Korrektur |
| --- | --- | --- | --- |
| Hoch | `revive-monitor-fix.sh`: kein EXIT-/Signal-Trap nach dem ersten `chvt` | Abbruch oder gescheiterter Rückwechsel kann den Benutzer auf einer anderen Konsole lassen. | Rückkehr-Trap, Prüfung beider sudo-Berechtigungen, Zeitlimits und ein Notfall-Rückkehrversuch. |
| Hoch | Nur der Watcher prüft eine aktive Wayland-Sitzung, danach mehrere Sekunden Wartezeit | Bei Abmeldung oder Sitzungswechsel kann die Recovery eine inzwischen fremde VT aktivieren; direkte Helper-Aufrufe umgehen den Schutz. | Eigenständige Helper-Prüfung, UID/seat0/VT-Abgleich und erneute Prüfung unmittelbar vor dem Wechsel. |
| Hoch | systemd erhält keine Hyprland-Instanz; `hyprctl`-Fehler werden ignoriert | Der Dienst kann auf die falsche/keine Instanz zugreifen und trotzdem VTs wechseln bzw. Erfolg melden. | Dokumentierter Login-Hook, Umgebungsimport im Installer, IPC-/Monitorprüfung und propagierte Fehler. |
| Mittel | `/tmp/revive-monitor` als gemeinsamer Fallback; Fehler beim Lock-Öffnen ungeprüft | Benutzerübergreifende Kollisionen, Symlink-Ziele und unwirksame Sperren möglich. | Nur privates, eigenes Runtime-Verzeichnis mit Modus 0700; kein `/tmp`-Fallback; Lockfehler führen zum Abbruch. |
| Mittel | Watcher-Pipeline ohne `pipefail`, EOF ergibt reguläres Ende | `udevadm` kann ausfallen, ohne dass `Restart=on-failure` den Dienst neu startet. | Pipeline-Status erfassen; jedes unerwartete Stream-Ende endet mit Fehlerstatus. |
| Mittel | Watcher meldet nach jedem Helper-Aufruf Erfolg; Zeit-/Monitorwerte ungeprüft | Diagnose verschleiert echte Fehler; fehlerhafte Einstellungen können Schutz- und Wartefunktionen beeinträchtigen. | Statusabhängige Logs, begrenzte Integer-Werte, erlaubte Output-Namen. |
| Mittel | Installer startet sofort; `enable --now` startet aktive Dienste bei Updates nicht neu; uneinheitliches XDG; globales `reset-failed` im Deinstaller | Aktivierung vor Konfiguration, alter Prozess trotz neuer Dateien, falscher Unit-Pfad, Löschen fremder Fehlerzustände. | Explizites `--start`, Stop vor Dateiaustausch, Restart bei Aktivierung, konsistente XDG-Pfade, Root-Verweigerung und gezieltes Zurücksetzen. |
## Validierung
- 25 bestandene Verhaltenstests mit echten Bash-Prozessen und simulierten loginctl-, hyprctl-,
udev-, sudo- und systemd-Befehlen; keine realen VT-Wechsel.
- Abgedeckt: Erfolg, Vor-Login-Schutz, fremde Benutzer/Seats/Desktops, falsche VT,
Sitzungswechsel während der Wartezeit, IPC-/Output-Fehler, fehlende Rechte,
Rückkehrfehler und Signalbehandlung, Runtime-/Symlink-Schutz, Konfiguration,
parallele Recovery, Hotplug-Bursts, Stream-Ende und Fehlerprotokollierung.
- Installation, wiederholte Installation, Update-Aktivierung und Deinstallation
unter temporärem HOME/XDG; vorhandene Konfiguration bleibt erhalten.
- Bash-Syntax und ShellCheck 0.11.0 ohne Befunde; statische systemd-Prüfung mit auf das Checkout
umgebogenem ExecStart, ohne Installation des Dienstes auf diesem Desktop.
- Release aus committed HEAD; Archivinhalt, Prüfsummen und entpackter Installer
werden separat geprüft.
## Verbleibende Grenzen
Ein Shell-Trap hilft nicht gegen SIGKILL, Stromausfall oder dauerhafte Kernel-/sudo-
Fehler. Prüfung und VT-Wechsel sind nicht atomar. Die temporäre VT kann einen
anderen Konsoleninhalt zeigen. Unterstützt ist eine lokale Hyprland-Sitzung auf
seat0; mehrere gleichzeitige Desktops desselben Benutzers sind nicht abgedeckt.
DRM-Hotplug identifiziert nicht zuverlässig den betroffenen Monitor. Der Cooldown
kann einen echten schnellen Rückwechsel unterdrücken. Das bleibt eine bewusste
Einschränkung dieses hardwareabhängigen Workarounds und ist in README beschrieben.
Ein realer Test von HDMI-Eingangswechsel, Ab-/Anmeldung und Boot-Verhalten wurde
in diesem Review nicht ausgeführt. Er bleibt nach Installation erforderlich.
+53 -103
View File
@@ -1,139 +1,89 @@
# Troubleshooting
## Check the Hyprland output name
```bash
hyprctl monitors
```
Update:
```text
~/.config/revive-monitor/config.env
```
if the output is not `HDMI-A-2`.
## Check the user service
## Status and logs
```bash
systemctl --user status revive-monitor-drm-watch.service
journalctl --user -u revive-monitor-drm-watch.service -b -f
```
## Follow logs
Both scripts write to stderr; systemd captures their messages in the unit journal.
Direct manual runs write to the terminal. There is no separate logger/syslog tag.
```bash
journalctl --user -u revive-monitor-drm-watch.service -f
```
## Missing Hyprland instance / IPC failure
For the recovery script tag:
From a Hyprland terminal, run `./scripts/install.sh --start` and add the startup
hook from README so each new login updates `HYPRLAND_INSTANCE_SIGNATURE`.
`hyprctl monitors` must list the output configured in `config.env`. A stale
signature, failed IPC call or absent output aborts recovery without switching VTs.
```bash
journalctl --user -t revive-monitor-fix -f
```
## No recovery after switching inputs
## Verify DRM hotplug events manually
Observe events without starting another watcher:
```bash
udevadm monitor --kernel --property --subsystem-match=drm
```
Switch the physical monitor input away and back. Look for:
```text
HOTPLUG=1
```
If no DRM hotplug event is emitted, this project has no reliable event to trigger from.
## Verify the active Wayland session
Look for `HOTPLUG=1`. Then inspect the session:
```bash
loginctl list-sessions
loginctl show-session SESSION_ID -p User -p Type -p Active -p Seat -p VTNr -p Desktop
```
Then:
Expected: your numeric UID, `Type=wayland`, `Active=yes`, `Seat=seat0`, a VT from
1 to 63, and either a Hyprland desktop or an empty Desktop property. The helper
also checks these properties when invoked manually.
## Invalid runtime directory
`XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700
(normally `/run/user/UID`). Do not set it to `/tmp` or run the service with sudo.
The systemd unit sets it from `%t`.
## sudo / VT return failures
Use `visudo` to verify the explicit VT permissions. Check authorization without
switching the display (replace the VTs to match your session):
```bash
for s in $(loginctl list-sessions --no-legend | awk '{print $1}'); do
echo "=== Session $s ==="
loginctl show-session "$s" -p Name -p Type -p Active -p VTNr
done
sudo -n -l /usr/bin/chvt 2
sudo -n -l /usr/bin/chvt 1
```
A normal Hyprland session should look similar to:
A denied return permission aborts before the outward switch. Actual chvt errors
are still possible; a return failure is retried once and logged. A failed emergency
return requires manually returning to the graphical VT, e.g. Ctrl+Alt+F1 for VT1.
```text
Name=your-user
VTNr=1
Type=wayland
Active=yes
```
## Debounce hides a rapid return / repeated recoveries
## Display manager appears only after manually switching TTY
`DEBOUNCE_SECONDS` is a cooldown after each attempt, not an event queue. A genuine
rapid return can be ignored along with duplicate and self-generated events.
Reduce the interval if needed; increasing it can help noisy hardware. Setting it
to zero disables cooldown and can cause a recovery loop on some GPUs.
Check the boot journal:
## Manual recovery
From a terminal inside the active Hyprland session, pass its actual VT number:
```bash
journalctl -b --no-pager | grep -E 'revive-monitor|COMMAND=/usr/bin/chvt|chvt'
~/.local/bin/revive-monitor-fix 1
```
If `chvt` runs before login, verify that you installed the current watcher script. It must ignore hotplug events when there is no active Wayland session.
This will visibly switch VTs. If the monitor needs more handshake time, increase
`HDMI_SETTLE_SECONDS` (default 5). Manual recovery still needs valid IPC, the
configured output, a private runtime directory and sudo permission.
Expected pre-login behavior:
## Updating / uninstalling
```text
No active Wayland session - DRM hotplug ignored
```
Updates preserve `config.env`. Run `./scripts/install.sh --start` inside Hyprland
to restart with the new scripts. Plain `install.sh` leaves the service stopped.
For uninstall, the systemd user manager must be reachable. Remove the startup
hook manually as documented in README.
## sudo / chvt fails
## Hardware acceptance
Test non-interactively:
```bash
sudo -n /usr/bin/chvt 2
```
If sudo asks for a password or exits with an error, verify:
```text
/etc/sudoers.d/revive-monitor-chvt
```
Always edit sudoers files with `visudo`.
## Recovery runs multiple times
Increase:
```bash
DEBOUNCE_SECONDS=30
```
in:
```text
~/.config/revive-monitor/config.env
```
For noisy hardware, `60` may be more appropriate.
## Recovery runs but the monitor still stays black
Increase:
```bash
HDMI_SETTLE_SECONDS=5
```
to `7` or `10` and test again.
Also manually verify that a VT switch actually restores the image:
```bash
sudo chvt 2
sleep 1
sudo chvt 1
```
Adjust the numbers to match the machine's graphical VT.
After installation, check a normal input change, a rapid away/back change and a
logout/login cycle. Before Hyprland login there must be no chvt invocation from
this service. Automated tests use mocks and do not replace this hardware check.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Shared by the watcher and recovery helper. Config is trusted user-owned Bash.
log() {
printf '%s: %s\n' "$LOGTAG" "$*" >&2
}
# These settings are consumed by scripts sourcing this library and via ${!name}.
# shellcheck disable=SC2034
load_config() {
MONITOR=HDMI-A-2
DEBOUNCE_SECONDS=30
HDMI_SETTLE_SECONDS=5
VT_SWITCH_DELAY_SECONDS=1
VT_RETURN_SETTLE_SECONDS=2
local config="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env"
if [[ -r "$config" ]]; then
# shellcheck disable=SC1090
source "$config" || return 1
fi
if [[ ! "$MONITOR" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log 'Invalid MONITOR: expected an output name such as HDMI-A-2'
return 1
fi
local name value limit
for name in DEBOUNCE_SECONDS HDMI_SETTLE_SECONDS VT_SWITCH_DELAY_SECONDS VT_RETURN_SETTLE_SECONDS; do
value="${!name}"
limit=300
[[ "$name" == DEBOUNCE_SECONDS ]] && limit=3600
if [[ ! "$value" =~ ^[0-9]{1,4}$ ]] || (( 10#$value > limit )); then
log "Invalid $name: expected an integer between 0 and $limit"
return 1
fi
printf -v "$name" '%d' "$((10#$value))"
done
}
require_commands() {
local command_name
for command_name in "$@"; do
if ! command -v "$command_name" >/dev/null 2>&1; then
log "Required command not found: $command_name"
return 1
fi
done
}
prepare_runtime() {
# No shared /tmp fallback: locks must live in a private per-user directory.
local runtime="${XDG_RUNTIME_DIR:-}"
if [[ "$runtime" != /* || ! -d "$runtime" || -L "$runtime" || ! -O "$runtime" ]] ||
[[ "$(stat -c %a -- "$runtime" 2>/dev/null)" != 700 ]]; then
log 'XDG_RUNTIME_DIR must be an existing, user-owned directory with mode 0700'
return 1
fi
STATE_DIR="$runtime/revive-monitor"
if [[ -L "$STATE_DIR" ]]; then
log 'Refusing a symlink as runtime state directory'
return 1
fi
(umask 077; mkdir -p -- "$STATE_DIR") || return 1
if [[ ! -O "$STATE_DIR" || "$(stat -c %a -- "$STATE_DIR")" != 700 ]]; then
log 'Runtime state directory must be user-owned with mode 0700'
return 1
fi
}
get_active_wayland_session() {
local sessions sid rest properties key value uid type active vt seat desktop
sessions="$(timeout 5 loginctl list-sessions --no-legend --no-pager 2>/dev/null)" || return 1
while read -r sid rest; do
[[ -n "$sid" ]] || continue
properties="$(timeout 5 loginctl show-session "$sid" -p User -p Type -p Active -p VTNr -p Seat -p Desktop 2>/dev/null)" || continue
uid='' type='' active='' vt='' seat='' desktop=''
while IFS='=' read -r key value; do
case "$key" in
User) uid="$value" ;;
Type) type="$value" ;;
Active) active="$value" ;;
VTNr) vt="$value" ;;
Seat) seat="$value" ;;
Desktop) desktop="${value,,}" ;;
esac
done <<< "$properties"
[[ "$uid" == "$UID" && "$type" == wayland && "$active" == yes && "$seat" == seat0 ]] || continue
[[ -z "$desktop" || "$desktop" == *hyprland* ]] || continue
if [[ "$vt" =~ ^[1-9][0-9]?$ ]] && (( vt <= 63 )); then
printf '%s:%s\n' "$sid" "$vt"
return 0
fi
done <<< "$sessions"
return 1
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Build only committed sources; generated artifacts never include local secrets.
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
if ! git diff --quiet || ! git diff --cached --quiet; then
printf 'Commit tracked changes before building a release.\n' >&2
exit 1
fi
VERSION="$(git show HEAD:VERSION)"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'Invalid VERSION\n' >&2; exit 1; }
NAME="hyprland-hdmi-revive-$VERSION"
mkdir -p dist
git archive --format=tar --prefix="$NAME/" HEAD | gzip -n > "dist/$NAME.tar.gz"
git archive --format=zip --prefix="$NAME/" HEAD > "dist/$NAME.zip"
base64 -w 64 "dist/$NAME.zip" > "dist/$NAME.zip.b64"
(
cd dist
sha256sum "$NAME.tar.gz" "$NAME.zip" "$NAME.zip.b64" > SHA256SUMS
sha256sum -c SHA256SUMS
)
printf 'Built %s from commit %s\n' "$NAME" "$(git rev-parse HEAD)"
+53 -16
View File
@@ -1,35 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
START_SERVICE=0
case "${1:-}" in
'') ;;
--start) START_SERVICE=1 ;;
--help) printf 'Usage: %s [--start]\nInstall for the current user; --start also enables and restarts the service.\n' "$0"; exit 0 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 2 ;;
esac
(( $# <= 1 )) || { printf 'Too many arguments\n' >&2; exit 2; }
if (( EUID == 0 )); then
printf 'Run this installer as the desktop user, without sudo.\n' >&2
exit 1
fi
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_DIR="$HOME/.local/bin"
SYSTEMD_DIR="$HOME/.config/systemd/user"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor"
mkdir -p "$BIN_DIR" "$SYSTEMD_DIR" "$CONFIG_DIR"
LIB_DIR="$HOME/.local/lib/hyprland-hdmi-revive"
CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
[[ "$CONFIG_HOME" == /* ]] || { printf 'XDG_CONFIG_HOME must be absolute\n' >&2; exit 1; }
SYSTEMD_DIR="$CONFIG_HOME/systemd/user"
CONFIG_DIR="$CONFIG_HOME/revive-monitor"
LOGTAG=revive-monitor-install
# shellcheck source=lib/common.sh
source "$REPO_ROOT/lib/common.sh"
require_commands systemctl udevadm stdbuf timeout loginctl hyprctl jq flock sudo stat install || exit 1
[[ -x /usr/bin/chvt ]] || { printf 'Missing /usr/bin/chvt (Arch package: kbd)\n' >&2; exit 1; }
# Validate existing config before overwriting any installed files.
load_config || exit 1
if (( START_SERVICE )); then
[[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]] || {
printf 'Use --start from a terminal inside Hyprland.\n' >&2; exit 1;
}
fi
# Avoid replacing Bash files while a running watcher/recovery is reading them.
if systemctl --user is-active --quiet revive-monitor-drm-watch.service; then
systemctl --user stop revive-monitor-drm-watch.service
fi
mkdir -p -- "$BIN_DIR" "$LIB_DIR" "$SYSTEMD_DIR" "$CONFIG_DIR"
install -m 0755 "$REPO_ROOT/scripts/revive-monitor-fix.sh" "$BIN_DIR/revive-monitor-fix"
install -m 0755 "$REPO_ROOT/scripts/revive-monitor-drm-watch.sh" "$BIN_DIR/revive-monitor-drm-watch"
install -m 0644 "$REPO_ROOT/lib/common.sh" "$LIB_DIR/common.sh"
install -m 0644 "$REPO_ROOT/systemd/user/revive-monitor-drm-watch.service" "$SYSTEMD_DIR/revive-monitor-drm-watch.service"
if [[ ! -e "$CONFIG_DIR/config.env" ]]; then
install -m 0644 "$REPO_ROOT/config/revive-monitor.env.example" "$CONFIG_DIR/config.env"
install -m 0600 "$REPO_ROOT/config/revive-monitor.env.example" "$CONFIG_DIR/config.env"
printf 'Created %s\n' "$CONFIG_DIR/config.env"
printf 'Edit MONITOR before using the service if your output is not HDMI-A-2.\n'
else
printf 'Keeping existing config: %s\n' "$CONFIG_DIR/config.env"
fi
if (( START_SERVICE )); then
systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE
# Keep the service and installer on the same configuration root.
export XDG_CONFIG_HOME="$CONFIG_HOME"
systemctl --user import-environment XDG_CONFIG_HOME
systemctl --user daemon-reload
systemctl --user enable --now revive-monitor-drm-watch.service
systemctl --user enable revive-monitor-drm-watch.service
systemctl --user restart revive-monitor-drm-watch.service
systemctl --user is-active --quiet revive-monitor-drm-watch.service
else
printf 'Files installed. Activation is explicit; follow README.md before starting.\n'
fi
cat <<'MSG'
Installed successfully.
Next steps:
1. Verify ~/.config/revive-monitor/config.env
2. Configure /etc/sudoers.d/revive-monitor-chvt using one of the templates in sudoers/
3. Test with: systemctl --user status revive-monitor-drm-watch.service
4. Watch logs: journalctl --user -u revive-monitor-drm-watch.service -f
1. Set MONITOR in ${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env
2. Configure chvt permission with visudo using the sudoers/ templates.
3. Add the Hyprland environment/start hook documented in README.md.
4. Run ./scripts/install.sh --start from Hyprland to activate or update.
5. Check: journalctl --user -u revive-monitor-drm-watch.service -b
MSG
+50 -101
View File
@@ -1,115 +1,64 @@
#!/usr/bin/env bash
set -u
set -uo pipefail
LOGTAG="revive-monitor-drm-watch"
CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env"
FIX_SCRIPT="${REVIVE_MONITOR_FIX_SCRIPT:-$HOME/.local/bin/revive-monitor-fix}"
# Defaults. Override in ~/.config/revive-monitor/config.env
DEBOUNCE_SECONDS="30"
if [[ -r "$CONFIG_FILE" ]]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE"
LOGTAG=revive-monitor-drm-watch
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
COMMON="$SCRIPT_DIR/../lib/hyprland-hdmi-revive/common.sh"
[[ -f "$COMMON" ]] || COMMON="$SCRIPT_DIR/../lib/common.sh"
# shellcheck source=lib/common.sh
source "$COMMON" || exit 1
load_config || exit 1
require_commands udevadm stdbuf timeout loginctl flock stat || exit 1
prepare_runtime || exit 1
if [[ -n "${REVIVE_MONITOR_FIX_SCRIPT:-}" ]]; then
FIX_SCRIPT="$REVIVE_MONITOR_FIX_SCRIPT"
else
FIX_SCRIPT="$SCRIPT_DIR/revive-monitor-fix"
[[ -e "$FIX_SCRIPT" ]] || FIX_SCRIPT="$SCRIPT_DIR/revive-monitor-fix.sh"
fi
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/revive-monitor"
LAST_RUN_FILE="$STATE_DIR/last-drm-hotplug-run"
mkdir -p "$STATE_DIR"
log() {
logger -t "$LOGTAG" -- "$*"
}
get_active_wayland_session() {
local sid
local name
local type
local active
local vt
while read -r sid; do
[[ -n "$sid" ]] || continue
name="$(loginctl show-session "$sid" -p Name --value 2>/dev/null)"
[[ "$name" == "$USER" ]] || continue
type="$(loginctl show-session "$sid" -p Type --value 2>/dev/null)"
[[ "$type" == "wayland" ]] || continue
active="$(loginctl show-session "$sid" -p Active --value 2>/dev/null)"
[[ "$active" == "yes" ]] || continue
vt="$(loginctl show-session "$sid" -p VTNr --value 2>/dev/null)"
if [[ "$vt" =~ ^[1-9][0-9]*$ ]]; then
printf '%s:%s\n' "$sid" "$vt"
return 0
fi
done < <(
loginctl list-sessions --no-legend --no-pager 2>/dev/null | awk '{print $1}'
)
return 1
}
run_recovery() {
local session_info
local session_id
local graphical_vt
local now
local last="0"
local diff
if ! session_info="$(get_active_wayland_session)"; then
log "No active Wayland session - DRM hotplug ignored"
return 0
fi
session_id="${session_info%%:*}"
graphical_vt="${session_info##*:}"
now="$(date +%s)"
if [[ -r "$LAST_RUN_FILE" ]]; then
last="$(cat "$LAST_RUN_FILE" 2>/dev/null || printf '0')"
fi
[[ "$last" =~ ^[0-9]+$ ]] || last="0"
diff=$((now - last))
if (( diff < DEBOUNCE_SECONDS )); then
log "DRM hotplug ignored because of debounce (${diff}s)"
return 0
fi
printf '%s\n' "$now" > "$LAST_RUN_FILE"
log "DRM hotplug detected - Wayland session $session_id on VT $graphical_vt"
"$FIX_SCRIPT" "$graphical_vt"
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 "Recovery script is not executable: $FIX_SCRIPT"
exit 1
fi
exec 8>"$STATE_DIR/watcher.lock" || exit 1
if ! flock -n 8; then
log 'Another watcher is running or the watcher lock is unavailable'
exit 1
fi
log "Listening for DRM hotplug events"
run_recovery() {
local session_info now status
session_info="$(get_active_wayland_session)" || {
log 'No active Wayland session - DRM hotplug ignored'
return 0
}
now=$SECONDS
if (( last_run >= 0 && now - last_run < DEBOUNCE_SECONDS )); then
log 'DRM hotplug ignored because of debounce'
return 0
fi
log "DRM hotplug detected - Wayland session ${session_info%%:*} on VT ${session_info##*:}"
"$FIX_SCRIPT" "${session_info##*:}"
status=$?
# Measure from completion, so events queued during our own VT refresh are suppressed.
last_run=$SECONDS
if (( status != 0 )); then
log "Recovery after DRM hotplug failed (exit $status)"
else
log 'Recovery helper finished; see its log for completion or skip reason'
fi
}
log 'Listening for DRM hotplug events'
# pipefail propagates a failed producer. EOF (even exit 0) is unexpected for
# this long-running service and must also trigger Restart=on-failure.
stdbuf -oL udevadm monitor --kernel --property --subsystem-match=drm |
while IFS= read -r line; do
last_run=${last_run:--1}
case "$line" in
HOTPLUG=1)
run_recovery
;;
HOTPLUG=1) run_recovery ;;
esac
done
status=$?
log "DRM event stream ended (exit $status) - requesting service restart"
exit 1
+96 -55
View File
@@ -1,77 +1,118 @@
#!/usr/bin/env bash
set -u
set -uo pipefail
LOGTAG="revive-monitor-fix"
CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env"
# Defaults. Override in ~/.config/revive-monitor/config.env
MONITOR="HDMI-A-2"
HDMI_SETTLE_SECONDS="5"
VT_SWITCH_DELAY_SECONDS="1"
VT_RETURN_SETTLE_SECONDS="2"
if [[ -r "$CONFIG_FILE" ]]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE"
fi
LOGTAG=revive-monitor-fix
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# Source-tree and installed layouts.
COMMON="$SCRIPT_DIR/../lib/hyprland-hdmi-revive/common.sh"
[[ -f "$COMMON" ]] || COMMON="$SCRIPT_DIR/../lib/common.sh"
# shellcheck source=lib/common.sh
source "$COMMON" || exit 1
load_config || exit 1
require_commands timeout loginctl hyprctl jq sudo flock stat sleep || exit 1
prepare_runtime || exit 1
GRAPHICAL_VT="${1:-}"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/revive-monitor"
LOCK_FILE="$STATE_DIR/fix.lock"
if [[ ! "$GRAPHICAL_VT" =~ ^[1-9][0-9]?$ ]] || (( GRAPHICAL_VT > 63 )); then
log 'No valid graphical VT supplied (expected 1..63)'
exit 1
fi
mkdir -p "$STATE_DIR"
exec 9>"$STATE_DIR/fix.lock" || exit 1
flock -n -E 75 9
lock_status=$?
if (( lock_status == 75 )); then
log 'Recovery already running - skipping'
exit 0
elif (( lock_status != 0 )); then
log 'Cannot acquire recovery lock'
exit 1
fi
log() {
logger -t "$LOGTAG" -- "$*"
SESSION="$(get_active_wayland_session)" || { log 'No active Wayland session - recovery skipped'; exit 0; }
if [[ "${SESSION##*:}" != "$GRAPHICAL_VT" ]]; then
log 'Requested VT does not belong to the active Wayland session'
exit 1
fi
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
log 'Missing HYPRLAND_INSTANCE_SIGNATURE; import the Hyprland environment into systemd --user'
exit 1
fi
monitor_present() {
timeout 5 hyprctl -j monitors |
jq -e --arg name "$MONITOR" 'any(.[]; .name == $name)' >/dev/null 2>&1
}
run_quiet() {
"$@" >/dev/null 2>&1
refresh_monitor() {
timeout 5 hyprctl dispatch dpms on "$MONITOR" >/dev/null 2>&1 &&
timeout 5 hyprctl dispatch focusmonitor "$MONITOR" >/dev/null 2>&1
}
if ! [[ "$GRAPHICAL_VT" =~ ^[1-9][0-9]*$ ]]; then
log "No valid graphical VT supplied - recovery aborted"
exit 0
fi
if [[ "$GRAPHICAL_VT" -eq 1 ]]; then
TEMP_VT=2
else
TEMP_VT=1
fi
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Recovery already running - skipping"
exit 0
fi
log "Recovery started for $MONITOR on VT $GRAPHICAL_VT"
sleep "$HDMI_SETTLE_SECONDS" || exit 1
if ! monitor_present; then
log "Hyprland IPC unavailable or configured output $MONITOR absent - recovery aborted"
exit 1
fi
if ! refresh_monitor; then
log 'Hyprland refresh failed - recovery aborted'
exit 1
fi
sleep "$HDMI_SETTLE_SECONDS"
# Recheck after settling/IPC, immediately before the privileged switch.
if [[ "$(get_active_wayland_session)" != "$SESSION" ]]; then
log 'Active session changed while settling - recovery skipped'
exit 0
fi
TEMP_VT=1
[[ "$GRAPHICAL_VT" == 1 ]] && TEMP_VT=2
# Check both directions before leaving the desktop (actual sudo can still fail).
for vt in "$TEMP_VT" "$GRAPHICAL_VT"; do
if ! timeout 5 sudo -n -l /usr/bin/chvt "$vt" >/dev/null 2>&1; then
log "sudo permission check failed for VT $vt - recovery aborted"
exit 1
fi
done
run_quiet hyprctl dispatch dpms on "$MONITOR"
run_quiet hyprctl dispatch focusmonitor "$MONITOR"
sleep 1
if [[ "$(get_active_wayland_session)" != "$SESSION" ]]; then
log 'Active session changed during permission checks - recovery skipped'
exit 0
fi
NEEDS_RETURN=0
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if (( NEEDS_RETURN )); then
log "Attempting emergency return to graphical VT $GRAPHICAL_VT"
if ! timeout 5 sudo -n /usr/bin/chvt "$GRAPHICAL_VT"; then
log "Emergency return failed; manually switch to VT $GRAPHICAL_VT"
status=1
fi
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
log "VT refresh: $TEMP_VT -> $GRAPHICAL_VT"
if ! sudo -n /usr/bin/chvt "$TEMP_VT"; then
# Set before chvt to cover a signal arriving as the command completes.
NEEDS_RETURN=1
if ! timeout 5 sudo -n /usr/bin/chvt "$TEMP_VT"; then
log "Failed to switch to temporary VT $TEMP_VT"
exit 1
fi
sleep "$VT_SWITCH_DELAY_SECONDS"
if ! sudo -n /usr/bin/chvt "$GRAPHICAL_VT"; then
sleep "$VT_SWITCH_DELAY_SECONDS" || exit 1
if ! timeout 5 sudo -n /usr/bin/chvt "$GRAPHICAL_VT"; then
log "Failed to return to graphical VT $GRAPHICAL_VT"
exit 1
fi
sleep "$VT_RETURN_SETTLE_SECONDS"
run_quiet hyprctl dispatch dpms on "$MONITOR"
run_quiet hyprctl dispatch focusmonitor "$MONITOR"
NEEDS_RETURN=0
sleep "$VT_RETURN_SETTLE_SECONDS" || exit 1
if ! refresh_monitor; then
log 'Final Hyprland refresh failed'
exit 1
fi
log "Recovery completed for $MONITOR"
+19 -15
View File
@@ -1,24 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
if (( EUID == 0 )); then
printf 'Run as the desktop user, without sudo.\n' >&2
exit 1
fi
BIN_DIR="$HOME/.local/bin"
SYSTEMD_DIR="$HOME/.config/systemd/user"
systemctl --user disable --now revive-monitor-drm-watch.service 2>/dev/null || true
rm -f "$SYSTEMD_DIR/revive-monitor-drm-watch.service"
rm -f "$BIN_DIR/revive-monitor-fix"
rm -f "$BIN_DIR/revive-monitor-drm-watch"
SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
LIB_DIR="$HOME/.local/lib/hyprland-hdmi-revive"
SERVICE=revive-monitor-drm-watch.service
[[ "$SYSTEMD_DIR" == /* ]] || { printf 'XDG_CONFIG_HOME must be absolute\n' >&2; exit 1; }
systemctl --user show-environment >/dev/null
# Stop must succeed before removing a running service's files.
if systemctl --user cat "$SERVICE" >/dev/null 2>&1; then
systemctl --user disable --now "$SERVICE"
fi
rm -f -- "$SYSTEMD_DIR/$SERVICE" "$BIN_DIR/revive-monitor-fix" "$BIN_DIR/revive-monitor-drm-watch" "$LIB_DIR/common.sh"
rmdir -- "$LIB_DIR" 2>/dev/null || true
systemctl --user daemon-reload
systemctl --user reset-failed
# Never reset failure information for unrelated user services.
systemctl --user reset-failed "$SERVICE" 2>/dev/null || true
cat <<'MSG'
Removed the user service and installed scripts.
Not removed automatically:
~/.config/revive-monitor/config.env
/etc/sudoers.d/revive-monitor-chvt
Remove those manually if you no longer need them.
Preserved config.env and /etc/sudoers.d/revive-monitor-chvt.
Remove the Hyprland startup hook and sudoers entry manually if no longer needed.
MSG
@@ -6,6 +6,7 @@ Type=simple
ExecStart=%h/.local/bin/revive-monitor-drm-watch
Restart=on-failure
RestartSec=2
TimeoutStopSec=15
Environment=XDG_RUNTIME_DIR=%t
[Install]
+278
View File
@@ -0,0 +1,278 @@
"""Behavior tests using fake IPC, udev, sudo and systemd; never switch real VTs."""
import fcntl
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
MOCK = r'''#!/usr/bin/env python3
import json, os, pathlib, signal, sys, time
name = pathlib.Path(sys.argv[0]).name
args = sys.argv[1:]
state = pathlib.Path(os.environ['TEST_STATE'])
with (state / 'calls').open('a') as f:
f.write(json.dumps([name] + args) + '\n')
if name == 'loginctl':
if args[0] == 'list-sessions':
if not os.environ.get('NO_SESSION'):
print('7 1000 desktop seat0 tty1')
else:
active = 'no' if (state / 'inactive').exists() else 'yes'
print(f"User={os.environ.get('SESSION_UID', os.getuid())}\nType={os.environ.get('SESSION_TYPE', 'wayland')}\nActive={active}\nVTNr={os.environ.get('SESSION_VT', '1')}\nSeat={os.environ.get('SESSION_SEAT', 'seat0')}\nDesktop={os.environ.get('SESSION_DESKTOP', 'Hyprland')}")
elif name == 'hyprctl':
if os.environ.get('HYPR_FAIL'):
sys.exit(1)
if args == ['-j', 'monitors']:
if os.environ.get('BAD_JSON'):
print('invalid')
else:
print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}]))
else:
print('ok')
elif name == 'sleep':
if os.environ.get('CHANGE_SESSION'):
(state / 'inactive').touch()
if os.environ.get('SIGNAL_AFTER_SWITCH') and (state / 'away').exists():
os.kill(os.getppid(), signal.SIGTERM)
if os.environ.get('BLOCK_AFTER_SWITCH') and (state / 'away').exists():
(state / 'sleeping').touch()
time.sleep(30)
elif name == 'sudo':
vt = args[-1]
if '-l' in args:
if os.environ.get('CHANGE_DURING_PERMISSION_CHECK'):
(state / 'inactive').touch()
sys.exit(1 if os.environ.get('DENY_RETURN') and vt == '1' else 0)
if vt == '2':
(state / 'away').touch()
if vt == '1' and os.environ.get('RETURN_FAIL_ONCE') and not (state / 'return-tried').exists():
(state / 'return-tried').touch()
sys.exit(1)
if os.environ.get('SUDO_FAIL'):
sys.exit(1)
elif name == 'udevadm':
for _ in range(int(os.environ.get('EVENT_COUNT', '1'))):
print('KERNEL[1.0] change /devices/drm/card0 (drm)\nHOTPLUG=1\n', flush=True)
sys.exit(int(os.environ.get('UDEV_EXIT', '0')))
elif name == 'fake-fix':
sys.exit(int(os.environ.get('FIX_EXIT', '0')))
elif name == 'systemctl':
if 'is-active' in args:
sys.exit(0 if os.environ.get('SERVICE_ACTIVE') else 3)
'''
class RecoveryTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
for part in ('bin', 'runtime', 'config', 'home', 'state'):
(self.root / part).mkdir(mode=0o700)
self.env = dict(os.environ, HOME=str(self.root / 'home'),
XDG_CONFIG_HOME=str(self.root / 'config'),
XDG_RUNTIME_DIR=str(self.root / 'runtime'),
TEST_STATE=str(self.root / 'state'),
HYPRLAND_INSTANCE_SIGNATURE='test-instance',
PATH=str(self.root / 'bin') + ':' + os.environ['PATH'])
for name in ('loginctl', 'hyprctl', 'sudo', 'sleep', 'udevadm', 'fake-fix', 'systemctl'):
p = self.root / 'bin' / name
p.write_text(MOCK)
p.chmod(0o755)
self.config = self.root / 'config/revive-monitor/config.env'
self.config.parent.mkdir()
self.config.write_text('HDMI_SETTLE_SECONDS=0\nVT_SWITCH_DELAY_SECONDS=0\nVT_RETURN_SETTLE_SECONDS=0\n')
def run_script(self, name='revive-monitor-fix.sh', args=('1',), **env):
return subprocess.run(['bash', str(ROOT / 'scripts' / name), *args],
env=dict(self.env, **env), text=True, capture_output=True, timeout=15)
def calls(self, name):
p = self.root / 'state/calls'
return [a[1:] for a in map(json.loads, p.read_text().splitlines()) if a[0] == name] if p.exists() else []
def switches(self):
return [a[-1] for a in self.calls('sudo') if '-l' not in a]
def test_success(self):
r = self.run_script()
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.switches(), ['2', '1'])
self.assertIn('Recovery completed', r.stderr)
def test_invalid_vt(self):
for vt in ('', '0', '64', '01', 'word', '99999999999999999'):
with self.subTest(vt=vt):
self.assertNotEqual(self.run_script(args=(vt,)).returncode, 0)
self.assertEqual(self.switches(), [])
def test_session_guards(self):
for env in ({'NO_SESSION': '1'}, {'SESSION_UID': '99999'}, {'SESSION_TYPE': 'x11'},
{'SESSION_SEAT': 'seat1'}, {'SESSION_DESKTOP': 'GNOME'}):
with self.subTest(env=env):
self.assertEqual(self.run_script(**env).returncode, 0)
self.assertEqual(self.switches(), [])
def test_wrong_vt(self):
self.assertNotEqual(self.run_script(args=('2',)).returncode, 0)
self.assertEqual(self.switches(), [])
def test_session_changes_during_settle(self):
self.assertEqual(self.run_script(CHANGE_SESSION='1').returncode, 0)
self.assertEqual(self.switches(), [])
def test_ipc_and_output_guards(self):
for env in ({'HYPRLAND_INSTANCE_SIGNATURE': ''}, {'HYPR_FAIL': '1'},
{'OUTPUT': 'DP-1'}, {'BAD_JSON': '1'}):
with self.subTest(env=env):
self.assertNotEqual(self.run_script(**env).returncode, 0)
self.assertEqual(self.switches(), [])
def test_permission_checked_before_leaving(self):
self.assertNotEqual(self.run_script(DENY_RETURN='1').returncode, 0)
self.assertEqual(self.switches(), [])
def test_return_failure_retried_and_reported(self):
r = self.run_script(RETURN_FAIL_ONCE='1')
self.assertNotEqual(r.returncode, 0)
self.assertEqual(self.switches(), ['2', '1', '1'])
self.assertNotIn('Recovery completed', r.stderr)
def test_termination_returns_to_desktop(self):
r = self.run_script(SIGNAL_AFTER_SWITCH='1')
self.assertEqual(r.returncode, 143, r.stderr)
self.assertEqual(self.switches(), ['2', '1'])
def test_process_group_termination_returns_to_desktop(self):
proc = subprocess.Popen(['bash', str(ROOT / 'scripts/revive-monitor-fix.sh'), '1'],
env=dict(self.env, BLOCK_AFTER_SWITCH='1'),
text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True)
try:
deadline = time.monotonic() + 5
while not (self.root / 'state/sleeping').exists() and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue((self.root / 'state/sleeping').exists())
os.killpg(proc.pid, signal.SIGTERM)
_, stderr = proc.communicate(timeout=10)
self.assertEqual(proc.returncode, 143, stderr)
self.assertEqual(self.switches(), ['2', '1'])
finally:
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
def test_session_changes_during_permission_check(self):
self.assertEqual(self.run_script(CHANGE_DURING_PERMISSION_CHECK='1').returncode, 0)
self.assertEqual(self.switches(), [])
def test_lock_open_failure_aborts(self):
state = self.root / 'runtime/revive-monitor'
state.mkdir(mode=0o700)
(state / 'fix.lock').mkdir()
self.assertNotEqual(self.run_script().returncode, 0)
self.assertEqual(self.switches(), [])
def test_sudo_failure_not_success(self):
r = self.run_script(SUDO_FAIL='1')
self.assertNotEqual(r.returncode, 0)
self.assertIn('Emergency return failed', r.stderr)
self.assertNotIn('Recovery completed', r.stderr)
def test_no_shared_tmp_fallback(self):
self.assertNotEqual(self.run_script(XDG_RUNTIME_DIR='').returncode, 0)
self.assertEqual(self.switches(), [])
def test_unsafe_runtime_rejected(self):
(self.root / 'runtime').chmod(0o755)
self.assertNotEqual(self.run_script().returncode, 0)
def test_symlink_state_rejected(self):
(self.root / 'runtime/revive-monitor').symlink_to(self.root / 'state')
self.assertNotEqual(self.run_script().returncode, 0)
self.assertFalse((self.root / 'state/fix.lock').exists())
def test_invalid_config(self):
for entry in ('DEBOUNCE_SECONDS=-1', 'HDMI_SETTLE_SECONDS=9999',
'VT_SWITCH_DELAY_SECONDS=abc', 'MONITOR="HDMI-A-2,garbage"'):
self.config.write_text(entry + '\n')
with self.subTest(entry=entry):
self.assertNotEqual(self.run_script().returncode, 0)
self.assertEqual(self.switches(), [])
def test_leading_zero_timing_is_decimal(self):
self.config.write_text('HDMI_SETTLE_SECONDS=08\n')
self.assertEqual(self.run_script().returncode, 0)
self.assertEqual(self.calls('sleep')[0], ['8'])
def test_lock_blocks_overlapping_recovery(self):
state = self.root / 'runtime/revive-monitor'
state.mkdir(mode=0o700)
with (state / 'fix.lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
r = self.run_script()
self.assertEqual(r.returncode, 0)
self.assertIn('already running', r.stderr)
self.assertEqual(self.switches(), [])
def watcher(self, **env):
return self.run_script('revive-monitor-drm-watch.sh', (),
REVIVE_MONITOR_FIX_SCRIPT=str(self.root / 'bin/fake-fix'), **env)
def test_watcher_eof_requests_restart(self):
for status in ('0', '7'):
with self.subTest(status=status):
r = self.watcher(UDEV_EXIT=status, EVENT_COUNT='0')
self.assertEqual(r.returncode, 1)
self.assertIn(f'event stream ended (exit {status})', r.stderr)
def test_watcher_debounces_burst(self):
r = self.watcher(EVENT_COUNT='4')
self.assertEqual(r.returncode, 1) # Mock stream ends.
self.assertEqual(self.calls('fake-fix'), [['1']])
def test_watcher_reports_recovery_failure(self):
r = self.watcher(FIX_EXIT='17')
self.assertIn('failed (exit 17)', r.stderr)
self.assertNotIn('helper finished', r.stderr)
def test_watcher_ignores_prelogin_events(self):
self.watcher(NO_SESSION='1')
self.assertEqual(self.calls('fake-fix'), [])
def test_watcher_missing_helper_aborts(self):
r = self.run_script('revive-monitor-drm-watch.sh', (),
REVIVE_MONITOR_FIX_SCRIPT=str(self.root / 'missing'))
self.assertEqual(r.returncode, 1)
self.assertEqual(self.calls('udevadm'), [])
@unittest.skipIf(os.geteuid() == 0, 'Installer intentionally rejects root')
def test_install_update_uninstall(self):
before = self.config.read_bytes()
for _ in range(2):
r = self.run_script('install.sh', ())
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.config.read_bytes(), before)
self.assertTrue((self.root / 'config/systemd/user/revive-monitor-drm-watch.service').exists())
helper = self.root / 'home/.local/bin/revive-monitor-fix'
self.assertTrue(os.access(helper, os.X_OK))
r = subprocess.run([str(helper), '1'], env=self.env, text=True, capture_output=True, timeout=15)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertNotIn('enable', str(self.calls('systemctl')))
r = self.run_script('install.sh', ('--start',), SERVICE_ACTIVE='1')
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn(['--user', 'restart', 'revive-monitor-drm-watch.service'], self.calls('systemctl'))
r = self.run_script('uninstall.sh', ())
self.assertEqual(r.returncode, 0, r.stderr)
self.assertFalse(helper.exists())
self.assertEqual(self.config.read_bytes(), before)
self.assertNotIn(['--user', 'reset-failed'], self.calls('systemctl'))
if __name__ == '__main__':
unittest.main()