fix(hyprland): its dpms dispatcher is a TOGGLE, and the classic argv does not parse under Lua
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m45s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m45s
android / android (pull_request) Successful in 5m52s
ci / rust (pull_request) Successful in 7m40s

Verified on the NixOS VM (125, Hyprland 0.55.4) — and the arm I shipped for it in
the previous commit was wrong twice over. This is why it went on glass.

**The argv did not work at all.** `hyprctl dispatch dpms off <name>`, the form
this file's own probe notes use, dies on the Lua config manager: `dispatch` is
shorthand for `hl.dispatch(...)`, so the bare words are parsed as a Lua
expression —

    error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
           ')' expected near 'off'

The Lua spelling is `hl.dsp.dpms("off", "<name>")` (found by enumerating
`hl.dsp` through `hyprctl eval`, which only exists on that manager). A hyprlang
box wants the classic form, there is no stable probe for which manager is
loaded, and `hyprctl_dispatch` already catches the exit-0 rejections both
produce — so try classic, then Lua, and report both failures if neither lands.

**And the dispatcher is a TOGGLE that ignores the state word.** Measured:

    On  ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  Off   <- asked ON, got OFF
    Off ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  On
    Off ==[ hl.dsp.dpms{state="off", ...}  ]==>  On    <- asked OFF, got ON

Both spellings, positional and table. So the blind "send off, later send on" the
previous commit shipped would LIGHT an already-dark head at stream start and
DARKEN a lit one at teardown — the operator's screen left off after the stream,
precisely the failure this policy exists to prevent. (It bit me while probing:
a restore fired at an already-On head turned it off, which for a while looked
like "dpms on cannot restore on Hyprland".)

