fix(kwin): drive display topology over kde_output_management_v2, not kscreen-doctor

A KDE host on Nobara stopped disabling its physical screens and creating the
virtual output the moment it updated: streaming still came up, but bring-up took
~26 s and the streamed output never became the desktop (`also_disabled=[]`).

Root cause is not our topology logic — it's that every `kscreen-doctor` call on
the reporter's session wedges. kscreen-doctor drives libkscreen, which (per
setup) waits on the kscreen KDED module over D-Bus; when that layer is stuck it
blocks in its own connect and never returns, so all five topology queries hit
their 5 s budget and got killed (host log). Reproduced live on a Nobara / KWin
6.6.4 box: `kscreen-doctor -j` there times out at 8 s, every time.

But the compositor's OWN Wayland is fully responsive on that same session — the
host just created a virtual output over it via zkde_screencast. So drive the
topology (resolve our output, take primary, disable the physical/bootstrap
outputs, capture their modes, re-enable on teardown, position) directly over
`kde_output_management_v2` + `kde_output_device_v2` instead of shelling out.
On that same Nobara box the in-process path binds management (v19) and
enumerates the outputs in 2.4 ms — ~3400x faster than the 8 s hang, and immune
to whatever wedges the standalone tool.

- vendor kde-output-management-v2 / kde-output-device-v2 (KWin advertises mgmt
  v19, device v20); generate client bindings inline (the interdependent-protocol
  module split from the wayland-protocols crate; needs the `bitflags` dep for the
  device protocol's bitfield enums).
- new `kwin_output_mgmt`: bounded enumerate-then-apply over one Wayland
  connection; every wait is time-bounded so a genuinely wedged compositor
  degrades to `handled = false` and the old kscreen-doctor path still runs.
- `kwin::create` topology + `apply_position` prefer the in-process path (address
  our output by its stable device UUID, supersede-robust) with kscreen-doctor as
  the fallback. The 60 Hz path now makes ZERO kscreen-doctor calls; only the
  >60 Hz custom-mode install still shells out (its in-process port is a follow-up).

Verified on Linux: cargo test -p pf-vdisplay (73 pass), clippy --all-targets
-D warnings clean, fmt clean, punktfunk-host checks clean. In-process enumeration
proven live against KWin 6.6.4 (the reporter's env); the disable/apply path is
not yet on-glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:47:33 +02:00
co-authored by Claude Opus 4.8
parent a3e21d9227
commit 86d5d66660
7 changed files with 1955 additions and 32 deletions
+94 -32
View File
@@ -89,6 +89,11 @@ pub struct KwinDisplay {
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
/// superseded same-name sibling is still alive.
last_name: Option<String>,
/// The RESOLVED `kde_output_device_v2` UUID of the last `create`'s output, when the in-process
/// output-management path handled the topology. A stable per-output id (unlike the shared name),
/// so [`apply_position`](VirtualDisplay::apply_position) and restore address exactly OUR output
/// across a supersede — preferred over `last_name`, which is only the kscreen-doctor fallback.
our_uuid: Option<String>,
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
@@ -114,6 +119,48 @@ impl KwinDisplay {
pub fn new() -> Result<Self> {
Ok(KwinDisplay::default())
}
/// Apply the effective display topology for the just-created output `our_prefix` (current size
/// `dims`), preferring the in-process `kde_output_management_v2` path and falling back to
/// `kscreen-doctor` if the compositor doesn't answer in budget or the management global is
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
fn apply_topology(
&mut self,
name: &str,
our_prefix: &str,
dims: (u32, u32),
) -> Vec<(String, String)> {
use crate::kwin_output_mgmt::TopologyKind;
use crate::policy::Topology;
let topology = crate::effective_topology();
let kind = match topology {
Topology::Exclusive => TopologyKind::Exclusive,
Topology::Primary => TopologyKind::Primary,
Topology::Extend | Topology::Auto => return Vec::new(),
};
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
if outcome.handled {
self.our_uuid = outcome.our_uuid;
return outcome.disabled;
}
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
tracing::info!(
"KWin topology: kde_output_management unavailable — kscreen-doctor fallback"
);
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
self.last_name = Some(addr.clone());
match topology {
Topology::Exclusive => apply_virtual_primary(&addr),
Topology::Primary => {
apply_virtual_primary_only(&addr);
Vec::new()
}
Topology::Extend | Topology::Auto => Vec::new(),
}
}
}
impl VirtualDisplay for KwinDisplay {
@@ -142,8 +189,15 @@ impl VirtualDisplay for KwinDisplay {
}
fn apply_position(&mut self, x: i32, y: i32) {
// `last_name` holds the RESOLVED kscreen address (numeric output id, or the
// `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
// Prefer the in-process path: address OUR output by its stable UUID (supersede-robust) over
// kde_output_management_v2 — immune to a wedged kscreen-doctor backend (see kwin_output_mgmt).
if let Some(uuid) = self.our_uuid.clone() {
if crate::kwin_output_mgmt::set_position(&uuid, x, y) {
return;
}
}
// Fallback: kscreen-doctor. `last_name` holds the RESOLVED kscreen address (numeric output id,
// or the `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
// outputs share it and the command would hit the old one (see `create`).
let Some(output) = self.last_name.clone() else {
return;
@@ -227,17 +281,10 @@ impl VirtualDisplay for KwinDisplay {
embedded_pointer = !self.hw_cursor,
"KWin virtual output ready"
);
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
// replacement output — SAME per-slot name, deliberately, for KWin's per-name config
// persistence — while the superseded sibling is still alive (create-before-drop). Every
// name-addressed kscreen command then hits the FIRST match = the OLD output: on-glass this
// resized the LIVE session's display out from under it (wrong-res/black), read back the
// OLD output as "custom mode applied", made the OLD output primary, and positioned it —
// while the new output never left its birth mode and the capturer's dims gate starved.
// Resolve OUR output's kscreen id once (match: managed-prefix name AND current mode ==
// the just-created birth size; newest id wins), and use it for every kscreen operation.
let mut addr = resolve_kscreen_addr(&name, width, birth_h);
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
// Topology + positioning address OUR output by its kde_output_management UUID (resolved
// in-process in `apply_topology`, supersede-robust) — no early kscreen-doctor resolve, so
// the 60 Hz path never shells out. Only the >60 Hz custom-mode install below still uses
// kscreen-doctor (its in-process port — set_custom_modes/add_cvt — is a follow-up).
let mut expect_exact_dims = false;
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
@@ -245,6 +292,14 @@ impl VirtualDisplay for KwinDisplay {
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
let mut final_dims = (width, height);
let achieved_hz = if want_high {
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
// replacement output — SAME per-slot name, deliberately, for KWin's per-name config
// persistence — while the superseded sibling is still alive (create-before-drop). A
// name-addressed kscreen command would hit the FIRST match = the OLD output. Resolve OUR
// output's kscreen id (managed-prefix name AND current mode == the birth size; newest id
// wins) for the custom-mode install, and keep it as the kscreen-doctor position fallback.
let addr = resolve_kscreen_addr(&name, width, birth_h);
self.last_name = Some(addr.clone());
let active = set_custom_refresh(width, height, mode.refresh_hz, &addr);
// Accept only an active mode that IS our custom one: the exact requested height, and a
// width at or just below the request (a CVT alignment). That also proves the output
@@ -275,8 +330,6 @@ impl VirtualDisplay for KwinDisplay {
let (nid, st) = spawn_vout(width, height)?;
node_id = nid;
stop = st;
addr = resolve_kscreen_addr(&name, width, height);
self.last_name = Some(addr.clone());
tracing::info!(
node_id,
width,
@@ -293,16 +346,17 @@ impl VirtualDisplay for KwinDisplay {
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
use crate::policy::Topology;
let disabled = match crate::effective_topology() {
Topology::Exclusive => apply_virtual_primary(&addr),
Topology::Primary => {
apply_virtual_primary_only(&addr);
Vec::new() // nothing disabled → nothing to restore
}
Topology::Extend | Topology::Auto => Vec::new(),
};
// bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
let our_prefix = format!("Virtual-{name}");
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
// A plain managed name is enough for apply_position's kscreen-doctor fallback when the
// in-process UUID path isn't set (single-output sessions are unambiguous; a supersede uses
// the UUID path instead). `want_high` already set `last_name` to the resolved kscreen id.
if self.last_name.is_none() {
self.last_name = Some(our_prefix);
}
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
@@ -310,7 +364,12 @@ impl VirtualDisplay for KwinDisplay {
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
self.pending_restore = (!disabled.is_empty()).then(|| {
let disabled = disabled.clone();
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in budget.
Box::new(move || {
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
reenable_outputs_kscreen(&disabled);
}
}) as Box<dyn FnOnce() + Send>
});
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
@@ -324,10 +383,12 @@ impl VirtualDisplay for KwinDisplay {
}
}
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
fn reenable_outputs(outputs: &[(String, String)]) {
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
/// the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
/// when the display group's last member is torn down (design §6.1), BEFORE that member's output is
/// reclaimed — so KWin is never momentarily left with zero enabled outputs.
fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
if outputs.is_empty() {
return;
}
@@ -823,8 +884,9 @@ fn apply_virtual_primary_only(ours: &str) {
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
/// longer bound here — it moved to the registry's display group (§6.1, restored in-process via
/// [`crate::kwin_output_mgmt::reenable_outputs`], `kscreen-doctor` fallback), which runs it once when
/// the group's last member drops, BEFORE this keepalive is dropped.
struct StopGuard {
stop: Arc<AtomicBool>,
}
@@ -0,0 +1,658 @@
//! In-process KDE output management (`kde_output_management_v2` + `kde_output_device_v2`).
//!
//! Topology — make the streamed output primary, disable the physical/bootstrap outputs, capture
//! their modes for restore, re-enable them on teardown, position the output — used to shell out to
//! `kscreen-doctor` (see [`super::kwin`]). But `kscreen-doctor` drives a *separate* stack:
//! libkscreen picks a backend and, depending on the setup, waits on the kscreen KDED module over
//! D-Bus. On a machine where THAT layer is wedged it blocks in its own connect and never returns —
//! a field report (Nobara, KWin 6.6.4) showed every topology `kscreen-doctor` timing out at its 5 s
//! budget, so the streamed output never became the desktop (`also_disabled=[]`) and bring-up took
//! ~26 s.
//!
//! The compositor's OWN Wayland is provably responsive on that same session — the host just created
//! a virtual output over it via `zkde_screencast` — so we drive `kde_output_management_v2` directly
//! over Wayland here, sidestepping whatever wedges the standalone tool. Every wait is time-bounded,
//! so a genuinely wedged compositor degrades to `handled = false` and the caller falls back to the
//! `kscreen-doctor` path rather than hanging.
//!
//! KWin advertises one `kde_output_device_v2` global per output (the classic model; verified live:
//! `kde_output_management_v2` v19, `kde_output_device_v2` v20 on KWin 6.6.4). We bind them all, read
//! each output's name / enabled / priority / current-mode size, then build a
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::time::{Duration, Instant};
use wayland_client::backend::ObjectId;
use wayland_client::protocol::wl_callback::{self, WlCallback};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
// Generate the client bindings for the two vendored KDE protocols inline (no build.rs). The
// management protocol references the device interfaces, so they can't share one `__interfaces`
// module (each `generate_interfaces!` emits its own helper items, which collide). Instead — the
// interdependent-protocol pattern from the `wayland-protocols` crate — `device` is a self-contained
// module and `management` pulls in `device`'s interface statics + generated proxy types before its
// own codegen, so its cross-protocol object args (`kde_output_device_v2`, `…_mode_v2`) resolve.
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod device {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/kde-output-device-v2.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/kde-output-device-v2.xml");
}
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod management {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use super::super::device::__interfaces::*;
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/kde-output-management-v2.xml");
}
use self::__interfaces::*;
// The device protocol's generated modules/types, so the foreign object args resolve.
use super::device::*;
wayland_scanner::generate_client_code!("protocols/kde-output-management-v2.xml");
}
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
use management::kde_output_configuration_v2::{
Event as ConfigEvent, KdeOutputConfigurationV2 as OutputConfig,
};
use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagement;
/// Highest interface versions we drive; we bind `min(advertised, MAX)`. Every request we issue is
/// `since ≤ 2` (`create_configuration`/`enable`/`mode`/`position`/`apply` are v1, `set_primary_output`
/// is v2) and every event we read is `since ≤ 18` (`priority`), so binding high and calling low is
/// always in range on any KWin that advertises the globals.
const MGMT_MAX: u32 = 22;
const DEVICE_MAX: u32 = 24;
/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
/// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
const OP_BUDGET: Duration = Duration::from_secs(3);
/// Poll slice while waiting on the Wayland fd (matches the keepalive loop's cadence in `kwin.rs`).
const POLL_MS: i32 = 100;
/// Which topology to apply once our output is resolved.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TopologyKind {
/// Make ours the sole desktop: primary + disable every other enabled output.
Exclusive,
/// Make ours primary but leave the other outputs enabled.
Primary,
}
/// Outcome of [`apply_topology`].
pub(crate) struct TopologyOutcome {
/// UUID of our resolved virtual output — a stable per-output id that survives a mode-switch
/// supersede (unlike the shared name) — for later [`set_position`] / restore addressing.
pub our_uuid: Option<String>,
/// The outputs we disabled, each `(name, "WxH@Hz")`, so teardown can restore them at their exact
/// mode. Empty for `Primary`, or when nothing else was enabled.
pub disabled: Vec<(String, String)>,
/// `true` if the in-process path bound management, resolved our output, and applied (or tried to)
/// a configuration. `false` ⇒ the compositor didn't answer in budget or our output never
/// appeared, so the caller should fall back to `kscreen-doctor`.
pub handled: bool,
}
/// One output as read from `kde_output_device_v2`.
#[derive(Default, Clone)]
struct DeviceState {
/// The global `name` number (higher = more recently advertised) — used to pick the newest of two
/// same-named outputs during a supersede.
global: u32,
name: Option<String>,
uuid: Option<String>,
enabled: bool,
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
priority: Option<u32>,
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
current_mode: Option<ObjectId>,
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
/// can pick the one matching a captured `WxH@Hz`.
modes: Vec<(ObjectId, DeviceMode)>,
/// Set once this output's `done` burst has been seen (its state is coherent to read).
seen_done: bool,
proxy: Option<OutputDevice>,
}
/// Everything the enumerate/apply queue accumulates on one connection.
#[derive(Default)]
struct State {
management: Option<OutputManagement>,
mgmt_name_version: Option<(u32, u32)>,
devices: HashMap<ObjectId, DeviceState>,
/// mode object id → `(width, height, refresh_mHz)`.
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
sync_done: u32,
/// Configuration apply verdict: `Some(true)` = applied, `Some(false)` = failed.
applied: Option<bool>,
failure_reason: Option<String>,
}
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 == OutputManagement::interface().name {
let v = version.min(MGMT_MAX);
state.management =
Some(registry.bind::<OutputManagement, _, _>(name, v, qh, ()));
state.mgmt_name_version = Some((name, v));
} else if interface == OutputDevice::interface().name {
let v = version.min(DEVICE_MAX);
// The device's `name` global carries into the device's UserData so the event
// handler can record it (newest-wins tie-break during a supersede).
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
let id = dev.id();
state.devices.entry(id).or_default().proxy = Some(dev);
}
}
wl_registry::Event::GlobalRemove { .. } => {}
_ => {}
}
}
}
// Management has no events.
impl Dispatch<OutputManagement, ()> for State {
fn event(
_: &mut Self,
_: &OutputManagement,
_: management::kde_output_management_v2::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
/// The device's UserData is its global `name` number.
impl Dispatch<OutputDevice, u32> for State {
fn event(
state: &mut Self,
device: &OutputDevice,
event: DeviceEvent,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let entry = state.devices.entry(device.id()).or_default();
entry.global = *global;
if entry.proxy.is_none() {
entry.proxy = Some(device.clone());
}
match event {
DeviceEvent::Name { name } => entry.name = Some(name),
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
DeviceEvent::Done => entry.seen_done = true,
_ => {}
}
}
// The `mode` event hands us a server-created `kde_output_device_mode_v2`. The opcode is a bare
// literal (the macro's fragment matcher rejects a `const` in some wayland-client versions); it is
// pinned to `DEVICE_MODE_EVENT_OPCODE` by `mode_event_opcode_is_two` below.
event_created_child!(State, OutputDevice, [
2 => (DeviceMode, ()),
]);
}
impl Dispatch<DeviceMode, ()> for State {
fn event(
state: &mut Self,
mode: &DeviceMode,
event: ModeEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
let entry = state.mode_dims.entry(mode.id()).or_insert((0, 0, 0));
match event {
ModeEvent::Size { width, height } => {
entry.0 = width.max(0) as u32;
entry.1 = height.max(0) as u32;
}
ModeEvent::Refresh { refresh } => entry.2 = refresh.max(0) as u32,
_ => {}
}
}
}
impl Dispatch<OutputConfig, ()> for State {
fn event(
state: &mut Self,
_: &OutputConfig,
event: ConfigEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
ConfigEvent::Applied => state.applied = Some(true),
ConfigEvent::Failed => state.applied = Some(false),
ConfigEvent::FailureReason { reason } => state.failure_reason = Some(reason),
_ => {}
}
}
}
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);
}
}
}
/// A connected, bound output-management session on its own Wayland connection.
struct Session {
conn: Connection,
queue: wayland_client::EventQueue<State>,
state: State,
next_sync: u32,
}
impl Session {
/// Connect to the KWin Wayland socket, bind `kde_output_management_v2` + every
/// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. `None` if
/// we can't connect, the management global isn't advertised, or the compositor doesn't answer in
/// budget (the wedge case — the caller then falls back to `kscreen-doctor`).
fn open() -> Option<Session> {
let conn = Connection::connect_to_env().ok()?;
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 management + every device in the handler).
if !s.sync_barrier(deadline) {
return None;
}
if s.state.management.is_none() {
tracing::debug!(
"KWin does not advertise kde_output_management_v2 to this client — kscreen-doctor \
fallback"
);
return None;
}
// Phase 2: flush the device binds issued in phase 1 and drain each output's state burst
// (name / enabled / priority / current_mode / mode sizes / done).
if !s.sync_barrier(deadline) {
return None;
}
Some(s)
}
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
/// Returns `true` on the barrier, `false` on timeout.
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 what's queued, then poll the connection fd for up
/// to `POLL_MS` and read. Mirrors the keepalive loop in `kwin.rs::run` (blocking_dispatch can't
/// be interrupted, so we poll the fd instead). Returns `true` once `done(&state)` holds.
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
}
}
/// A fresh `kde_output_configuration_v2` on this connection.
fn new_config(&self) -> OutputConfig {
let qh = self.queue.handle();
self.state
.management
.as_ref()
.unwrap()
.create_configuration(&qh, ())
}
/// `apply()` the config and pump until `applied`/`failed` or the deadline. Returns the verdict
/// (`true` applied, `false` failed/timeout).
fn apply(&mut self, config: &OutputConfig, deadline: Instant) -> bool {
self.state.applied = None;
config.apply();
let ok = self.pump_until(deadline, |st| st.applied.is_some());
if !ok {
return false;
}
matches!(self.state.applied, Some(true))
}
/// The current-mode size of a device as `(w, h, refresh_mHz)`, if known.
fn current_dims(&self, dev: &DeviceState) -> Option<(u32, u32, u32)> {
let id = dev.current_mode.as_ref()?;
self.state.mode_dims.get(id).copied()
}
}
/// `(width, height, "WxH@Hz")` capture of a device's current mode, Hz rounded — the same shape the
/// `kscreen-doctor` restore path used, so teardown can put a panel back at its real refresh.
fn mode_spec(dims: (u32, u32, u32)) -> String {
let hz = ((dims.2 as f64) / 1000.0).round() as u32;
format!("{}x{}@{}", dims.0, dims.1, hz)
}
/// Prefix EVERY managed KWin output shares (mirrors `kwin::MANAGED_PREFIX`) — the streamed outputs
/// are `Virtual-punktfunk` / `Virtual-punktfunk-<id>`, so a same-family sibling session is never
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
pub(crate) fn apply_topology(
our_prefix: &str,
our_w: u32,
our_h: u32,
kind: TopologyKind,
) -> TopologyOutcome {
let miss = || TopologyOutcome {
our_uuid: None,
disabled: Vec::new(),
handled: false,
};
let Some(mut sess) = Session::open() else {
return miss();
};
let deadline = Instant::now() + OP_BUDGET;
// Resolve OUR output: managed-prefix name AND current size == the birth size (only the
// just-created output sits there during a supersede); newest global wins the tie.
let ours = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
})
.max_by_key(|d| d.global)
.cloned();
let Some(ours) = ours else {
tracing::warn!(
our_prefix,
our_w,
our_h,
"KWin output management: our virtual output hasn't appeared yet — kscreen-doctor fallback"
);
return miss();
};
let our_uuid = ours.uuid.clone();
let our_id = ours.proxy.as_ref().map(|p| p.id());
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop.
let sibling_is_primary = sess.state.devices.values().any(|d| {
d.enabled
&& d.priority == Some(1)
&& d.proxy.as_ref().map(|p| p.id()) != our_id
&& d.name
.as_deref()
.is_some_and(|n| n.starts_with(MANAGED_PREFIX))
});
// The physical/bootstrap outputs to disable for `Exclusive`: enabled, not any managed sibling,
// not ours. Captured WITH their current mode so teardown restores the exact refresh.
let mut to_disable: Vec<(OutputDevice, String, String)> = Vec::new();
if kind == TopologyKind::Exclusive {
for d in sess.state.devices.values() {
let is_ours = d.proxy.as_ref().map(|p| p.id()) == our_id;
let managed = d
.name
.as_deref()
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
if d.enabled && !is_ours && !managed {
if let (Some(name), Some(proxy)) = (d.name.clone(), d.proxy.clone()) {
let spec = sess.current_dims(d).map(mode_spec).unwrap_or_default();
to_disable.push((proxy, name, spec));
}
}
}
}
// Build one configuration: ensure ours is enabled, take primary (unless a sibling holds it),
// disable the others. `apply()` is atomic — KWin re-homes the shell onto the remaining desktop.
let config = sess.new_config();
if let Some(proxy) = ours.proxy.as_ref() {
config.enable(proxy, 1);
if !sibling_is_primary {
config.set_primary_output(proxy);
}
}
for (proxy, _, _) in &to_disable {
config.enable(proxy, 0);
}
let applied = sess.apply(&config, deadline);
config.destroy();
// Always report the outputs we ASKED to disable so teardown restores them: re-enabling an output
// that never actually got disabled is a harmless no-op, whereas dropping them here would strand a
// physical dark if KWin processed the disable but the `applied` ack didn't land in budget.
let disabled: Vec<(String, String)> = to_disable
.into_iter()
.map(|(_, name, spec)| (name, spec))
.collect();
if applied {
tracing::info!(
also_disabled = ?disabled,
primary_taken = !sibling_is_primary,
"KWin output management: streamed output set as the desktop (in-process)"
);
} else {
tracing::warn!(
reason = ?sess.state.failure_reason,
also_disabled = ?disabled,
"KWin output management: apply() not confirmed in budget — proceeding (restore will re-enable)"
);
}
// We resolved our output and drove the config over Wayland; don't ALSO run kscreen-doctor — that
// would double-apply (and on a wedged box it would just add 26 s of timeouts). `handled` is true
// even on an unconfirmed apply; a genuinely absent management global / unresolved output took the
// `handled = false` early returns above and falls back to kscreen-doctor.
TopologyOutcome {
our_uuid,
disabled,
handled: true,
}
}
/// Re-enable outputs by name at their captured `WxH@Hz` modes (teardown), in-process. Returns
/// `true` if the config applied; `false` (compositor unresponsive / management absent) tells the
/// caller to fall back to `kscreen-doctor`.
pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
if outputs.is_empty() {
return true;
}
let Some(mut sess) = Session::open() else {
return false;
};
let deadline = Instant::now() + OP_BUDGET;
let config = sess.new_config();
for (name, spec) in outputs {
// Find the device by name (physical names are stable across a session).
let Some(dev) = sess
.state
.devices
.values()
.find(|d| d.name.as_deref() == Some(name.as_str()))
.cloned()
else {
continue;
};
let Some(proxy) = dev.proxy.as_ref() else {
continue;
};
// Enable first — a bare enable always succeeds, so a physical is never left dark.
config.enable(proxy, 1);
// Then re-assert the captured mode so a 120 Hz panel doesn't return at KWin's ~60 Hz default.
if let Some(mode) = find_mode(&sess, &dev, spec) {
config.mode(proxy, &mode);
}
}
let ok = sess.apply(&config, deadline);
config.destroy();
if ok {
tracing::info!(reenabled = ?outputs, "KWin output management: restored outputs (in-process)");
}
ok
}
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
let Some(mut sess) = Session::open() else {
return false;
};
let deadline = Instant::now() + OP_BUDGET;
let Some(dev) = sess
.state
.devices
.values()
.find(|d| d.uuid.as_deref() == Some(uuid))
.cloned()
else {
return false;
};
let Some(proxy) = dev.proxy.as_ref() else {
return false;
};
let config = sess.new_config();
config.position(proxy, x, y);
let ok = sess.apply(&config, deadline);
config.destroy();
if ok {
tracing::info!(
uuid,
x,
y,
"KWin output management: placed output (in-process)"
);
}
ok
}
/// Find a device's advertised mode proxy matching a captured `"WxH@Hz"` spec (Hz rounded), for
/// restore. `None` if the spec is empty or no mode matches (the caller then enables without a mode
/// and lets KWin pick its preferred one).
fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode> {
if spec.is_empty() {
return None;
}
let (wh, hz) = spec.split_once('@')?;
let (w, h) = wh.split_once('x')?;
let (w, h, hz): (u32, u32, u32) = (w.parse().ok()?, h.parse().ok()?, hz.parse().ok()?);
dev.modes.iter().find_map(|(id, proxy)| {
let (mw, mh, mmhz) = sess.state.mode_dims.get(id).copied()?;
let mhz = ((mmhz as f64) / 1000.0).round() as u32;
((mw, mh, mhz) == (w, h, hz)).then(|| proxy.clone())
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
#[test]
fn mode_spec_rounds_millihertz() {
assert_eq!(mode_spec((2560, 1440, 59940)), "2560x1440@60");
assert_eq!(mode_spec((1920, 1080, 60000)), "1920x1080@60");
assert_eq!(mode_spec((3840, 2160, 119880)), "3840x2160@120");
}
/// The vendored device XML must keep `mode` at the opcode the `event_created_child!` macro
/// hardcodes — a reorder there would bind the child to the wrong event and desync mode sizes.
#[test]
fn mode_event_opcode_is_two() {
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
}
}