From 358cfa4be476e37659a143f8a53be68654624fb5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 20:54:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(host):=20mirror-test=20=E2=80=94=20the=20o?= =?UTF-8?q?n-glass=20gate=20for=20per-monitor=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens the display backend exactly as a session would, attaches a capturer and reports the frames a named head actually produces, with no client involved. `--monitor` names the head explicitly (it cannot go through PUNKTFUNK_CAPTURE_MONITOR: pf_host_config parses the environment once and startup already read it, so a tool setting it for itself would still see the old snapshot — hence vdisplay::open_mirror); with no argument it exercises the production routing through the pin. A frame timeout is NOT treated as failure. Compositor capture is damage-driven — the host's own capture diag logs new_fps=0 for virtual outputs on an idle desktop for exactly this reason — so the first version of this tool ended its measurement on the first idle gap and made a working mirror look like a 2-frame stall. Verified on KWin 6.7.3 (home-nobara-1), mirroring HDMI-A-1: 346 frames in 20s at 1920x1080, arriving while input was injected and pausing when the desktop went quiet, on both the CPU (BGRx/MemFd) and zero-copy (NV12 dmabuf) paths, ownership=External. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-vdisplay/src/lib.rs | 12 +++ crates/punktfunk-host/src/devtest.rs | 122 +++++++++++++++++++++++++++ crates/punktfunk-host/src/main.rs | 4 + 3 files changed, 138 insertions(+) diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 111a6f3c..f8a3cae3 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -333,6 +333,18 @@ pub fn open(compositor: Compositor) -> Result> { } } +/// Open the **mirror** backend for a specific monitor, bypassing the `PUNKTFUNK_CAPTURE_MONITOR` +/// pin that [`open`] consults. For tools that name the head explicitly (`punktfunk-host +/// mirror-test`) — the pin can't serve them, since `pf_host_config` parses the environment once at +/// startup, so a tool setting the variable for itself would be reading a snapshot taken before it. +#[cfg(target_os = "linux")] +pub fn open_mirror(compositor: Compositor, connector: &str) -> Result> { + Ok(Box::new(mirror::MirrorDisplay::new( + compositor, + connector.to_string(), + )?)) +} + /// Readiness probe for `compositor`: is it up and able to create a virtual output *right /// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate /// on actual readiness instead of racing the compositor with a blind sleep. diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 7c5f2192..1454cfe6 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -475,3 +475,125 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { println!("dualsense-windows-test: done (devnode removed)"); Ok(()) } + +/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture +/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect. +/// +/// Opens the display backend exactly as a session would (so a `PUNKTFUNK_CAPTURE_MONITOR` pin +/// routes to the mirror backend), attaches a capturer to whatever PipeWire node comes back, and +/// reports the frames it actually receives. What it proves that a unit test cannot: the compositor +/// accepted the record request for a NAMED head, and that head is producing pixels at its own size. +/// +/// `--monitor ` pins for this run (else `PUNKTFUNK_CAPTURE_MONITOR`); `--seconds N`. +#[cfg(target_os = "linux")] +pub fn mirror_test(args: &[String]) -> Result<()> { + use std::time::{Duration, Instant}; + let arg = |name: &str| { + args.iter() + .skip_while(|a| a.as_str() != name) + .nth(1) + .cloned() + }; + let secs: u64 = arg("--seconds").and_then(|s| s.parse().ok()).unwrap_or(5); + // `--monitor` cannot work by setting PUNKTFUNK_CAPTURE_MONITOR here: pf_host_config parses the + // environment ONCE and startup already read it, so this process would still see the old + // snapshot. An explicit connector therefore goes through `open_mirror` below; only the unset + // case falls back to the pin (and to `open`, which is the production routing). + let explicit = arg("--monitor"); + let want = explicit + .clone() + .or_else(|| pf_host_config::config().capture_monitor.clone()) + .context( + "no monitor named — pass --monitor or set PUNKTFUNK_CAPTURE_MONITOR", + )?; + + let compositor = crate::vdisplay::detect()?; + let monitors = crate::vdisplay::monitors::list(compositor)?; + let target = crate::vdisplay::monitors::resolve(&monitors, &want)?; + println!( + "mirror-test: {compositor:?} {} ({}) at +{},+{}", + target.connector, + target.mode_label(), + target.x, + target.y + ); + + // No `--monitor` ⇒ exercise the PRODUCTION routing (`open` consulting the pin), which is the + // more valuable path to prove; an explicit connector takes the direct opener. + let mut vd = match &explicit { + Some(connector) => crate::vdisplay::open_mirror(compositor, connector)?, + None => crate::vdisplay::open(compositor)?, + }; + // The mode is ignored by the mirror backend (a panel runs at the mode its owner set); pass the + // head's own so this also behaves if the pin is ever unset mid-test. + let mode = crate::vdisplay::Mode { + width: target.width, + height: target.height, + refresh_hz: 60, + }; + let vout = vd.create(mode).context("open the mirror display")?; + println!( + "mirror-test: node_id={} preferred={:?} ownership={:?}", + vout.node_id, vout.preferred_mode, vout.ownership + ); + + // Default to the GPU (dmabuf zero-copy) path a real session uses; `--cpu` forces the mmap + // path, which is worth having as a switch — the two negotiate different PipeWire buffer types. + let gpu = !args.iter().any(|a| a == "--cpu"); + let fmt = pf_frame::OutputFormat::resolve(false, gpu); + println!( + "mirror-test: capture path = {}", + if gpu { "gpu/dmabuf" } else { "cpu/mmap" } + ); + let mut cap = crate::capture::capture_virtual_output( + vout, + fmt, + crate::session_plan::CaptureBackend::resolve(), + ) + .context("attach a capturer to the mirrored monitor")?; + cap.set_active(true); + + let deadline = Instant::now() + Duration::from_secs(secs); + let (mut frames, mut first) = (0u32, None); + let mut idle = 0u32; + let mut dims = (0u32, 0u32); + while Instant::now() < deadline { + match cap.next_frame_within(Duration::from_secs(5)) { + Ok(f) => { + if first.is_none() { + first = Some(Instant::now()); + println!( + "mirror-test: FIRST FRAME {}x{} {:?}", + f.width, f.height, f.format + ); + } + dims = (f.width, f.height); + frames += 1; + } + // A timeout is NOT fatal here: compositor screencast is damage-driven, so a static + // desktop legitimately produces nothing for seconds at a time (the host's own capture + // diag logs `new_fps=0` for virtual outputs on an idle desktop for the same reason). + // Keep waiting until the deadline instead of ending the measurement on the first gap. + Err(e) => { + idle += 1; + if idle == 1 { + println!("mirror-test: (idle — no damage yet: {e:#})"); + } + } + } + } + match first { + Some(_) => println!( + "mirror-test: OK — {frames} frames in {secs}s at {}x{} ({:.1} fps over the whole run, \ + {idle} idle gaps). Compositor capture is damage-driven: a static desktop produces \ + nothing, so judge this by whether frames track what is happening on screen.", + dims.0, + dims.1, + frames as f64 / secs as f64 + ), + None => { + anyhow::bail!("no frames arrived in {secs}s — the cast started but produced nothing") + } + } + Ok(()) +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 1cb77da6..65deb759 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -506,6 +506,10 @@ fn real_main() -> Result<()> { } Ok(()) } + // Mirror a pinned physical monitor and pull frames from it — the per-monitor capture + // on-glass gate, with no client involved. + #[cfg(target_os = "linux")] + Some("mirror-test") => devtest::mirror_test(&args), // Create a virtual DualSense via UHID and exercise it (validation, no streaming session). #[cfg(target_os = "linux")] Some("dualsense-test") => devtest::dualsense_test(&args),