Fix HDMI recovery dispatches for Lua Hyprland sessions

This commit is contained in:
admin committed 2026-09-23 15:46:21 +02:00
1 parent d963224cb7
commit 34bae045c3
7 files changed
+162 -9

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
All notable changes to this project will be documented here. 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 ## 0.2.0 - 2026-09-23
### Fixed ### Fixed
+20 -5
View File
@@ -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 short switch away from the active graphical VT and back. There is no periodic
DPMS toggle or polling timer. 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). commits. See [the code review](docs/code-review.md) and [changelog](CHANGELOG.md).
## Requirements ## Requirements
@@ -24,14 +24,14 @@ recovery. Hyprland must still expose the configured monitor over its IPC socket.
## Install from the package ## 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). [Gitea release](https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive/releases).
Run as your desktop user, **without sudo**: Run as your desktop user, **without sudo**:
```bash ```bash
sha256sum --ignore-missing -c SHA256SUMS sha256sum --ignore-missing -c SHA256SUMS
tar -xzf hyprland-hdmi-revive-0.2.0.tar.gz tar -xzf hyprland-hdmi-revive-0.2.1.tar.gz
cd hyprland-hdmi-revive-0.2.0 cd hyprland-hdmi-revive-0.2.1
./scripts/install.sh ./scripts/install.sh
``` ```
@@ -92,7 +92,19 @@ uses `sudo -n` with command timeouts. A return failure gets one emergency retry.
### Connect the Hyprland session ### Connect the Hyprland session
The user service manager does not automatically inherit a terminal's Hyprland 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`): `~/.config/hypr/hyprland.conf`):
```ini ```ini
@@ -124,6 +136,9 @@ and IPC guards prevent pre-login VT switching.
A reported non-Hyprland desktop is excluded. A reported non-Hyprland desktop is excluded.
- The helper independently verifies the requested VT and rechecks the session - The helper independently verifies the requested VT and rechecks the session
after settling and permission checks. 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 - A private `XDG_RUNTIME_DIR` is required. Locks prevent duplicate watchers and
overlapping recoveries; there is no shared `/tmp` fallback. overlapping recoveries; there is no shared `/tmp` fallback.
- Missing IPC, invalid monitor names, command failures and lost event streams - Missing IPC, invalid monitor names, command failures and lost event streams
+1 -1
View File
@@ -1 +1 @@
0.2.0 0.2.1
+4 -1
View File
@@ -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. timestamps are not read from shared files or affected by wall-clock changes.
4. The synchronous recovery helper locks, independently identifies the active 4. The synchronous recovery helper locks, independently identifies the active
session, waits for HDMI settling, checks the Hyprland IPC output and refreshes 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 5. Before switching, the helper rechecks session identity and VT and checks sudo
permission for both directions. It rechecks session identity after those calls. 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 6. The helper sets an EXIT/signal trap, switches to VT2 (when graphical VT=1) or
+16
View File
@@ -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 1 to 63, and either a Hyprland desktop or an empty Desktop property. The helper
also checks these properties when invoked manually. 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 ## Invalid runtime directory
`XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700 `XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700
+38 -2
View File
@@ -44,9 +44,44 @@ monitor_present() {
jq -e --arg name "$MONITOR" 'any(.[]; .name == $name)' >/dev/null 2>&1 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() { refresh_monitor() {
timeout 5 hyprctl dispatch dpms on "$MONITOR" >/dev/null 2>&1 && if [[ "$DISPATCH_SYNTAX" == lua ]]; then
timeout 5 hyprctl dispatch focusmonitor "$MONITOR" >/dev/null 2>&1 # 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" 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" log "Hyprland IPC unavailable or configured output $MONITOR absent - recovery aborted"
exit 1 exit 1
fi fi
select_dispatch_syntax || exit 1
if ! refresh_monitor; then if ! refresh_monitor; then
log 'Hyprland refresh failed - recovery aborted' log 'Hyprland refresh failed - recovery aborted'
exit 1 exit 1
+72
View File
@@ -32,8 +32,35 @@ elif name == 'hyprctl':
print('invalid') print('invalid')
else: else:
print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}])) print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}]))
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: 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') print('ok')
else:
print('unexpected hyprctl call', file=sys.stderr)
sys.exit(99)
elif name == 'sleep': elif name == 'sleep':
if os.environ.get('CHANGE_SESSION'): if os.environ.get('CHANGE_SESSION'):
(state / 'inactive').touch() (state / 'inactive').touch()
@@ -105,6 +132,51 @@ class RecoveryTests(unittest.TestCase):
self.assertEqual(self.switches(), ['2', '1']) self.assertEqual(self.switches(), ['2', '1'])
self.assertIn('Recovery completed', r.stderr) 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): def test_invalid_vt(self):
for vt in ('', '0', '64', '01', 'word', '99999999999999999'): for vt in ('', '0', '64', '01', 'word', '99999999999999999'):
with self.subTest(vt=vt): with self.subTest(vt=vt):