From 34bae045c37ae42a16cb9c92747423059658164b Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Wed, 23 Sep 2026 15:46:21 +0200 Subject: [PATCH] Fix HDMI recovery dispatches for Lua Hyprland sessions --- CHANGELOG.md | 11 ++++++ README.md | 25 +++++++++--- VERSION | 2 +- docs/architecture.md | 5 ++- docs/troubleshooting.md | 16 ++++++++ scripts/revive-monitor-fix.sh | 40 ++++++++++++++++++- tests/test_recovery.py | 74 ++++++++++++++++++++++++++++++++++- 7 files changed, 163 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba96bd..ef9390a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented here. +## 0.2.1 - 2026-09-23 + +### Fixed + +- Detect the active Hyprland config provider and use Lua DPMS/focus dispatchers + for Lua sessions. Legacy hyprlang sessions retain their original commands. +- Log the failing refresh operation, exit status and Hyprland error response; + reject error responses even when hyprctl exits successfully. +- Add regression coverage for Lua, older Hyprland without `status`, and refresh + failures before and after the VT switch. Document the Lua startup hook. + ## 0.2.0 - 2026-09-23 ### Fixed diff --git a/README.md b/README.md index 5d1e7f2..44f77ce 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 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. -**Version 0.2.0.** The initial implementation and the review fixes are separate +**Version 0.2.1.** The initial implementation and the review fixes are separate commits. See [the code review](docs/code-review.md) and [changelog](CHANGELOG.md). ## Requirements @@ -24,14 +24,14 @@ recovery. Hyprland must still expose the configured monitor over its IPC socket. ## Install from the package -Download `hyprland-hdmi-revive-0.2.0.tar.gz` and `SHA256SUMS` from the +Download `hyprland-hdmi-revive-0.2.1.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 sha256sum --ignore-missing -c SHA256SUMS -tar -xzf hyprland-hdmi-revive-0.2.0.tar.gz -cd hyprland-hdmi-revive-0.2.0 +tar -xzf hyprland-hdmi-revive-0.2.1.tar.gz +cd hyprland-hdmi-revive-0.2.1 ./scripts/install.sh ``` @@ -92,7 +92,19 @@ uses `sudo -n` with command timeouts. A return failure gets one emergency retry. ### 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 +environment. For a Lua configuration, add this hook to your loaded Lua config: + +```lua +hl.on("hyprland.start", function() + hl.exec_cmd([[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 + ']]) +end) +``` + +For a legacy hyprlang configuration, use this line instead (commonly in `~/.config/hypr/hyprland.conf`): ```ini @@ -124,6 +136,9 @@ and IPC guards prevent pre-login VT switching. A reported non-Hyprland desktop is excluded. - The helper independently verifies the requested VT and rechecks the session after settling and permission checks. +- DPMS/focus commands follow the active config provider reported by + `hyprctl -j status`: Lua or legacy hyprlang. Older versions without this query + use legacy commands. A refresh error is logged before aborting recovery. - 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 diff --git a/VERSION b/VERSION index 0ea3a94..0c62199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.2.1 diff --git a/docs/architecture.md b/docs/architecture.md index ac4af22..4798281 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,10 @@ active graphical VT. It is intended for one local Hyprland session on `seat0`. 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. + DPMS/focus. `hyprctl -j status` selects Lua or legacy hyprlang dispatch syntax; + versions without the query use legacy commands. Dispatches must return `ok` + with exit status zero, otherwise their error is logged and recovery aborts. + 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0c4e0f1..e5c4dbf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -36,6 +36,22 @@ Expected: your numeric UID, `Type=wayland`, `Active=yes`, `Seat=seat0`, a VT fro 1 to 63, and either a Hyprland desktop or an empty Desktop property. The helper also checks these properties when invoked manually. +## Hyprland refresh failed / Lua syntax error + +Version 0.2.0 sent legacy dispatch commands even to Lua-configured sessions. +On Hyprland 0.56.2 this produced `')' expected near 'on'` and aborted recovery +before the VT switch. Update to 0.2.1 with `./scripts/install.sh --start`. + +The helper now reads `hyprctl -j status` to choose the active config provider. +For Lua it sends `hl.dsp.dpms({ action = "on", monitor = "HDMI-A-2" })` and +`hl.dsp.focus({ monitor = "HDMI-A-2" })` through `hyprctl dispatch`. The configured +output replaces `HDMI-A-2`. Legacy hyprlang and older versions remain supported. +See the official [hyprctl documentation](https://wiki.hypr.land/configuring/core/advanced-configuration/using-hyprctl/) +and [dispatchers](https://wiki.hypr.land/configuring/core/dispatchers/). + +The journal identifies `DPMS` or `focus`, the command's exit status and its error +response. A zero exit status with an error response is also treated as failure. + ## Invalid runtime directory `XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700 diff --git a/scripts/revive-monitor-fix.sh b/scripts/revive-monitor-fix.sh index 45d6bf8..9cd653f 100755 --- a/scripts/revive-monitor-fix.sh +++ b/scripts/revive-monitor-fix.sh @@ -44,9 +44,44 @@ monitor_present() { jq -e --arg name "$MONITOR" 'any(.[]; .name == $name)' >/dev/null 2>&1 } +select_dispatch_syntax() { + local status provider + DISPATCH_SYNTAX=hyprlang + # The active config provider, not the version, determines dispatch syntax. + # Older Hyprland versions do not expose `status`; their dispatch is legacy. + if status="$(timeout 5 hyprctl -j status 2>/dev/null)" && + provider="$(jq -er '.configProvider | select(type == "string")' <<< "$status" 2>/dev/null)"; then + case "$provider" in + lua|hyprlang) DISPATCH_SYNTAX="$provider" ;; + *) log "Unsupported Hyprland config provider: $provider"; return 1 ;; + esac + else + log 'Hyprland config provider unavailable; trying legacy dispatch syntax' + fi + log "Using $DISPATCH_SYNTAX dispatch syntax" +} + +dispatch_checked() { + local action="$1" reply status + shift + reply="$(timeout 5 hyprctl dispatch "$@" 2>&1)" + status=$? + # Some hyprctl versions return exit 0 even for an IPC error response. + if (( status != 0 )) || [[ "$reply" != ok ]]; then + log "Hyprland $action failed (exit $status): ${reply:-no response}" + return 1 + fi +} + refresh_monitor() { - timeout 5 hyprctl dispatch dpms on "$MONITOR" >/dev/null 2>&1 && - timeout 5 hyprctl dispatch focusmonitor "$MONITOR" >/dev/null 2>&1 + if [[ "$DISPATCH_SYNTAX" == lua ]]; then + # MONITOR is restricted to alphanumeric characters, underscores and '-'. + dispatch_checked DPMS "hl.dsp.dpms({ action = \"on\", monitor = \"$MONITOR\" })" && + dispatch_checked focus "hl.dsp.focus({ monitor = \"$MONITOR\" })" + else + dispatch_checked DPMS dpms on "$MONITOR" && + dispatch_checked focus focusmonitor "$MONITOR" + fi } log "Recovery started for $MONITOR on VT $GRAPHICAL_VT" @@ -55,6 +90,7 @@ if ! monitor_present; then log "Hyprland IPC unavailable or configured output $MONITOR absent - recovery aborted" exit 1 fi +select_dispatch_syntax || exit 1 if ! refresh_monitor; then log 'Hyprland refresh failed - recovery aborted' exit 1 diff --git a/tests/test_recovery.py b/tests/test_recovery.py index 0d3ac42..27c7339 100644 --- a/tests/test_recovery.py +++ b/tests/test_recovery.py @@ -32,8 +32,35 @@ elif name == 'hyprctl': print('invalid') else: print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}])) - else: + elif args == ['-j', 'status']: + if os.environ.get('OLD_HYPRLAND'): + print('unknown request') + sys.exit(int(os.environ.get('STATUS_EXIT', '0'))) + print(json.dumps({'configProvider': os.environ.get('CONFIG_PROVIDER', 'hyprlang')})) + elif args[0] == 'dispatch': + monitor = os.environ.get('OUTPUT', 'HDMI-A-2') + if os.environ.get('CONFIG_PROVIDER') == 'lua': + commands = { + ('dispatch', f'hl.dsp.dpms({{ action = "on", monitor = "{monitor}" }})'): 'DPMS', + ('dispatch', f'hl.dsp.focus({{ monitor = "{monitor}" }})'): 'focus', + } + else: + commands = { + ('dispatch', 'dpms', 'on', monitor): 'DPMS', + ('dispatch', 'focusmonitor', monitor): 'focus', + } + action = commands.get(tuple(args)) + if action is None: + print('error: invalid dispatch syntax') + sys.exit(7) + if os.environ.get('DISPATCH_FAIL') == action and ( + not os.environ.get('FINAL_REFRESH_FAIL') or (state / 'away').exists()): + print(f'error: {action} rejected for {monitor}') + sys.exit(int(os.environ.get('DISPATCH_EXIT', '7'))) print('ok') + else: + print('unexpected hyprctl call', file=sys.stderr) + sys.exit(99) elif name == 'sleep': if os.environ.get('CHANGE_SESSION'): (state / 'inactive').touch() @@ -105,6 +132,51 @@ class RecoveryTests(unittest.TestCase): self.assertEqual(self.switches(), ['2', '1']) self.assertIn('Recovery completed', r.stderr) + def test_lua_session_refreshes_before_and_after_vt_switch(self): + r = self.run_script(CONFIG_PROVIDER='lua') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(self.switches(), ['2', '1']) + self.assertIn('Using lua dispatch syntax', r.stderr) + dispatches = [a for a in self.calls('hyprctl') if a[0] == 'dispatch'] + self.assertEqual(dispatches, [ + ['dispatch', 'hl.dsp.dpms({ action = "on", monitor = "HDMI-A-2" })'], + ['dispatch', 'hl.dsp.focus({ monitor = "HDMI-A-2" })'], + ] * 2) + + def test_legacy_without_status_command(self): + for code in ('0', '1'): + with self.subTest(code=code): + r = self.run_script(OLD_HYPRLAND='1', STATUS_EXIT=code) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn('Using hyprlang dispatch syntax', r.stderr) + + def test_unknown_config_provider_aborts(self): + r = self.run_script(CONFIG_PROVIDER='unknown') + self.assertNotEqual(r.returncode, 0) + self.assertIn('Unsupported Hyprland config provider', r.stderr) + self.assertEqual(self.switches(), []) + + def test_dispatch_errors_reported_before_switch(self): + for provider in ('lua', 'hyprlang'): + for action in ('DPMS', 'focus'): + for code in ('0', '7', '124'): + with self.subTest(provider=provider, action=action, code=code): + r = self.run_script(CONFIG_PROVIDER=provider, + DISPATCH_FAIL=action, DISPATCH_EXIT=code) + self.assertNotEqual(r.returncode, 0) + self.assertIn(f'Hyprland {action} failed (exit {code})', r.stderr) + self.assertIn(f'error: {action} rejected for HDMI-A-2', r.stderr) + self.assertNotIn('Recovery completed', r.stderr) + self.assertEqual(self.switches(), []) + + def test_final_lua_refresh_failure_not_reported_as_success(self): + r = self.run_script(CONFIG_PROVIDER='lua', DISPATCH_FAIL='focus', FINAL_REFRESH_FAIL='1') + self.assertNotEqual(r.returncode, 0) + self.assertEqual(self.switches(), ['2', '1']) + self.assertIn('Final Hyprland refresh failed', r.stderr) + self.assertIn('error: focus rejected for HDMI-A-2', r.stderr) + self.assertNotIn('Recovery completed', r.stderr) + def test_invalid_vt(self): for vt in ('', '0', '64', '01', 'word', '99999999999999999'): with self.subTest(vt=vt):