diff --git a/crates/pf-vdisplay/protocols/dpms.xml b/crates/pf-vdisplay/protocols/dpms.xml new file mode 100644 index 00000000..79b7bbe6 --- /dev/null +++ b/crates/pf-vdisplay/protocols/dpms.xml @@ -0,0 +1,87 @@ + + + + + + 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. + + + + Factory request to get the org_kde_kwin_dpms for a given 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. + + + + 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). + + + + + + + + + + + + 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. + + + + + + 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. + + + + + 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. + + + + + + + + + diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 1b39dc32..f51bc2d7 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -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; diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index a36e25e1..51c83b39 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -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, + /// 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>, } /// 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> { + // 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, diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs index 817aa0c6..51f9aaa6 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs @@ -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 { +pub(crate) fn kscreen_verdict(args: &[String]) -> Option { match crate::proc::status_within( std::process::Command::new("kscreen-doctor").args(args), KSCREEN_BUDGET, diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin_dpms.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin_dpms.rs new file mode 100644 index 00000000..6738b0d9 --- /dev/null +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin_dpms.rs @@ -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, + /// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is + /// the address everything operates on. + connector: Option, + dpms: Option, + /// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives. + supported: Option, + /// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it + /// flip. + mode: Option, +} + +/// Everything one connection's queue accumulates. +#[derive(Default)] +struct State { + manager: Option, + /// 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, + /// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on. + sync_done: u32, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + 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::(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::(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 for State { + fn event( + state: &mut Self, + _: &WlOutput, + event: wl_output::Event, + global: &u32, + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event { + if let Some(o) = state.outputs.get_mut(global) { + o.connector = Some(name); + } + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &Dpms, + event: DpmsEvent, + global: &u32, + _: &Connection, + _: &QueueHandle, + ) { + 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 for State { + fn event( + _: &mut Self, + _: &DpmsManager, + _: protocol::org_kde_kwin_dpms_manager::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &WlCallback, + event: wl_callback::Event, + serial: &u32, + _: &Connection, + _: &QueueHandle, + ) { + 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, + 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 { + 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 { + 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)> { + let deadline = Instant::now() + OP_BUDGET; + let mut touched: Vec<(u32, Option)> = 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 = 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)>), + /// 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, +} + +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) { + 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 { + 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 = 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 { + 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 = 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 ` 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 { + 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" + ); + } +}