fix(linux/vdisplay): KWin virtual outputs stream above 60 Hz via sacrificial-birth renegotiation

KWin's ScreenCastStream builds its PipeWire format offer — including the
maxFramerate cap it actively throttles delivery to — ONCE at stream
creation, when the virtual output sits at its hardcoded birth 60 Hz. A
kscreen custom-mode change afterwards updates the OUTPUT (readback says
240) but never the offer, so every consumer negotiates max=60 and the
stream delivers 60 (pw-dump-verified on KWin 6.6.4). The only path that
rebuilds the offer is the stream's own resize handling: a source SIZE
change while recording re-runs buildFormats — picking up the output's
current refresh — and renegotiates the live stream.

So above 60 Hz, birth the output at a sacrificial height (+16), then
install + select the real WxH@hz custom mode: the first frame recorded
after the consumer connects triggers KWin's resize path, which renegotiates
the live stream to WxH@hz. The capturer holds (requeues) buffers until the
negotiated size matches — new expect_exact_dims plumbing VirtualOutput →
registry (fresh creates only) → open_virtual_output → a self-disarming
gate in the process callback — bounded by a 3 s deadline that accepts the
producer's dims rather than wedging the session into the first-frame
retry loop. set_custom_refresh reads back the full active mode; a size
reject (pre-6.6 KWin) recreates plain at the real size @60.