So `dpms_one` is read → act only if the state differs → verify, via
`hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's
sysfs `dpms` exactly, in both states, and a dark monitor stays listed). That
shape is also correct where the call really is a set, so it is not conditional
on detecting the manager. It returns whether it CHANGED anything, and
`dpms_other_heads` records only those — a head already in the wanted state is
left alone, because "fixing" it would break it, and reporting it would have the
re-light toggle a head we never darkened.

The on-glass assertion is relaxed from "every connected head goes dark" to "at
least one did, and all are restored": this VM carries a virtio `Virtual-1`
beside the real `HDMI-A-1` and Hyprland manages only the latter, so the strict
form failed on a difference that is not a defect.

Verified end to end, real Rust through the real dispatcher on a live Hyprland:

    before: [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    during: [("card0-HDMI-A-1", "Off"), ("card1-Virtual-1", "On")]
    after:  [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    went dark: ["card0-HDMI-A-1"]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The unmanaged `Virtual-1` is correctly untouched, and the test exercises the
double-acquire (reconnect) path too, so the toggle-safe release is covered.

cargo test -p pf-vdisplay 255 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean. sway remains the one arm not verified
on glass — there is no sway box in the lab.
This commit is contained in:
2026-08-24 15:29:24 +02:00
parent e0a822016f
commit 540e282e60
3 changed files with 125 additions and 11 deletions
@@ -6084,16 +6084,22 @@ mod tests {
let after = lit();
println!("after: {after:?}");
if during.iter().all(|(_, d)| d == "On") {
let went_dark: Vec<&String> = during
.iter()
.zip(&before)
.filter(|((_, now), (_, was))| was == "On" && now == "Off")
.map(|((n, _), _)| n)
.collect();
if went_dark.is_empty() {
println!("nothing was ours to darken (card already mastered?) — skipping");
return;
}
for (name, d) in &during {
assert_eq!(
d, "Off",
"{name} should be dark while the managed hold is up"
);
}
// Deliberately "at least one went dark", not "all did": a box can carry a connected head
// the live compositor does not manage. The Hyprland VM has a virtio `Virtual-1` beside the
// real `HDMI-A-1`, and only the latter is Hyprland's to darken — asserting all of them
// would fail on a difference that is not a defect. What must hold is that the mechanism
// darkened something real, and that the release put every head back exactly as found.
println!("went dark: {went_dark:?}");
assert_eq!(after, before, "the release must restore what we found");
}
@@ -557,11 +557,14 @@ pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
let Ok(heads) = list_monitors() else {
return Vec::new();
};
let verb = if on { "on" } else { "off" };
let mut changed = Vec::new();
for name in heads_to_disable(&heads, "") {
match hyprctl_dispatch(&["dispatch", "dpms", verb, &name]) {
Ok(()) => changed.push(name),
match dpms_one(&name, on) {
// Only a head THIS call moved is recorded: one already in the wanted state was left
// alone (the dispatcher toggles, so "fixing" it would break it), and reporting it as
// changed would have the re-light toggle a head we never darkened.
Ok(true) => changed.push(name),
Ok(false) => {}
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"hyprland: could not DPMS this monitor for `topology: exclusive`"
@@ -571,6 +574,88 @@ pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
changed
}
/// The DPMS state Hyprland reports for `name` right now — `hyprctl -j monitors all`'s
/// `dpmsStatus`. `None` when the monitor is not listed or the field is missing.
///
/// Measured on 0.55.4: this tracks the hardware exactly (`dpmsStatus:true` ⇔ the connector's sysfs
/// `dpms=On`), in both states, and a DPMS-off monitor stays listed. It is the readback
/// [`dpms_one`] is built around.
fn monitor_dpms(name: &str) -> Option<bool> {
let raw = hyprctl(&["-j", "monitors", "all"]).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed
.as_array()?
.iter()
.find(|m| m.get("name").and_then(|v| v.as_str()) == Some(name))?
.get("dpmsStatus")?
.as_bool()
}
/// Put ONE monitor into `want_on`, reporting whether this call actually changed it.
///
/// ⚠ **The dispatcher is a TOGGLE, not a set** — measured on 0.55.4 (Lua) 2026-08-24, and the
/// single most important fact in this function. It ignores the state word entirely:
///
/// ```text
/// On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked for ON, got OFF
/// Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
/// Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked for OFF, got ON
/// ```
///
/// So a blind "off" LIGHTS an already-dark head, and a blind "on" at teardown DARKENS a lit one —
/// the operator's screen left off after the stream, which is the failure this whole policy exists
/// to avoid. Hence read → act only if it differs → verify. That shape is also correct on a
/// config manager where the call really is a set, so it is not conditional on detecting which.
///
/// The SPELLING differs too. The classic `hyprctl dispatch dpms off <name>` does not work on the
/// Lua manager at all: `dispatch` is shorthand for `hl.dispatch(...)`, so the bare words parse as
/// a Lua expression and it dies with `')' expected near 'off'`. A hyprlang box (0.56.2 was probed
/// as one) wants the classic form. There is no stable probe for which manager is loaded, and
/// [`hyprctl_dispatch`] already catches the exit-0 rejections both produce — so try classic, then
/// Lua, and report both failures if neither lands.
///
/// ⚠ **Never omit the monitor name.** `hl.dsp.dpms("on")` answers `ok` and toggles *something*;
/// with a name it is at least addressed at the head we mean.
fn dpms_one(name: &str, want_on: bool) -> Result<bool> {
if monitor_dpms(name) == Some(want_on) {
return Ok(false); // already where we want it — toggling would break it
}
let classic =
match hyprctl_dispatch(&["dispatch", "dpms", if want_on { "on" } else { "off" }, name]) {
Ok(()) => None,
Err(e) => {
let lua = lua_dpms_expr(name, want_on);
match hyprctl_dispatch(&["dispatch", &lua]) {
Ok(()) => None,
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
}
}
};
if let Some(why) = classic {
bail!("neither dispatch form was accepted for {name} — {why}");
}
// Verify, because a toggle that fired against a state we misread is worse than one that did
// not fire at all.
match monitor_dpms(name) {
Some(now) if now == want_on => Ok(true),
Some(now) => bail!(
"hyprland accepted the dpms dispatch for {name} but it is now dpmsStatus={now}, \
wanted {want_on} (the dispatcher toggles — the readback disagreed with reality)"
),
None => bail!("hyprland stopped listing {name} after its dpms dispatch"),
}
}
/// The Lua-config-manager spelling of a per-monitor DPMS. Pure, so a test pins the shape — the
/// quoting is the whole trick, and an unquoted argument is exactly what the classic form gets
/// wrong on that manager.
fn lua_dpms_expr(name: &str, on: bool) -> String {
format!(
"hl.dsp.dpms(\"{}\", \"{name}\")",
if on { "on" } else { "off" }
)
}
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
/// that screen, not the session.
@@ -1420,6 +1505,22 @@ fn portal_thread(
mod tests {
use super::*;
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
/// entire difference between working and silently doing nothing.
#[test]
fn the_lua_dpms_expression_quotes_both_arguments() {
assert_eq!(
lua_dpms_expr("HDMI-A-1", false),
r#"hl.dsp.dpms("off", "HDMI-A-1")"#
);
assert_eq!(lua_dpms_expr("DP-2", true), r#"hl.dsp.dpms("on", "DP-2")"#);
// The monitor name is never omitted: the no-name form answers `ok` and TOGGLES on 0.55.4,
// which would flip a just-restored head back off.
assert!(lua_dpms_expr("DP-2", true).contains("\"DP-2\""));
}
#[test]
fn version_tag_parses_release_and_dev_builds() {
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
@@ -20,7 +20,7 @@
//! |---|---|
//! | KDE / KWin | in-process `org_kde_kwin_dpms`, then a `kscreen-doctor --dpms` shell-out |
//! | sway (wlroots) | `swaymsg output <name> dpms off` ([`crate::wlroots::dpms_other_heads`]) |
//! | Hyprland | `hyprctl dispatch dpms off <name>` ([`crate::hyprland::dpms_other_heads`]) |
//! | Hyprland | its dpms dispatcher, read-modify-verify ([`crate::hyprland::dpms_other_heads`]) |
//! | none at all | [`crate::drm_dpms`] — the CRTCs off over DRM, no compositor needed |
//! | GNOME / Mutter | **cannot be served** — see below |
//!
@@ -31,6 +31,13 @@
//! [`crate::hyprland`] already drive them — no second layer to be wedged, so no in-process twin
//! is warranted.
//!
//! Neither of those two is as simple as "send the off command", and the Hyprland one especially
//! is not: its dpms dispatcher is a **toggle** that ignores the state word (measured on 0.55.4 —
//! asking for `on` turned a lit head OFF), and the classic argv does not even parse under its Lua
//! config manager. [`crate::hyprland::dpms_other_heads`] carries the full account; the contract
//! this module depends on is only that each arm returns **the heads it actually changed**, so the
//! re-light moves exactly those and never a head it did not darken.
//!
//! The DRM arm is not an afterthought: a box sitting in Game Mode runs gamescope and NO desktop
//! compositor, and it is *exactly* the deployment whose TV the operator wants dark.
//!