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
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:
@@ -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.
|
||||
//!
|
||||
|
||||
Reference in New Issue
Block a user