Exclusive topology left the KDE panel lit under a gamescope spawn — DPMS it dark #227

Merged
enricobuehler merged 1 commits from worktree-gamescope-exclusive-dpms into main 2026-08-14 17:14:43 +00:00
5 changed files with 802 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="dpms">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2015 Martin Gräßlin
SPDX-License-Identifier: LGPL-2.1-or-later
]]></copyright>
<interface name="org_kde_kwin_dpms_manager" version="1">
<description summary="Output dpms manager">
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
In addition it allows to request a state change. A compositor is not obliged to honor it
and will normally automatically switch back to on state.
Warning! The protocol described in this file is a desktop environment
implementation detail. Regular clients must not use this protocol.
Backward incompatible changes may be added without bumping the major
version of the extension.
</description>
<request name="get">
<description summary="Get org_kde_kwin_dpms for wl_output">
Factory request to get the org_kde_kwin_dpms for a given wl_output.
</description>
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
</interface>
<interface name="org_kde_kwin_dpms" version="1">
<description summary="Dpms for a wl_output">
This interface provides information about the VESA DPMS state for a wl_output.
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
On creating the resource the server will push whether DPSM is supported for the output,
the currently used DPMS state and notifies the client through the done event once all
states are pushed. Whenever a state changes the set of changes is committed with the
done event.
</description>
<event name="supported">
<description summary="Event indicating whether DPMS is supported on the wl_output">
This event gets pushed on binding the resource and indicates whether the wl_output
supports DPMS. There are operation modes of a Wayland server where DPMS might not
make sense (e.g. nested compositors).
</description>
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
</event>
<enum name="mode">
<entry name="On" value="0"/>
<entry name="Standby" value="1"/>
<entry name="Suspend" value="2"/>
<entry name="Off" value="3"/>
</enum>
<event name="mode">
<description summary="Event indicating used DPMS mode">
This mode gets pushed on binding the resource and provides the currently used
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
case the value will be On.
The event is also pushed whenever the state changes.
</description>
<arg name="mode" type="uint" summary="The new currently used mode"/>
</event>
<event name="done">
<description summary="All changes are pushed">
This event gets pushed on binding the resource once all other states are pushed.
In addition it gets pushed whenever a state changes to tell the client that all
state changes have been pushed.
</description>
</event>
<request name="set">
<description summary="Request DPMS state change for the wl_output">
Requests that the compositor puts the wl_output into the passed mode. The compositor
is not obliged to change the state. In addition the compositor might leave the mode
whenever it seems suitable. E.g. the compositor might return to On state on user input.
The client should not assume that the mode changed after requesting a new mode.
Instead the client should listen for the mode event.
</description>
<arg name="mode" type="uint" summary="Requested mode"/>
</request>
<request name="release" type="destructor">
<description summary="release the dpms object"/>
</request>
</interface>
</protocol>
+9
View File
@@ -848,6 +848,15 @@ mod kwin;
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
// Consumed by `gamescope` (best-effort, with kscreen fallback).
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin_dpms.rs"]
mod kwin_dpms;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
pub mod manager;
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
route: Option<crate::GamescopeRoute>,
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
/// teardown (§6.1) and never before.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
self.route = route;
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
// only). The registry stores it on this display's entry and runs it at teardown — which,
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
self.pending_restore.take()
}
fn poolable_now(&self) -> bool {
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
@@ -576,6 +589,23 @@ impl VirtualDisplay for GamescopeDisplay {
hz = mode.refresh_hz,
"gamescope virtual output ready"
);
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
// the physical panel with the idle desktop for the whole stream. The KWin route disables
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
// through the registry's group restore, because every gamescope spawn is its own group
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
// be driving the physical panel) — both returned earlier in this function.
if crate::effective_topology() == crate::policy::Topology::Exclusive {
crate::kwin_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
}
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
node_id,
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
/// the restore path that assertion costs a monitor its refresh rate.
fn kscreen_verdict(args: &[String]) -> Option<bool> {
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
match crate::proc::status_within(
std::process::Command::new("kscreen-doctor").args(args),
KSCREEN_BUDGET,
@@ -0,0 +1,675 @@
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
//! does not pass through KWin.
//!
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
//! exactly as before this module existed.
//!
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
//! restore at that session's teardown and re-light the panel under a second, still-streaming
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
//! the *timing* of each release.
//!
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
//! precisely because its disable survives everything.)
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use wayland_client::protocol::wl_callback::{self, WlCallback};
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
// `wl_output`, which `wayland_client::protocol` already provides.
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod protocol {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/dpms.xml");
}
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
const DPMS_MODE_ON: u32 = 0;
const DPMS_MODE_OFF: u32 = 3;
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
const MANAGER_MAX: u32 = 1;
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
/// works at v1, so a lower advert just costs the log its names.
const WL_OUTPUT_MAX: u32 = 4;
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
/// session-create (or group-teardown) thread.
const OP_BUDGET: Duration = Duration::from_secs(3);
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
const POLL_MS: i32 = 100;
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
#[derive(Default)]
struct OutputState {
proxy: Option<WlOutput>,
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
/// the address everything operates on.
connector: Option<String>,
dpms: Option<Dpms>,
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
supported: Option<bool>,
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
/// flip.
mode: Option<u32>,
}
/// Everything one connection's queue accumulates.
#[derive(Default)]
struct State {
manager: Option<DpmsManager>,
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
/// the identity the darken records so the re-light (a separate, later connection) can find the
/// same outputs again.
outputs: HashMap<u32, OutputState>,
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
sync_done: u32,
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => {
if interface == DpmsManager::interface().name {
let v = version.min(MANAGER_MAX);
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
let v = version.min(WL_OUTPUT_MAX);
// The global name rides in the UserData so the output's own events (and the
// dpms object's, which gets the same stamp) can find this entry.
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
state.outputs.entry(name).or_default().proxy = Some(out);
}
}
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
wl_registry::Event::GlobalRemove { name } => {
state.outputs.remove(&name);
}
_ => {}
}
}
}
impl Dispatch<WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(o) = state.outputs.get_mut(global) {
o.connector = Some(name);
}
}
}
}
impl Dispatch<Dpms, u32> for State {
fn event(
state: &mut Self,
_: &Dpms,
event: DpmsEvent,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(o) = state.outputs.get_mut(global) else {
return;
};
match event {
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
DpmsEvent::Mode { mode } => o.mode = Some(mode),
DpmsEvent::Done => {}
}
}
}
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
impl Dispatch<DpmsManager, ()> for State {
fn event(
_: &mut Self,
_: &DpmsManager,
_: protocol::org_kde_kwin_dpms_manager::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WlCallback, u32> for State {
fn event(
state: &mut Self,
_: &WlCallback,
event: wl_callback::Event,
serial: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
state.sync_done = state.sync_done.max(*serial);
}
}
}
/// Why [`Session::open`] declined — the same honest-decline discipline as
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
/// `kscreen-doctor` fallback is worth attempting.
enum OpenFailure {
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
Connect(String),
/// The compositor accepted the connection but did not answer the registry barrier in budget:
/// a live but wedged session — the case the shell-out fallback exists for.
RegistryBarrier,
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
/// KDE-only machinery), so this rung declines without one.
NoDpmsGlobal,
/// The manager is there but the per-output DPMS state bursts never completed in budget.
StateBarrier,
}
impl std::fmt::Display for OpenFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
OpenFailure::RegistryBarrier => {
write!(
f,
"the compositor did not answer the registry roundtrip in budget"
)
}
OpenFailure::NoDpmsGlobal => {
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
}
OpenFailure::StateBarrier => {
write!(
f,
"the outputs' DPMS state never finished announcing in budget"
)
}
}
}
}
/// A connected session with the manager bound and every output's DPMS state read.
struct Session {
conn: Connection,
queue: wayland_client::EventQueue<State>,
state: State,
next_sync: u32,
}
impl Session {
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
fn open(op: &'static str) -> Result<Session, OpenFailure> {
let opened = Session::connect();
if let Err(reason) = &opened {
match reason {
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
tracing::debug!(op, %reason, "KWin DPMS unavailable");
}
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
tracing::warn!(
op,
%reason,
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
);
}
}
}
opened
}
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
fn connect() -> Result<Session, OpenFailure> {
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
let queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut s = Session {
conn,
queue,
state: State::default(),
next_sync: 0,
};
let deadline = Instant::now() + OP_BUDGET;
// Phase 1: process the registry globals (binds the manager + every wl_output).
if !s.sync_barrier(deadline) {
return Err(OpenFailure::RegistryBarrier);
}
let Some(mgr) = s.state.manager.clone() else {
return Err(OpenFailure::NoDpmsGlobal);
};
// Phase 2: one dpms status object per output (stamped with the output's global name so its
// events land on the right entry), then a barrier that drains both the outputs' `name`
// events and the dpms objects' supported/mode/done bursts.
let qh = s.queue.handle();
let bound: Vec<(u32, WlOutput)> = s
.state
.outputs
.iter()
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
.collect();
for (global, out) in bound {
let d = mgr.get(&out, &qh, global);
if let Some(o) = s.state.outputs.get_mut(&global) {
o.dpms = Some(d);
}
}
if !s.sync_barrier(deadline) {
return Err(OpenFailure::StateBarrier);
}
Ok(s)
}
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
fn sync_barrier(&mut self, deadline: Instant) -> bool {
self.next_sync += 1;
let serial = self.next_sync;
let qh = self.queue.handle();
let _cb = self.conn.display().sync(&qh, serial);
self.pump_until(deadline, |st| st.sync_done >= serial)
}
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
loop {
if done(&self.state) {
return true;
}
if self.queue.dispatch_pending(&mut self.state).is_err() {
return false;
}
if done(&self.state) {
return true;
}
if Instant::now() >= deadline {
return false;
}
if self.conn.flush().is_err() {
return false;
}
let Some(guard) = self.conn.prepare_read() else {
continue; // events already queued — loop dispatches them
};
let mut pfd = libc::pollfd {
fd: self.conn.as_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let remaining = deadline.saturating_duration_since(Instant::now());
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
// aliases nothing.
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
let _ = guard.read();
} // else: timeout/signal — drop the guard, re-check the deadline
}
}
/// Request `target` on every DPMS-supporting output not already there — restricted to the
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
/// request the compositor may decline, so the confirmation is watched and its absence logged
/// rather than assumed.
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
let deadline = Instant::now() + OP_BUDGET;
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
for (global, o) in &self.state.outputs {
if only.is_some_and(|list| !list.contains(global)) {
continue;
}
if o.supported != Some(true) || o.mode == Some(target) {
continue;
}
if let Some(dpms) = &o.dpms {
dpms.set(target);
touched.push((*global, o.connector.clone()));
}
}
if touched.is_empty() {
return touched;
}
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
// nothing left to flip.
let confirmed = self.pump_until(deadline, |st| {
want.iter()
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
});
if !confirmed {
tracing::warn!(
outputs = ?touched,
target,
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
requests are flushed; it may still land, or KWin may have declined)"
);
}
touched
}
}
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
/// work matters: the two are undone through different doors.
enum Darkened {
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
/// no-op, because a fresh KWin brings its outputs up lit anyway.
Wayland(Vec<(u32, Option<String>)>),
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
/// re-light is the symmetric `--dpms on`).
Kscreen,
}
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
struct Holds {
count: u32,
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
/// nothing to undo, which is exactly right.
darkened: Option<Darkened>,
}
impl Holds {
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
fn acquire_edge(&mut self) -> bool {
self.count += 1;
self.count == 1
}
/// Store the 0→1 darken's outcome.
fn record(&mut self, d: Option<Darkened>) {
self.darkened = d;
}
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
fn release_edge(&mut self) -> Option<Darkened> {
if self.count == 0 {
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
return None;
}
self.count -= 1;
if self.count == 0 {
self.darkened.take()
} else {
None
}
}
}
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
count: 0,
darkened: None,
});
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
/// as the display's topology restore, so the registry runs it exactly once per display at
/// teardown (§6.1).
///
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
/// strictly ordered: re-light completes, then the new stream's darken runs.
pub fn acquire_stream_darken() {
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
if h.acquire_edge() {
let d = darken();
h.record(d);
}
}
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
pub fn release_stream_darken() {
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(d) = h.release_edge() {
relight(d);
}
}
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
/// off, or every arm declined) — and therefore nothing to restore.
fn darken() -> Option<Darkened> {
match Session::open("darken") {
Ok(mut s) => {
let touched = s.set_mode(DPMS_MODE_OFF, None);
if touched.is_empty() {
tracing::debug!(
"KWin DPMS: no output to darken (none supported, or all already off)"
);
None
} else {
tracing::info!(
outputs = ?touched,
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
);
Some(Darkened::Wayland(touched))
}
}
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
// the same KDE-only machinery), so decline quietly — already logged by `open`.
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
// A live session that stopped answering: the standalone tool rides a different stack
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
// kscreen fallbacks, honest-verdict discipline included.
Err(_) => match kscreen_dpms("off") {
Some(true) => {
tracing::info!(
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
(kscreen-doctor fallback)"
);
Some(Darkened::Kscreen)
}
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
// on the compositor, so a loaded KWin routinely lands the change and still gets
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
// against a lit panel is a no-op.
None => Some(Darkened::Kscreen),
Some(false) => {
tracing::warn!(
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
(in-process path and kscreen-doctor both declined) — the panel stays lit"
);
None
}
},
}
}
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
fn relight(d: Darkened) {
match d {
Darkened::Wayland(outputs) => {
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
match Session::open("re-light") {
Ok(mut s) => {
s.set_mode(DPMS_MODE_ON, Some(&globals));
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
}
Err(_) => match kscreen_dpms("on") {
Some(true) | None => {
tracing::info!(
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
);
}
Some(false) => {
tracing::error!(
outputs = ?outputs,
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
restore and kscreen-doctor both declined) — the panel stays dark \
until local input wakes it"
);
}
},
}
}
Darkened::Kscreen => {
if kscreen_dpms("on") == Some(false) {
tracing::error!(
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
the --dpms on it earlier accepted the off for) — the panel stays dark until \
local input wakes it"
);
}
}
}
}
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
}
#[cfg(test)]
mod tests {
use super::{Darkened, Holds};
fn fresh() -> Holds {
Holds {
count: 0,
darkened: None,
}
}
#[test]
fn first_acquire_darkens_later_ones_count() {
let mut h = fresh();
assert!(h.acquire_edge(), "0→1 must darken");
h.record(Some(Darkened::Kscreen));
assert!(
!h.acquire_edge(),
"a second concurrent stream must not re-darken"
);
assert!(!h.acquire_edge());
}
#[test]
fn only_the_last_release_relights() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
assert!(!h.acquire_edge());
// First release: a sibling still streams — the panel must stay dark.
assert!(h.release_edge().is_none());
// Last release hands back the record to undo.
let d = h.release_edge();
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
}
#[test]
fn a_darken_that_did_nothing_restores_nothing() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(None); // no KDE / already dark: nothing was changed
assert!(h.release_edge().is_none(), "nothing to undo");
assert_eq!(h.count, 0);
}
#[test]
fn unbalanced_release_never_underflows() {
let mut h = fresh();
assert!(h.release_edge().is_none());
assert_eq!(h.count, 0, "count must not wrap");
// And the state machine still works afterwards.
assert!(h.acquire_edge());
h.record(Some(Darkened::Kscreen));
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
}
#[test]
fn a_full_cycle_rearms_the_darken() {
let mut h = fresh();
assert!(h.acquire_edge());
h.record(Some(Darkened::Kscreen));
assert!(h.release_edge().is_some());
// A later stream on the same host lifetime darkens again.
assert!(
h.acquire_edge(),
"the 0→1 edge must re-arm after a full cycle"
);
}
}