Every kscreen operation now addresses the output by its NUMERIC id,
resolved by matching the managed name-prefix AND the just-created birth
size (newest id wins): a mode-switch supersede reuses the per-slot output
NAME (deliberately, for KWin's per-name config persistence) while the
superseded sibling is still alive, and name addressing hit the FIRST
match = the OLD output — on-glass that resized the live session's display
out from under it (wrong-res/black), read the old output back as 'mode
applied', and set the old output primary while the replacement starved at
its birth mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:47:53 +02:00
co-authored by Claude Fable 5
parent 09113c9899
commit af87549052
9 changed files with 339 additions and 89 deletions
+2
View File
@@ -453,6 +453,7 @@ pub fn open_virtual_output(
allow_zerocopy: bool, allow_zerocopy: bool,
want_444: bool, want_444: bool,
policy: ZeroCopyPolicy, policy: ZeroCopyPolicy,
expect_exact_dims: bool,
) -> Result<Box<dyn Capturer>> { ) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::from_virtual_output( linux::PortalCapturer::from_virtual_output(
remote_fd, remote_fd,
@@ -462,6 +463,7 @@ pub fn open_virtual_output(
allow_zerocopy, allow_zerocopy,
want_444, want_444,
policy, policy,
expect_exact_dims,
) )
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
+97 -4
View File
@@ -120,10 +120,17 @@ impl PortalCapturer {
"ScreenCast portal session started; connecting PipeWire" "ScreenCast portal session started; connecting PipeWire"
); );
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before. // This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
Ok( Ok(spawn_pipewire(
spawn_pipewire(Some(fd), node_id, None, true, false, want_hdr, policy)? Some(fd),
.into_capturer(node_id, None), node_id,
) None,
true,
false,
want_hdr,
policy,
false,
)?
.into_capturer(node_id, None))
} }
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade /// Build a capturer from an already-created virtual output's PipeWire node. The host facade
@@ -143,11 +150,13 @@ impl PortalCapturer {
allow_zerocopy: bool, allow_zerocopy: bool,
want_444: bool, want_444: bool,
policy: ZeroCopyPolicy, policy: ZeroCopyPolicy,
expect_exact_dims: bool,
) -> Result<PortalCapturer> { ) -> Result<PortalCapturer> {
tracing::info!( tracing::info!(
node_id, node_id,
allow_zerocopy, allow_zerocopy,
want_444, want_444,
expect_exact_dims,
"connecting PipeWire to virtual output" "connecting PipeWire to virtual output"
); );
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit // Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
@@ -160,6 +169,7 @@ impl PortalCapturer {
want_444, want_444,
false, false,
policy, policy,
expect_exact_dims,
)? )?
.into_capturer(node_id, Some(keepalive))) .into_capturer(node_id, Some(keepalive)))
} }
@@ -233,6 +243,12 @@ fn spawn_pipewire(
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way // Encode-backend facts resolved by the facade (never re-derived here) — the one-way
// capture→encode edge (plan §W6). // capture→encode edge (plan §W6).
policy: ZeroCopyPolicy, policy: ZeroCopyPolicy,
// The producer's FIRST negotiation is for a sacrificial mode and a renegotiation to
// `preferred`'s dims is guaranteed to follow (KWin virtual outputs — see kwin.rs `create`):
// skip whole buffers until the negotiated size matches, so the pipeline never builds against
// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation,
// gamescope fixates its own — gating those would starve legitimate first frames).
expect_exact_dims: bool,
) -> Result<PwHandles> { ) -> Result<PwHandles> {
// Frames flow from the pipewire thread over a small bounded channel. // Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8); let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
@@ -290,6 +306,7 @@ fn spawn_pipewire(
preferred, preferred,
quit_rx, quit_rx,
policy, policy,
expect_exact_dims,
) { ) {
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
} }
@@ -967,6 +984,18 @@ mod pipewire {
/// LIVE overlay slot shared with [`super::PortalCapturer::cursor_live`] — refreshed after /// LIVE overlay slot shared with [`super::PortalCapturer::cursor_live`] — refreshed after
/// every `update_cursor_meta`, including from cursor-only buffers that never become frames. /// every `update_cursor_meta`, including from cursor-only buffers that never become frames.
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>, cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
/// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a
/// renegotiation to these dims is guaranteed (KWin virtual outputs — kwin.rs `create`):
/// `.process` skips whole buffers until the negotiated size matches, then clears this
/// (self-disarming — later legitimate resizes are unaffected). `None` = no gating.
expect_dims: Option<(u32, u32)>,
/// Buffers skipped by the `expect_dims` gate (rate-limits its log).
gate_skips: u64,
/// When the gate first held a buffer — after [`GATE_DEADLINE`] with no renegotiation the
/// gate disarms and accepts what the producer serves (degraded dims beat a session wedged
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
/// within a frame or two).
gate_since: Option<std::time::Instant>,
} }
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before /// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
@@ -2101,6 +2130,9 @@ mod pipewire {
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way // Encode-backend facts resolved by the facade (never re-derived here) — the one-way
// capture→encode edge (plan §W6). // capture→encode edge (plan §W6).
policy: ZeroCopyPolicy, policy: ZeroCopyPolicy,
// See `spawn_pipewire`: the first negotiation is for a sacrificial mode; hold frames
// until the producer renegotiates to `preferred`'s dims.
expect_exact_dims: bool,
) -> Result<()> { ) -> Result<()> {
crate::pwinit::ensure_init(); crate::pwinit::ensure_init();
@@ -2286,6 +2318,13 @@ mod pipewire {
dbg_log_n: 0, dbg_log_n: 0,
cursor: CursorState::default(), cursor: CursorState::default(),
cursor_live, cursor_live,
expect_dims: if expect_exact_dims {
preferred.map(|(w, h, _)| (w, h))
} else {
None
},
gate_skips: 0,
gate_since: None,
}; };
let stream = pw::stream::StreamBox::new( let stream = pw::stream::StreamBox::new(
@@ -2397,6 +2436,60 @@ mod pipewire {
newest = next; newest = next;
drained += 1; drained += 1;
} }
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
// expected dims, every buffer — frame AND cursor meta, whose positions are in the
// doomed mode's space — belongs to the birth mode; consuming one would build the
// pipeline at the wrong size. Self-disarms on the first matching negotiation, or
// after `GATE_DEADLINE` without one — degraded dims beat wedging the session into
// the first-frame-timeout retry loop when the promised renegotiation never comes.
if let Some((ew, eh)) = ud.expect_dims {
/// The renegotiation normally lands within a frame or two of recording; well
/// past that, the producer is not going to deliver it (the on-glass case: the
/// real mode never actually applied) — stop starving the pipeline.
const GATE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(3);
let sz = ud.info.size();
if sz.width == ew && sz.height == eh {
tracing::info!(
skipped = ud.gate_skips,
width = ew,
height = eh,
"producer renegotiated to the expected mode — frames flow"
);
ud.expect_dims = None;
} else if ud
.gate_since
.get_or_insert_with(std::time::Instant::now)
.elapsed()
> GATE_DEADLINE
{
tracing::warn!(
negotiated_w = sz.width,
negotiated_h = sz.height,
expected_w = ew,
expected_h = eh,
skipped = ud.gate_skips,
"producer never renegotiated to the expected mode — accepting its \
dims (session runs degraded rather than wedged)"
);
ud.expect_dims = None;
} else {
ud.gate_skips += 1;
if ud.gate_skips == 1 || ud.gate_skips.is_power_of_two() {
tracing::info!(
negotiated_w = sz.width,
negotiated_h = sz.height,
expected_w = ew,
expected_h = eh,
n = ud.gate_skips,
"holding frames until the producer renegotiates to the expected mode"
);
}
// SAFETY: `newest` was dequeued from this stream and not yet requeued;
// requeued exactly once here, then never touched (mirrors the null path).
unsafe { stream.queue_raw_buffer(newest) };
return;
}
}
// PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI // PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI
// boundary would abort the whole host. Contain the inspect/consume work — the only Rust // boundary would abort the whole host. Contain the inspect/consume work — the only Rust
// code here that can panic — and requeue `newest` unconditionally after it. // code here that can panic — and requeue `newest` unconditionally after it.
@@ -70,6 +70,12 @@ pub struct VirtualOutput {
/// Linux-only (the keep-alive pool is Linux). /// Linux-only (the keep-alive pool is Linux).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub pool_gen: Option<u64>, pub pool_gen: Option<u64>,
/// The backend created the output at a SACRIFICIAL mode and the producer will renegotiate the
/// live stream to `preferred_mode`'s dims (KWin's screencast only rebuilds its format offer —
/// and thus its refresh cap — on a size change while recording; see kwin.rs `create`). The
/// capturer must hold frames until that renegotiation lands. Linux-only.
#[cfg(target_os = "linux")]
pub expect_exact_dims: bool,
} }
impl VirtualOutput { impl VirtualOutput {
@@ -93,6 +99,8 @@ impl VirtualOutput {
reused_gen: None, reused_gen: None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pool_gen: None, pool_gen: None,
#[cfg(target_os = "linux")]
expect_exact_dims: false,
} }
} }
} }
@@ -253,6 +253,7 @@ impl VirtualDisplay for GamescopeDisplay {
ownership: DisplayOwnership::External, ownership: DisplayOwnership::External,
reused_gen: None, reused_gen: None,
pool_gen: None, pool_gen: None,
expect_exact_dims: false,
}); });
} }
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
@@ -409,6 +410,7 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
ownership: DisplayOwnership::SessionManaged, ownership: DisplayOwnership::SessionManaged,
reused_gen: None, reused_gen: None,
pool_gen: None, pool_gen: None,
expect_exact_dims: false,
} }
} }
@@ -199,6 +199,7 @@ impl VirtualDisplay for HyprlandDisplay {
ownership: DisplayOwnership::Owned, ownership: DisplayOwnership::Owned,
reused_gen: None, reused_gen: None,
pool_gen: None, pool_gen: None,
expect_exact_dims: false,
}) })
} }
} }
+199 -64
View File
@@ -82,8 +82,10 @@ pub struct KwinDisplay {
/// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key /// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key
/// the group arrangement + `/display/state` slot to the same id this backend named the output with. /// the group arrangement + `/display/state` slot to the same id this backend named the output with.
last_slot: Option<u32>, last_slot: Option<u32>,
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so /// The RESOLVED kscreen address of the last `create`'s output — the numeric kscreen output id
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`. /// when [`resolve_kscreen_addr`] found it, else the `Virtual-<name>` fallback — so
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
/// superseded same-name sibling is still alive.
last_name: Option<String>, last_name: Option<String>,
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive` /// 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 /// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
@@ -127,11 +129,13 @@ impl VirtualDisplay for KwinDisplay {
} }
fn apply_position(&mut self, x: i32, y: i32) { fn apply_position(&mut self, x: i32, y: i32) {
let Some(name) = self.last_name.clone() else { // `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; return;
}; };
let output = format!("Virtual-{name}"); // kscreen-doctor position syntax: `output.<name-or-id>.position.<x>,<y>`.
// kscreen-doctor position syntax: `output.<name>.position.<x>,<y>`.
let ok = std::process::Command::new("kscreen-doctor") let ok = std::process::Command::new("kscreen-doctor")
.arg(format!("output.{output}.position.{x},{y}")) .arg(format!("output.{output}.position.{x},{y}"))
.status() .status()
@@ -161,32 +165,86 @@ impl VirtualDisplay for KwinDisplay {
None => VOUT_NAME.to_string(), None => VOUT_NAME.to_string(),
}; };
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout) self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
let (width, height) = (mode.width, mode.height);
let spawn_vout = |w: u32, h: u32| -> Result<(u32, Arc<AtomicBool>)> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>(); let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
let (width, height) = (mode.width, mode.height);
let name_thread = name.clone(); let name_thread = name.clone();
thread::Builder::new() thread::Builder::new()
.name("punktfunk-kwin-vout".into()) .name("punktfunk-kwin-vout".into())
.spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread)) .spawn(move || virtual_output_thread(w, h, name_thread, setup_tx, stop_thread))
.context("spawn KWin virtual-output thread")?; .context("spawn KWin virtual-output thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { Ok(Ok(v)) => Ok((v, stop)),
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("KWin virtual output failed: {e}"), Ok(Err(e)) => bail!("KWin virtual output failed: {e}"),
Err(_) => bail!("timed out creating the KWin virtual output"), Err(_) => bail!("timed out creating the KWin virtual output"),
}
}; };
tracing::info!(node_id, width, height, "KWin virtual output ready"); // KWin creates virtual outputs at a hardcoded 60 Hz, `stream_virtual_output` has no
// KWin creates virtual outputs at a hardcoded 60 Hz and `stream_virtual_output` has no // refresh argument — and its screencast stream builds its PipeWire format offer, INCLUDING
// refresh argument, so above 60 Hz we install + select a custom mode (supported on virtual // the `maxFramerate` cap it actively throttles delivery to, ONCE at stream creation
// outputs since KWin 6.6) before capture connects PipeWire, so the stream negotiates at the // (screencaststream.cpp `buildFormats`, verified against the live offer with `pw-dump`:
// higher rate. First cut shells out to kscreen-doctor; the in-process // the offer stays `max=60` after a kscreen mode change, and consumers connecting later
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back and // still negotiate against it). The ONLY path that rebuilds the offer is the stream's own
// returns what KWin *actually* achieved so the encoder paces to the real source rate (a // resize handling: when the source's texture size changes while recording, KWin re-runs
// rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install — // `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live
// the source runs 60 Hz and the encoder downsamples — so carry the requested rate through. // stream via `pw_stream_update_params`. So above 60 Hz the output is born at a
let achieved_hz = if mode.refresh_hz > 60 { // SACRIFICIAL height: installing + selecting the real `WxH@hz` custom mode (supported on
set_custom_refresh(width, height, mode.refresh_hz, &name) // virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
// after the consumer connects trigger KWin's resize → a renegotiation to `WxH@hz`. The
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what
// KWin *actually* gave so the encoder paces to the real source rate. At ≤60 Hz there's
// nothing to install — the output is born at the real size and 60 Hz is the offer anyway.
let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
tracing::info!(node_id, width, height, birth_h, "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)
let mut expect_exact_dims = false;
let achieved_hz = if want_high {
let (achieved, size_applied) =
set_custom_refresh(width, height, mode.refresh_hz, &addr);
if size_applied {
// Real mode selected: the recording stream will renegotiate to it (see above).
expect_exact_dims = true;
achieved
} else {
// Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the
// output is STUCK at the sacrificial birth size — unusable. Recreate plain at the
// real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz).
tracing::warn!(
"KWin rejected the custom mode — recreating the virtual output at the real \
size (60 Hz ceiling on this KWin)"
);
stop.store(true, Ordering::Relaxed);
// Let KWin retire the doomed output before re-using its name.
std::thread::sleep(Duration::from_millis(300));
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,
height,
"KWin virtual output ready (fallback)"
);
60
}
} else { } else {
mode.refresh_hz mode.refresh_hz
}; };
@@ -197,9 +255,9 @@ impl VirtualDisplay for KwinDisplay {
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
use crate::policy::Topology; use crate::policy::Topology;
let disabled = match crate::effective_topology() { let disabled = match crate::effective_topology() {
Topology::Exclusive => apply_virtual_primary(&name), Topology::Exclusive => apply_virtual_primary(&addr),
Topology::Primary => { Topology::Primary => {
apply_virtual_primary_only(&name); apply_virtual_primary_only(&addr);
Vec::new() // nothing disabled → nothing to restore Vec::new() // nothing disabled → nothing to restore
} }
Topology::Extend | Topology::Auto => Vec::new(), Topology::Extend | Topology::Auto => Vec::new(),
@@ -215,11 +273,13 @@ impl VirtualDisplay for KwinDisplay {
}); });
// Layout position (§6.2) is applied by the registry via `apply_position` right after create // 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). // (it owns the display group, so it computes auto-row / manual placement over the whole group).
Ok(VirtualOutput::owned( let mut out = VirtualOutput::owned(
node_id, node_id,
Some((mode.width, mode.height, achieved_hz)), Some((mode.width, mode.height, achieved_hz)),
Box::new(StopGuard { stop }), Box::new(StopGuard { stop }),
)) );
out.expect_exact_dims = expect_exact_dims;
Ok(out)
} }
} }
@@ -257,14 +317,88 @@ fn reenable_outputs(outputs: &[(String, String)]) {
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)"); tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
} }
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by /// Resolve the kscreen address of the virtual output the host JUST created: the managed-prefix
/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`, /// name alone is ambiguous during a supersede (the replacement deliberately reuses the per-slot
/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually /// name while the superseded sibling is still alive), so match on the birth mode's size too —
/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected), /// only the just-created output sits at the sacrificial `(w, h)` — and prefer the HIGHEST output
/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller /// id (the newest) if several match. Returns the numeric id as a string (kscreen-doctor accepts
/// paces the encoder to the *achieved* rate, not the requested one. /// `output.<id>.…`), falling back to the ambiguous `Virtual-<name>` if the output hasn't reached
fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 { /// kscreen's model yet after a few tries (single-output sessions are unambiguous anyway).
let output = format!("Virtual-{name}"); fn resolve_kscreen_addr(name: &str, w: u32, h: u32) -> String {
let fallback = format!("Virtual-{name}");
for attempt in 0..3 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(150));
}
let Some(doc) = kscreen_json() else { continue };
let Some(outputs) = doc.get("outputs").and_then(|o| o.as_array()) else {
continue;
};
let best = outputs
.iter()
.filter(|o| {
o.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.starts_with(&fallback))
&& output_active_size(o) == Some((w, h))
})
.filter_map(|o| o.get("id").and_then(|i| i.as_u64()))
.max();
if let Some(id) = best {
tracing::info!(id, name, w, h, "KWin: resolved the new output's kscreen id");
return id.to_string();
}
}
tracing::warn!(
name,
w,
h,
"KWin: could not resolve the new output's kscreen id — falling back to name addressing \
(ambiguous during a mode-switch supersede)"
);
fallback
}
/// `kscreen-doctor -j` parsed, `None` on any failure.
fn kscreen_json() -> Option<serde_json::Value> {
let out = std::process::Command::new("kscreen-doctor")
.arg("-j")
.output()
.ok()?;
serde_json::from_slice(&out.stdout).ok()
}
/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry.
fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
let as_id = |v: &serde_json::Value| -> Option<String> {
v.as_str()
.map(|s| s.to_string())
.or_else(|| v.as_u64().map(|n| n.to_string()))
};
let current = o.get("currentModeId").and_then(as_id)?;
let mode = o
.get("modes")?
.as_array()?
.iter()
.find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?;
let size = mode.get("size")?;
Some((
size.get("width").and_then(|v| v.as_u64())? as u32,
size.get("height").and_then(|v| v.as_u64())? as u32,
))
}
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created
/// virtual output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or
/// name, see [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode**
/// and return `(achieved_hz, size_applied)`. The apply command can report success yet leave the
/// output on its old mode (rejected), and a silent rate mismatch surfaces downstream as judder /
/// duplicated frames — so the caller paces the encoder to the *achieved* rate, not the requested
/// one. `size_applied` tells the sacrificial-birth caller (see `create`) whether the SIZE half of
/// the mode actually landed — that, not the refresh, is what triggers KWin's stream
/// renegotiation.
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, bool) {
let output = output.to_string();
let mhz = hz.saturating_mul(1000); let mhz = hz.saturating_mul(1000);
let run = |arg: String| { let run = |arg: String| {
std::process::Command::new("kscreen-doctor") std::process::Command::new("kscreen-doctor")
@@ -278,26 +412,29 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full" "output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
)); ));
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}")); let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}"));
match read_active_refresh(&output) { match read_active_mode(&output) {
Some(achieved) if achieved >= hz => { Some((w, h, achieved)) => {
let size_applied = (w, h) == (width, height);
if achieved >= hz && size_applied {
tracing::info!( tracing::info!(
output, output,
requested = hz, requested = hz,
achieved, achieved,
"KWin virtual output: custom refresh applied" "KWin virtual output: custom refresh applied"
); );
achieved } else {
}
Some(achieved) => {
tracing::warn!( tracing::warn!(
output, output,
requested = hz, requested = hz,
achieved, achieved,
active_w = w,
active_h = h,
applied, applied,
"KWin virtual output refresh below requested — pacing the encoder to the achieved \ "KWin virtual output mode below requested — pacing the encoder to the \
rate (custom-mode install rejected? is kscreen-doctor up to date?)" achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)"
); );
achieved.max(1) }
(achieved.max(1), size_applied)
} }
None => { None => {
tracing::warn!( tracing::warn!(
@@ -307,38 +444,37 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
"could not read back KWin virtual output refresh — assuming 60 Hz (is \ "could not read back KWin virtual output refresh — assuming 60 Hz (is \
kscreen-doctor installed?)" kscreen-doctor installed?)"
); );
60 (60, false)
} }
} }
} }
/// Read the active refresh (Hz, rounded) of `output` from `kscreen-doctor -j`. `None` if the /// Read the active mode (`(width, height, refresh_hz)`, Hz rounded) of `output` — a RESOLVED
/// tool, the output, or its current mode can't be found. Mode/output ids come through as either /// kscreen address (numeric id or name, see [`resolve_kscreen_addr`]) — from `kscreen-doctor -j`.
/// JSON strings or numbers depending on the KWin version, so both are accepted. /// `None` if the tool, the output, or its current mode can't be found. Mode/output ids come
fn read_active_refresh(output: &str) -> Option<u32> { /// through as either JSON strings or numbers depending on the KWin version, so both are accepted.
let out = std::process::Command::new("kscreen-doctor") fn read_active_mode(output: &str) -> Option<(u32, u32, u32)> {
.arg("-j") let doc = kscreen_json()?;
.output()
.ok()?;
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let as_id = |v: &serde_json::Value| -> Option<String> { let as_id = |v: &serde_json::Value| -> Option<String> {
v.as_str() v.as_str()
.map(|s| s.to_string()) .map(|s| s.to_string())
.or_else(|| v.as_u64().map(|n| n.to_string())) .or_else(|| v.as_u64().map(|n| n.to_string()))
}; };
let o = doc let o = doc.get("outputs")?.as_array()?.iter().find(|o| {
.get("outputs")? o.get("name").and_then(|n| n.as_str()) == Some(output)
.as_array()? || o.get("id").and_then(as_id).as_deref() == Some(output)
.iter() })?;
.find(|o| o.get("name").and_then(|n| n.as_str()) == Some(output))?;
let current = o.get("currentModeId").and_then(as_id)?; let current = o.get("currentModeId").and_then(as_id)?;
let mode = o let mode = o
.get("modes")? .get("modes")?
.as_array()? .as_array()?
.iter() .iter()
.find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?; .find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?;
let size = mode.get("size")?;
let w = size.get("width").and_then(|v| v.as_u64())? as u32;
let h = size.get("height").and_then(|v| v.as_u64())? as u32;
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?; let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?;
Some(hz.round() as u32) Some((w, h, hz.round() as u32))
} }
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`, /// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
@@ -377,7 +513,7 @@ fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
/// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz). /// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz).
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this /// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables /// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]). /// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_mode`]).
fn other_enabled_outputs() -> Vec<(String, String)> { fn other_enabled_outputs() -> Vec<(String, String)> {
let out = match std::process::Command::new("kscreen-doctor") let out = match std::process::Command::new("kscreen-doctor")
.arg("-j") .arg("-j")
@@ -440,12 +576,12 @@ fn a_managed_output_is_primary() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes /// Set our output primary and disable the bootstrap output(s) so the managed group becomes
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for /// the sole desktop (KWin re-homes plasmashell + windows onto it). `ours` is the RESOLVED kscreen
/// address (numeric id or name, see [`resolve_kscreen_addr`]). Returns the disabled outputs for
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly /// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// showing only the wallpaper) rather than failing the session. /// showing only the wallpaper) rather than failing the session.
fn apply_virtual_primary(name: &str) -> Vec<(String, String)> { fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
let ours = format!("Virtual-{name}");
let kscreen = |args: &[String]| { let kscreen = |args: &[String]| {
std::process::Command::new("kscreen-doctor") std::process::Command::new("kscreen-doctor")
.args(args) .args(args)
@@ -483,8 +619,7 @@ fn apply_virtual_primary(name: &str) -> Vec<(String, String)> {
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled /// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a /// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing). /// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
fn apply_virtual_primary_only(name: &str) { fn apply_virtual_primary_only(ours: &str) {
let ours = format!("Virtual-{name}");
let ok = std::process::Command::new("kscreen-doctor") let ok = std::process::Command::new("kscreen-doctor")
.arg(format!("output.{ours}.primary")) .arg(format!("output.{ours}.primary"))
.status() .status()
@@ -136,6 +136,7 @@ impl VirtualDisplay for WlrootsDisplay {
ownership: DisplayOwnership::Owned, ownership: DisplayOwnership::Owned,
reused_gen: None, reused_gen: None,
pool_gen: None, pool_gen: None,
expect_exact_dims: false,
}) })
} }
} }
+8 -1
View File
@@ -584,6 +584,11 @@ mod linux {
let node_id = real.node_id; let node_id = real.node_id;
let preferred_mode = real.preferred_mode; let preferred_mode = real.preferred_mode;
// Fresh creates only: the backend may have birthed the output at a sacrificial mode whose
// stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept
// display already renegotiated in its prior session (the producer's rebuilt offer persists
// across consumer reconnects), so the reuse path above correctly leaves the flag off.
let expect_exact_dims = real.expect_exact_dims;
// The backend's topology-restore action (KWin `exclusive` → re-enable the disabled physicals), // The backend's topology-restore action (KWin `exclusive` → re-enable the disabled physicals),
// lifted into the group so it runs once when the group's last member drops (§6.1), not at this // lifted into the group so it runs once when the group's last member drops (§6.1), not at this
// session's teardown. `None` for non-exclusive / non-first / backends whose topology auto-reverts. // session's teardown. `None` for non-exclusive / non-first / backends whose topology auto-reverts.
@@ -647,7 +652,9 @@ mod linux {
if (position.x, position.y) != (0, 0) { if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y); vd.apply_position(position.x, position.y);
} }
Ok(output_for(node_id, preferred_mode, gen, quit, false)) let mut out = output_for(node_id, preferred_mode, gen, quit, false);
out.expect_exact_dims = expect_exact_dims;
Ok(out)
} }
/// The linger a releasing session actually gets. A deliberate quit (`force_immediate` — the /// The linger a releasing session actually gets. A deliberate quit (`force_immediate` — the
+1
View File
@@ -108,6 +108,7 @@ pub fn capture_virtual_output(
want.gpu, want.gpu,
want.chroma_444, want.chroma_444,
zero_copy_policy(want.pyrowave, want.nv12_native), zero_copy_policy(want.pyrowave, want.nv12_native),
vout.expect_exact_dims,
) )
} }