Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cca5008805 | |||
| f68f6bc590 | |||
| 7ab97bb1a3 |
@@ -2517,6 +2517,10 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"ddc_power_off": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||||
|
},
|
||||||
"game_session": {
|
"game_session": {
|
||||||
"$ref": "#/components/schemas/GameSession",
|
"$ref": "#/components/schemas/GameSession",
|
||||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||||
|
|||||||
@@ -565,6 +565,81 @@ impl Drop for DescriptorPoller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||||||
|
/// desktop was actively composing right beforehand (see [`StallWatch`]).
|
||||||
|
struct Stall {
|
||||||
|
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
||||||
|
gap: Duration,
|
||||||
|
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
||||||
|
/// [`crate::metronome::Metronome`]).
|
||||||
|
metronomic: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
||||||
|
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
||||||
|
/// path BELOW capture and only while no physical output is active).
|
||||||
|
///
|
||||||
|
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
||||||
|
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
||||||
|
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
||||||
|
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
|
||||||
|
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
||||||
|
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
||||||
|
/// below; the caller does the logging.
|
||||||
|
struct StallWatch {
|
||||||
|
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||||
|
recent: std::collections::VecDeque<Instant>,
|
||||||
|
cadence: crate::metronome::Metronome,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StallWatch {
|
||||||
|
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
|
||||||
|
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
|
||||||
|
const RECENT: usize = 8;
|
||||||
|
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
|
||||||
|
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
|
||||||
|
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
|
||||||
|
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
||||||
|
/// reported 300–700 ms freezes, above encode/present jitter.
|
||||||
|
const STALL_MIN: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||||
|
cadence: crate::metronome::Metronome::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||||||
|
/// the reset the first post-recreate frame would read as one).
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.recent.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
||||||
|
fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
|
||||||
|
let was_active = self.recent.len() == Self::RECENT
|
||||||
|
&& self
|
||||||
|
.recent
|
||||||
|
.back()
|
||||||
|
.zip(self.recent.front())
|
||||||
|
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
|
||||||
|
let gap = self.recent.back().map(|last| now.duration_since(*last));
|
||||||
|
self.recent.push_back(now);
|
||||||
|
if self.recent.len() > Self::RECENT {
|
||||||
|
self.recent.pop_front();
|
||||||
|
}
|
||||||
|
let gap = gap?;
|
||||||
|
if !was_active || gap < Self::STALL_MIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Stall {
|
||||||
|
gap,
|
||||||
|
metronomic: self.cadence.note(now),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct IddPushCapturer {
|
pub struct IddPushCapturer {
|
||||||
device: ID3D11Device,
|
device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
@@ -615,6 +690,10 @@ pub struct IddPushCapturer {
|
|||||||
last_liveness: Instant,
|
last_liveness: Instant,
|
||||||
/// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only).
|
/// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only).
|
||||||
last_kick: Instant,
|
last_kick: Instant,
|
||||||
|
/// Capture-stall watch (see [`StallWatch`]): flags multi-hundred-ms DWM composition holes
|
||||||
|
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||||
|
/// periodic-stutter diagnostic.
|
||||||
|
stall_watch: StallWatch,
|
||||||
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
||||||
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||||
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
||||||
@@ -995,6 +1074,7 @@ impl IddPushCapturer {
|
|||||||
last_fresh: Instant::now(),
|
last_fresh: Instant::now(),
|
||||||
last_liveness: Instant::now(),
|
last_liveness: Instant::now(),
|
||||||
last_kick: Instant::now(),
|
last_kick: Instant::now(),
|
||||||
|
stall_watch: StallWatch::new(),
|
||||||
out_ring: Vec::new(),
|
out_ring: Vec::new(),
|
||||||
out_idx: 0,
|
out_idx: 0,
|
||||||
video_conv: None,
|
video_conv: None,
|
||||||
@@ -1441,8 +1521,34 @@ impl IddPushCapturer {
|
|||||||
self.out_idx = (i + 1) % self.out_ring.len();
|
self.out_idx = (i + 1) % self.out_ring.len();
|
||||||
self.last_seq = seq;
|
self.last_seq = seq;
|
||||||
self.last_present = Some((out.clone(), pf));
|
self.last_present = Some((out.clone(), pf));
|
||||||
self.recovering_since = None; // a fresh frame resumed → recovered
|
let now = Instant::now();
|
||||||
self.last_fresh = Instant::now(); // feeds the driver-death watch
|
if self.recovering_since.take().is_some() {
|
||||||
|
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
||||||
|
// recreate, already logged by the recreate path) — reset the stall watch so it
|
||||||
|
// doesn't read as a DWM stall.
|
||||||
|
self.stall_watch.reset();
|
||||||
|
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
|
||||||
|
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||||
|
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||||
|
// at debug level, and the web-console debug ring captures these.
|
||||||
|
tracing::debug!(
|
||||||
|
gap_ms = stall.gap.as_millis() as u64,
|
||||||
|
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||||
|
delivered no frame for the gap; the present path stalled below capture"
|
||||||
|
);
|
||||||
|
if let Some(period) = stall.metronomic {
|
||||||
|
tracing::warn!(
|
||||||
|
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||||
|
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
|
||||||
|
on a stable period, i.e. a periodic display-path disturbance BELOW \
|
||||||
|
capture (DWM present clock / GPU driver / display-poller software). \
|
||||||
|
Correlate with 'slow display-descriptor poll'; if that never fires, the \
|
||||||
|
disturbance is outside punktfunk — try display topology=primary or \
|
||||||
|
extend (keep a physical output active), or a different refresh rate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_fresh = now; // feeds the driver-death watch
|
||||||
Ok(Some(CapturedFrame {
|
Ok(Some(CapturedFrame {
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
@@ -1576,3 +1682,99 @@ impl Drop for IddPushCapturer {
|
|||||||
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
|
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
|
||||||
|
/// return what each `note_fresh` produced.
|
||||||
|
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut w = StallWatch::new();
|
||||||
|
offsets_ms
|
||||||
|
.iter()
|
||||||
|
.map(|ms| w.note_fresh(base + Duration::from_millis(*ms)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 60 fps flow (16 ms cadence) for `frames` frames starting at `start_ms`, appended to `out`.
|
||||||
|
fn flow(out: &mut Vec<u64>, start_ms: u64, frames: u64) {
|
||||||
|
out.extend((0..frames).map(|i| start_ms + i * 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_detected_after_active_flow() {
|
||||||
|
// 20 frames of 60 fps flow, then a 300 ms hole — the resuming frame reads as a stall.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
flow(&mut t, 0, 20); // last frame at 304 ms
|
||||||
|
t.push(604);
|
||||||
|
let out = watch_run(&t);
|
||||||
|
assert!(out[..20].iter().all(Option::is_none));
|
||||||
|
let stall = out[20].as_ref().expect("hole after active flow is a stall");
|
||||||
|
assert_eq!(stall.gap.as_millis(), 300);
|
||||||
|
assert!(stall.metronomic.is_none(), "one stall is not a cycle");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_desktop_gaps_are_not_stalls() {
|
||||||
|
// Caret-blink damage: frames ~530 ms apart — the activity gate never opens, so neither
|
||||||
|
// the blink gaps nor a long idle hole count.
|
||||||
|
let t: Vec<u64> = (0..12).map(|i| i * 530).chain([20_000]).collect();
|
||||||
|
assert!(watch_run(&t).iter().all(Option::is_none));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thirty_fps_content_still_qualifies_as_active() {
|
||||||
|
// A 30 fps-capped game (33 ms cadence): 8 pre-gap frames span 231 ms ≤ ACTIVE_SPAN, so a
|
||||||
|
// 200 ms hole still reads as a stall.
|
||||||
|
let mut t: Vec<u64> = (0..10).map(|i| i * 33).collect(); // last at 297 ms
|
||||||
|
t.push(497);
|
||||||
|
let out = watch_run(&t);
|
||||||
|
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metronomic_stalls_self_diagnose() {
|
||||||
|
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
|
||||||
|
// cycle BOUNDARIES (5 cycles → 4 stalls); the 4th completes the metronome streak and
|
||||||
|
// reports the ~4 s period.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
for cycle in 0..5u64 {
|
||||||
|
// ~3.7 s of flow, then the hole to the next cycle start.
|
||||||
|
flow(&mut t, cycle * 4_000, 232); // last frame at cycle*4000 + 3696
|
||||||
|
}
|
||||||
|
let out = watch_run(&t);
|
||||||
|
let stalls: Vec<&Stall> = out.iter().flatten().collect();
|
||||||
|
assert_eq!(stalls.len(), 4, "each cycle boundary is one stall");
|
||||||
|
assert!(stalls[..3].iter().all(|s| s.metronomic.is_none()));
|
||||||
|
let period = stalls[3]
|
||||||
|
.metronomic
|
||||||
|
.expect("the 4th evenly-spaced event completes the metronome streak");
|
||||||
|
assert!(
|
||||||
|
(period.as_secs_f64() - 4.0).abs() < 0.3,
|
||||||
|
"period={period:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_swallows_the_recreate_gap() {
|
||||||
|
// Active flow, then a ring recreate (reset), then flow resumes 800 ms later — the resume
|
||||||
|
// frame must NOT read as a stall, and detection re-arms afterwards.
|
||||||
|
let base = Instant::now();
|
||||||
|
let at = |ms: u64| base + Duration::from_millis(ms);
|
||||||
|
let mut w = StallWatch::new();
|
||||||
|
for i in 0..20u64 {
|
||||||
|
assert!(w.note_fresh(at(i * 16)).is_none());
|
||||||
|
}
|
||||||
|
w.reset();
|
||||||
|
assert!(w.note_fresh(at(1_104)).is_none(), "recreate gap swallowed");
|
||||||
|
for i in 1..20u64 {
|
||||||
|
assert!(w.note_fresh(at(1_104 + i * 16)).is_none());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
w.note_fresh(at(1_104 + 19 * 16 + 300)).is_some(),
|
||||||
|
"detection re-armed after the reset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ use super::egl::DmabufPlane;
|
|||||||
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||||
use std::path::Path;
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command};
|
use std::process::{Child, Command};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
||||||
@@ -69,6 +71,57 @@ fn sweep_reaper() {
|
|||||||
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
|
||||||
|
/// `/proc/self/exe` magic link. The link names the running image's *inode*, not its path, so it
|
||||||
|
/// resolves even after the installed binary was replaced or deleted — and exec'ing the fd (via
|
||||||
|
/// [`fd_exec_path`]) then still runs byte-for-byte the build this process is. `current_exe()`
|
||||||
|
/// instead readlinks to a path: after a package upgrade under a running host that path is
|
||||||
|
/// "<path> (deleted)" and spawning it fails ENOENT — every capture then silently fell back to
|
||||||
|
/// the CPU copy — and even while the path exists it may hold a newer build whose worker
|
||||||
|
/// protocol mismatches this process.
|
||||||
|
static SELF_EXE: OnceLock<Option<File>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn self_exe() -> Option<BorrowedFd<'static>> {
|
||||||
|
SELF_EXE
|
||||||
|
.get_or_init(|| {
|
||||||
|
let f = match File::open("/proc/self/exe") {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"cannot pin /proc/self/exe — worker spawns use the current_exe() path, \
|
||||||
|
which breaks if this binary is replaced on disk"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if f.as_raw_fd() != 3 {
|
||||||
|
return Some(f);
|
||||||
|
}
|
||||||
|
// Fd 3 is the slot the spawn hands the worker its socket on (the `dup2` in
|
||||||
|
// `spawn_exe`) — pinned there, the child would clobber it before exec resolves
|
||||||
|
// `/proc/self/fd/3`. Re-number: 3 stays occupied by `f` during the clone, so the
|
||||||
|
// duplicate cannot land on it.
|
||||||
|
match f.try_clone() {
|
||||||
|
Ok(clone) => Some(clone),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "re-numbering the pinned exe fd off fd 3 failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.as_fd())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/proc/self/fd/<n>` — an exec'able path to `fd`'s inode. The kernel resolves it at exec time
|
||||||
|
/// inside the forked child, whose fd table is a copy of ours (close-on-exec applies only once
|
||||||
|
/// the exec succeeds), so it names the pinned inode no matter what sits at the file's original
|
||||||
|
/// path by then.
|
||||||
|
fn fd_exec_path(fd: BorrowedFd<'_>) -> PathBuf {
|
||||||
|
PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()))
|
||||||
|
}
|
||||||
|
|
||||||
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
|
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
|
||||||
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
||||||
pub struct RemoteImporter {
|
pub struct RemoteImporter {
|
||||||
@@ -81,12 +134,18 @@ pub struct RemoteImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteImporter {
|
impl RemoteImporter {
|
||||||
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err`
|
/// Spawn the worker from this host binary and complete the readiness handshake. The worker
|
||||||
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly
|
/// is exec'd through the pinned [`SELF_EXE`] fd, so it is always the exact image this
|
||||||
/// like an in-process `EglImporter::new()` failure.
|
/// process runs — even after the installed binary was replaced mid-flight. An `Err` here
|
||||||
|
/// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like
|
||||||
|
/// an in-process `EglImporter::new()` failure.
|
||||||
pub fn spawn() -> Result<RemoteImporter> {
|
pub fn spawn() -> Result<RemoteImporter> {
|
||||||
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?;
|
match self_exe() {
|
||||||
Self::spawn_exe(&exe)
|
Some(fd) => Self::spawn_exe(&fd_exec_path(fd)),
|
||||||
|
None => Self::spawn_exe(
|
||||||
|
&std::env::current_exe().context("resolve /proc/self/exe for the worker")?,
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
||||||
@@ -94,6 +153,8 @@ impl RemoteImporter {
|
|||||||
sweep_reaper();
|
sweep_reaper();
|
||||||
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
||||||
let mut cmd = Command::new(exe);
|
let mut cmd = Command::new(exe);
|
||||||
|
// `exe` is normally an opaque `/proc/self/fd/<n>` — keep `ps` output meaningful.
|
||||||
|
cmd.arg0("punktfunk-host");
|
||||||
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
|
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
|
||||||
let raw = worker_end.as_raw_fd();
|
let raw = worker_end.as_raw_fd();
|
||||||
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
|
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
|
||||||
@@ -102,7 +163,6 @@ impl RemoteImporter {
|
|||||||
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
|
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
|
||||||
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
|
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
|
||||||
unsafe {
|
unsafe {
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
cmd.pre_exec(move || {
|
cmd.pre_exec(move || {
|
||||||
if raw == 3 {
|
if raw == 3 {
|
||||||
let flags = libc::fcntl(3, libc::F_GETFD);
|
let flags = libc::fcntl(3, libc::F_GETFD);
|
||||||
@@ -483,6 +543,48 @@ mod tests {
|
|||||||
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_execs_the_pinned_self_exe() {
|
||||||
|
// `spawn()` execs this very process's image via the pinned `/proc/self/fd/…` path. Here
|
||||||
|
// that image is the libtest harness, which rejects `--fd` and exits without a handshake
|
||||||
|
// — so a "handshake" error proves the exec itself succeeded (an exec failure would read
|
||||||
|
// "spawn zerocopy-worker" instead).
|
||||||
|
let Err(err) = RemoteImporter::spawn() else {
|
||||||
|
panic!("the test harness is not a worker; spawn must fail at the handshake")
|
||||||
|
};
|
||||||
|
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pinned_fd_exec_survives_on_disk_replacement() {
|
||||||
|
// The 2026-07-10 canary regression: a package upgrade replaced the installed binary and
|
||||||
|
// every worker spawn ENOENT'd (`current_exe()` readlinked to "<path> (deleted)"). The
|
||||||
|
// pinned-fd mechanism must keep exec'ing the original image after the file is gone: pin
|
||||||
|
// a copy of /bin/sh, delete it, then run it through the fd path.
|
||||||
|
let copy = std::env::temp_dir().join(format!("pf-zerocopy-exe-pin-{}", std::process::id()));
|
||||||
|
std::fs::copy("/bin/sh", ©).unwrap();
|
||||||
|
let pinned = File::open(©).unwrap();
|
||||||
|
std::fs::remove_file(©).unwrap();
|
||||||
|
// Retry ETXTBSY: `fs::copy`'s write fd leaks into other tests' concurrently-forked
|
||||||
|
// children until their execs clear it (CLOEXEC applies only at exec), and exec'ing a
|
||||||
|
// file someone holds open for writing is refused. A harness artifact of copy-then-exec,
|
||||||
|
// not the mechanism under test — production pins a read-only fd on a binary nobody
|
||||||
|
// write-opens.
|
||||||
|
let status = loop {
|
||||||
|
match Command::new(fd_exec_path(pinned.as_fd()))
|
||||||
|
.arg("-c")
|
||||||
|
.arg("exit 42")
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
|
||||||
|
std::thread::sleep(Duration::from_millis(10))
|
||||||
|
}
|
||||||
|
other => break other.expect("exec via /proc/self/fd of a deleted file"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(status.code(), Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
/// A scripted peer: answers the handshake, then serves canned replies per request.
|
/// A scripted peer: answers the handshake, then serves canned replies per request.
|
||||||
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
||||||
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||||
|
|||||||
@@ -221,11 +221,20 @@ pub fn drm_fourcc(format: crate::capture::PixelFormat) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA
|
/// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA
|
||||||
/// context and report. De-risks the FFI/linking/GPU-access without needing a capture session.
|
/// context and report, then exercise the production path — spawn the isolated worker (exec'd
|
||||||
|
/// from this binary's pinned exe fd), handshake, and query modifiers. De-risks the
|
||||||
|
/// FFI/linking/GPU-access AND the worker spawn (e.g. the installed binary replaced under a
|
||||||
|
/// running host) without needing a capture session.
|
||||||
pub fn probe() -> anyhow::Result<()> {
|
pub fn probe() -> anyhow::Result<()> {
|
||||||
let _importer = EglImporter::new()?;
|
let _importer = EglImporter::new()?;
|
||||||
let ctx = cuda::context()?;
|
let ctx = cuda::context()?;
|
||||||
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
|
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
|
||||||
|
let mut worker = client::RemoteImporter::spawn()?;
|
||||||
|
let modifiers = worker.supported_modifiers(fourcc(b"XR24")).len();
|
||||||
|
tracing::info!(
|
||||||
|
modifiers,
|
||||||
|
"zero-copy probe OK — worker spawned, handshake + modifier query"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const FD_CACHE_CAP: usize = 64;
|
|||||||
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
|
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
|
||||||
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
|
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
|
||||||
pub fn run_from_args(args: &[String]) -> Result<()> {
|
pub fn run_from_args(args: &[String]) -> Result<()> {
|
||||||
|
// The host execs this worker through its pinned exe fd (`client::self_exe`), so the kernel
|
||||||
|
// derives our comm from the exec path's basename — a meaningless fd number. Rename so
|
||||||
|
// `top`/`pkill` see the worker.
|
||||||
|
// SAFETY: `PR_SET_NAME` copies at most 16 bytes from the given pointer; the C-string literal
|
||||||
|
// is valid, NUL-terminated, and short enough. No pointer is retained past the call.
|
||||||
|
unsafe {
|
||||||
|
libc::prctl(libc::PR_SET_NAME, c"pf-zerocopy".as_ptr());
|
||||||
|
}
|
||||||
let fd: i32 = args
|
let fd: i32 = args
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|a| *a != "--fd")
|
.skip_while(|a| *a != "--fd")
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ mod wol;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/crash.rs"]
|
#[path = "windows/crash.rs"]
|
||||||
mod crash;
|
mod crash;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/ddc.rs"]
|
||||||
|
mod ddc;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/dmabuf_fence.rs"]
|
#[path = "linux/dmabuf_fence.rs"]
|
||||||
mod dmabuf_fence;
|
mod dmabuf_fence;
|
||||||
@@ -50,6 +53,7 @@ mod install;
|
|||||||
mod interactive;
|
mod interactive;
|
||||||
mod library;
|
mod library;
|
||||||
mod log_capture;
|
mod log_capture;
|
||||||
|
mod metronome;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
mod mgmt_token;
|
mod mgmt_token;
|
||||||
mod native_pairing;
|
mod native_pairing;
|
||||||
@@ -213,9 +217,9 @@ fn real_main() -> Result<()> {
|
|||||||
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-probe") => zerocopy::probe(),
|
Some("zerocopy-probe") => zerocopy::probe(),
|
||||||
// Hidden: the isolated GPU-import worker the capture path spawns from /proc/self/exe
|
// Hidden: the isolated GPU-import worker the capture path spawns from a pinned fd to its
|
||||||
// (design/zerocopy-worker-isolation.md) — never run by hand; --fd names the inherited
|
// own executable image (design/zerocopy-worker-isolation.md) — never run by hand; --fd
|
||||||
// socketpair end.
|
// names the inherited socketpair end.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
||||||
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! Detector for METRONOMIC event cycles — evenly-spaced disturbances repeating every few seconds.
|
||||||
|
//!
|
||||||
|
//! The "periodic double-jolt" symptom class field reports keep describing is a host/display-side
|
||||||
|
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
|
||||||
|
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
|
||||||
|
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
|
||||||
|
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`punktfunk1`) and IDD-push capture
|
||||||
|
//! stalls (`capture::windows::idd_push`).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Pure evenly-spaced-events detector (unit-tested below).
|
||||||
|
///
|
||||||
|
/// Events within [`Self::COALESCE`] count as ONE (a double-jolt's paired disturbances — e.g. the
|
||||||
|
/// cooldown re-issue of a lost keyframe ~0.7 s after the first — are one user-visible cycle). When
|
||||||
|
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
|
||||||
|
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
||||||
|
/// [`Self::REWARN`] while the cycle persists.
|
||||||
|
pub(crate) struct Metronome {
|
||||||
|
events: VecDeque<Instant>,
|
||||||
|
last_warn: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metronome {
|
||||||
|
/// Events closer together than this are the same user-visible disturbance.
|
||||||
|
const COALESCE: Duration = Duration::from_millis(1500);
|
||||||
|
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
|
||||||
|
const STREAK: usize = 4;
|
||||||
|
/// "Evenly spaced" = every gap within this fraction of the mean gap.
|
||||||
|
const TOLERANCE: f64 = 0.2;
|
||||||
|
/// Once warned, re-warn at most this often while the cycle persists.
|
||||||
|
const REWARN: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
events: VecDeque::new(),
|
||||||
|
last_warn: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
|
||||||
|
/// warning should fire.
|
||||||
|
pub(crate) fn note(&mut self, now: Instant) -> Option<Duration> {
|
||||||
|
if self
|
||||||
|
.events
|
||||||
|
.back()
|
||||||
|
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.events.push_back(now);
|
||||||
|
if self.events.len() > Self::STREAK {
|
||||||
|
self.events.pop_front();
|
||||||
|
}
|
||||||
|
if self.events.len() < Self::STREAK {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let gaps: Vec<f64> = self
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.zip(self.events.iter().skip(1))
|
||||||
|
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
|
||||||
|
.collect();
|
||||||
|
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
|
||||||
|
if mean <= 0.0
|
||||||
|
|| gaps
|
||||||
|
.iter()
|
||||||
|
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.last_warn
|
||||||
|
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last_warn = Some(now);
|
||||||
|
Some(Duration::from_secs_f64(mean))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Feed a [`Metronome`] a schedule of event offsets (ms from a common origin) and return
|
||||||
|
/// what each `note` produced.
|
||||||
|
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<Duration>> {
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut c = Metronome::new();
|
||||||
|
offsets_ms
|
||||||
|
.iter()
|
||||||
|
.map(|ms| c.note(base + Duration::from_millis(*ms)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_detects_metronomic_events() {
|
||||||
|
// Four events ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
|
||||||
|
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
|
||||||
|
assert_eq!(out[..3], [None, None, None]);
|
||||||
|
let period = out[3].expect("metronomic series must be detected");
|
||||||
|
assert!(
|
||||||
|
(period.as_secs_f64() - 3.98).abs() < 0.2,
|
||||||
|
"period={period:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_coalesces_double_jolt_pairs() {
|
||||||
|
// The field signature: a jolt pair (second event ~0.7 s after the first, e.g. the IDR
|
||||||
|
// cooldown re-issue) every ~4 s. Each pair is ONE event; detection still lands on the
|
||||||
|
// ~4 s cycle.
|
||||||
|
let out = cadence_run(&[
|
||||||
|
0, 700, // pair 1
|
||||||
|
4_000, 4_700, // pair 2
|
||||||
|
8_000, 8_650, // pair 3
|
||||||
|
12_000, // pair 4 (first event trips it)
|
||||||
|
]);
|
||||||
|
assert!(out[..6].iter().all(Option::is_none));
|
||||||
|
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
|
||||||
|
assert!(
|
||||||
|
(period.as_secs_f64() - 4.0).abs() < 0.2,
|
||||||
|
"period={period:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_ignores_irregular_bursts() {
|
||||||
|
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
|
||||||
|
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
|
||||||
|
.iter()
|
||||||
|
.all(Option::is_none));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_rewarns_at_most_every_30s() {
|
||||||
|
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
|
||||||
|
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
|
||||||
|
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
|
||||||
|
let out = cadence_run(&offsets);
|
||||||
|
let warned: Vec<usize> = out
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, o)| o.map(|_| i))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(warned, vec![3, 11], "warn indices");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1051,6 +1051,9 @@ fn display_settings_state() -> DisplaySettingsState {
|
|||||||
"identity".into(),
|
"identity".into(),
|
||||||
"layout".into(),
|
"layout".into(),
|
||||||
"game_session".into(),
|
"game_session".into(),
|
||||||
|
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
||||||
|
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||||
|
"ddc_power_off".into(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,10 +1259,11 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
|||||||
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
|
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
|
||||||
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
|
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
|
||||||
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
|
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
|
||||||
let policy = store
|
let policy = store.get().effective().with_manual_layout(
|
||||||
.get()
|
req.positions,
|
||||||
.effective()
|
store.game_session(),
|
||||||
.with_manual_layout(req.positions, store.game_session());
|
store.ddc_power_off(),
|
||||||
|
);
|
||||||
if let Err(e) = store.set(policy) {
|
if let Err(e) = store.set(policy) {
|
||||||
return api_error(
|
return api_error(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -2944,6 +2948,8 @@ mod tests {
|
|||||||
assert!(enforced.contains(&"mode_conflict"));
|
assert!(enforced.contains(&"mode_conflict"));
|
||||||
assert!(enforced.contains(&"identity"));
|
assert!(enforced.contains(&"identity"));
|
||||||
assert!(enforced.contains(&"layout"));
|
assert!(enforced.contains(&"layout"));
|
||||||
|
// The experimental DDC/CI panel-off axis is acted on (Windows exclusive-isolate path).
|
||||||
|
assert!(enforced.contains(&"ddc_power_off"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||||
|
|||||||
@@ -3230,82 +3230,6 @@ struct SessionContext {
|
|||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detector for METRONOMIC client keyframe-recovery cycles — the "periodic double-jolt" symptom
|
|
||||||
/// class field reports keep describing: a host/display-side disturbance repeating every few
|
|
||||||
/// seconds (display-topology churn, display-poller software, virtual-display timing), where each
|
|
||||||
/// cycle ends in a client keyframe request the host serves. Random network loss is bursty and
|
|
||||||
/// irregular; a stable period is a machine, and saying so in the host log turns a "nothing in the
|
|
||||||
/// logs :/" report into a self-diagnosis.
|
|
||||||
///
|
|
||||||
/// Served forced IDRs within [`Self::COALESCE`] count as ONE event (a double-jolt's paired IDRs —
|
|
||||||
/// the cooldown re-issue of a lost keyframe — are one user-visible disturbance). When the gaps
|
|
||||||
/// between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their mean,
|
|
||||||
/// [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
|
||||||
/// [`Self::REWARN`] while the cycle persists. Pure logic — unit-tested below.
|
|
||||||
struct RecoveryCadence {
|
|
||||||
events: std::collections::VecDeque<std::time::Instant>,
|
|
||||||
last_warn: Option<std::time::Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecoveryCadence {
|
|
||||||
/// Serves closer together than this are the same user-visible disturbance.
|
|
||||||
const COALESCE: std::time::Duration = std::time::Duration::from_millis(1500);
|
|
||||||
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
|
|
||||||
const STREAK: usize = 4;
|
|
||||||
/// "Evenly spaced" = every gap within this fraction of the mean gap.
|
|
||||||
const TOLERANCE: f64 = 0.2;
|
|
||||||
/// Once warned, re-warn at most this often while the cycle persists.
|
|
||||||
const REWARN: std::time::Duration = std::time::Duration::from_secs(30);
|
|
||||||
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
events: std::collections::VecDeque::new(),
|
|
||||||
last_warn: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a served client-recovery IDR at `now`; `Some(mean period)` exactly when the
|
|
||||||
/// metronomic-cycle warning should fire.
|
|
||||||
fn note(&mut self, now: std::time::Instant) -> Option<std::time::Duration> {
|
|
||||||
if self
|
|
||||||
.events
|
|
||||||
.back()
|
|
||||||
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.events.push_back(now);
|
|
||||||
if self.events.len() > Self::STREAK {
|
|
||||||
self.events.pop_front();
|
|
||||||
}
|
|
||||||
if self.events.len() < Self::STREAK {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let gaps: Vec<f64> = self
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.zip(self.events.iter().skip(1))
|
|
||||||
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
|
|
||||||
.collect();
|
|
||||||
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
|
|
||||||
if mean <= 0.0
|
|
||||||
|| gaps
|
|
||||||
.iter()
|
|
||||||
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.last_warn
|
|
||||||
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.last_warn = Some(now);
|
|
||||||
Some(std::time::Duration::from_secs_f64(mean))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||||
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
||||||
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
||||||
@@ -3521,8 +3445,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// opening GOP, instead of answering it with a redundant second IDR.
|
// opening GOP, instead of answering it with a redundant second IDR.
|
||||||
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
|
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
|
||||||
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
|
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
|
||||||
// into a stable multi-second rhythm (see [`RecoveryCadence`]).
|
// into a stable multi-second rhythm (see [`crate::metronome::Metronome`]).
|
||||||
let mut recovery_cadence = RecoveryCadence::new();
|
let mut recovery_cadence = crate::metronome::Metronome::new();
|
||||||
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
|
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
|
||||||
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
|
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
|
||||||
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
|
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
|
||||||
@@ -3733,7 +3657,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
disturbance (display-topology churn, display-poller software, \
|
disturbance (display-topology churn, display-poller software, \
|
||||||
virtual-display timing) is the likely cause, not random network loss; \
|
virtual-display timing) is the likely cause, not random network loss; \
|
||||||
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
||||||
changed' lines"
|
changed' / 'IDD-push capture stall' lines"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4425,69 +4349,6 @@ mod tests {
|
|||||||
assert_eq!(dec, snap);
|
assert_eq!(dec, snap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
|
|
||||||
/// what each `note` produced.
|
|
||||||
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
|
|
||||||
let base = std::time::Instant::now();
|
|
||||||
let mut c = RecoveryCadence::new();
|
|
||||||
offsets_ms
|
|
||||||
.iter()
|
|
||||||
.map(|ms| c.note(base + std::time::Duration::from_millis(*ms)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_detects_metronomic_recoveries() {
|
|
||||||
// Four IDR serves ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
|
|
||||||
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
|
|
||||||
assert_eq!(out[..3], [None, None, None]);
|
|
||||||
let period = out[3].expect("metronomic series must be detected");
|
|
||||||
assert!(
|
|
||||||
(period.as_secs_f64() - 3.98).abs() < 0.2,
|
|
||||||
"period={period:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_coalesces_double_jolt_pairs() {
|
|
||||||
// The field signature: a jolt pair (second IDR ~0.7 s after the first, the cooldown
|
|
||||||
// re-issue) every ~4 s. Each pair is ONE event; detection still lands on the ~4 s cycle.
|
|
||||||
let out = cadence_run(&[
|
|
||||||
0, 700, // pair 1
|
|
||||||
4_000, 4_700, // pair 2
|
|
||||||
8_000, 8_650, // pair 3
|
|
||||||
12_000, // pair 4 (first serve trips it)
|
|
||||||
]);
|
|
||||||
assert!(out[..6].iter().all(Option::is_none));
|
|
||||||
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
|
|
||||||
assert!(
|
|
||||||
(period.as_secs_f64() - 4.0).abs() < 0.2,
|
|
||||||
"period={period:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_ignores_irregular_bursts() {
|
|
||||||
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
|
|
||||||
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
|
|
||||||
.iter()
|
|
||||||
.all(Option::is_none));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_rewarns_at_most_every_30s() {
|
|
||||||
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
|
|
||||||
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
|
|
||||||
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
|
|
||||||
let out = cadence_run(&offsets);
|
|
||||||
let warned: Vec<usize> = out
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(i, o)| o.map(|_| i))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(warned, vec![3, 11], "warn indices");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adapt_fec_maps_loss_to_recovery_band() {
|
fn adapt_fec_maps_loss_to_recovery_band() {
|
||||||
// A perfectly clean window (0 loss) lands on the floor.
|
// A perfectly clean window (0 loss) lands on the floor.
|
||||||
|
|||||||
@@ -224,6 +224,16 @@ pub struct DisplayPolicy {
|
|||||||
/// so existing `display-settings.json` files are untouched.
|
/// so existing `display-settings.json` files are untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub game_session: GameSession,
|
pub game_session: GameSession,
|
||||||
|
/// EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →
|
||||||
|
/// DPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.
|
||||||
|
/// Targets the "connected-but-dark head" periodic-stutter class (monitor standby
|
||||||
|
/// auto-input-scan / DP link churn while the virtual display is the sole active display) at
|
||||||
|
/// the monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in
|
||||||
|
/// the OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across
|
||||||
|
/// preset changes; `#[serde(default)]` = off so existing `display-settings.json` files are
|
||||||
|
/// untouched.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ddc_power_off: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn one() -> u32 {
|
fn one() -> u32 {
|
||||||
@@ -247,6 +257,7 @@ impl Default for DisplayPolicy {
|
|||||||
layout: Layout::default(),
|
layout: Layout::default(),
|
||||||
max_displays: 4,
|
max_displays: 4,
|
||||||
game_session: GameSession::default(),
|
game_session: GameSession::default(),
|
||||||
|
ddc_power_off: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,6 +317,7 @@ impl EffectivePolicy {
|
|||||||
&self,
|
&self,
|
||||||
positions: BTreeMap<String, Position>,
|
positions: BTreeMap<String, Position>,
|
||||||
game_session: GameSession,
|
game_session: GameSession,
|
||||||
|
ddc_power_off: bool,
|
||||||
) -> DisplayPolicy {
|
) -> DisplayPolicy {
|
||||||
DisplayPolicy {
|
DisplayPolicy {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -319,8 +331,9 @@ impl EffectivePolicy {
|
|||||||
positions,
|
positions,
|
||||||
},
|
},
|
||||||
max_displays: self.max_displays,
|
max_displays: self.max_displays,
|
||||||
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it).
|
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
|
||||||
game_session,
|
game_session,
|
||||||
|
ddc_power_off,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +447,13 @@ impl DisplayPolicyStore {
|
|||||||
self.get().game_session
|
self.get().game_session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The experimental DDC/CI panel-off axis — orthogonal to the preset (like
|
||||||
|
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||||
|
/// unconfigured).
|
||||||
|
pub fn ddc_power_off(&self) -> bool {
|
||||||
|
self.get().ddc_power_off
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||||
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||||
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||||
@@ -749,9 +769,10 @@ mod tests {
|
|||||||
let mut positions = BTreeMap::new();
|
let mut positions = BTreeMap::new();
|
||||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated);
|
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true);
|
||||||
// The orthogonal game-session axis is preserved through the layout transform.
|
// The orthogonal axes (game-session, DDC power-off) are preserved through the transform.
|
||||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||||
|
assert!(p.ddc_power_off);
|
||||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||||
assert_eq!(p.preset, Preset::Custom);
|
assert_eq!(p.preset, Preset::Custom);
|
||||||
// …every other behavior axis is preserved verbatim…
|
// …every other behavior axis is preserved verbatim…
|
||||||
@@ -776,6 +797,8 @@ mod tests {
|
|||||||
assert_eq!(p.keep_alive, KeepAlive::default());
|
assert_eq!(p.keep_alive, KeepAlive::default());
|
||||||
assert_eq!(p.topology, Topology::Auto);
|
assert_eq!(p.topology, Topology::Auto);
|
||||||
assert_eq!(p.version, 1);
|
assert_eq!(p.version, 1);
|
||||||
|
// A file written before the experimental DDC axis existed defaults it OFF.
|
||||||
|
assert!(!p.ddc_power_off);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ struct Monitor {
|
|||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
pinger: Option<JoinHandle<()>>,
|
pinger: Option<JoinHandle<()>>,
|
||||||
ccd_saved: Option<SavedConfig>,
|
ccd_saved: Option<SavedConfig>,
|
||||||
|
/// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at this monitor's
|
||||||
|
/// isolate (`ddc_power_off` policy axis) — teardown wakes them after the CCD restore iff > 0.
|
||||||
|
ddc_panels_off: u32,
|
||||||
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
||||||
gen: u64,
|
gen: u64,
|
||||||
}
|
}
|
||||||
@@ -668,6 +671,7 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut ccd_saved: Option<SavedConfig> = None;
|
let mut ccd_saved: Option<SavedConfig> = None;
|
||||||
|
let mut ddc_panels_off = 0u32;
|
||||||
match &gdi_name {
|
match &gdi_name {
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||||
@@ -682,6 +686,15 @@ impl VirtualDisplayManager {
|
|||||||
use crate::vdisplay::policy::Topology;
|
use crate::vdisplay::policy::Topology;
|
||||||
match topology_action() {
|
match topology_action() {
|
||||||
Topology::Exclusive => {
|
Topology::Exclusive => {
|
||||||
|
// EXPERIMENTAL `ddc_power_off` policy axis: command the physical panels
|
||||||
|
// dark over DDC/CI BEFORE the isolate — an HMONITOR (and with it the DDC
|
||||||
|
// channel) only exists while the display is still active. A panel that
|
||||||
|
// believes it has an owner skips its no-signal standby probing — the
|
||||||
|
// suspected source of the periodic sole-virtual-display stutter (the
|
||||||
|
// rationale + evidence live in `windows/ddc.rs`).
|
||||||
|
if crate::vdisplay::policy::prefs().ddc_power_off() {
|
||||||
|
ddc_panels_off = crate::ddc::panel_off_except(n);
|
||||||
|
}
|
||||||
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
|
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
|
||||||
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
||||||
// crosses), under the `state` lock — the sole topology mutator.
|
// crosses), under the `state` lock — the sole topology mutator.
|
||||||
@@ -743,6 +756,7 @@ impl VirtualDisplayManager {
|
|||||||
stop,
|
stop,
|
||||||
pinger: Some(pinger),
|
pinger: Some(pinger),
|
||||||
ccd_saved,
|
ccd_saved,
|
||||||
|
ddc_panels_off,
|
||||||
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -805,6 +819,20 @@ impl VirtualDisplayManager {
|
|||||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||||
if let Some(saved) = &mon.ccd_saved {
|
if let Some(saved) = &mon.ccd_saved {
|
||||||
restore_displays_ccd(saved);
|
restore_displays_ccd(saved);
|
||||||
|
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
||||||
|
// returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is
|
||||||
|
// belt-and-braces for the rest. The brief settle wait lets the re-activated paths show
|
||||||
|
// up in EnumDisplayMonitors (no HMONITOR, no DDC channel); teardown is already
|
||||||
|
// seconds-scale and watched by the 10 s wedge logger above.
|
||||||
|
if mon.ddc_panels_off > 0 {
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let woken = crate::ddc::panel_on_all();
|
||||||
|
tracing::info!(
|
||||||
|
commanded_off = mon.ddc_panels_off,
|
||||||
|
woken,
|
||||||
|
"DDC/CI: panel wake commands sent after topology restore"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
|
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
|
||||||
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the
|
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! DDC/CI monitor panel power control — the EXPERIMENTAL `ddc_power_off` display-policy axis.
|
||||||
|
//!
|
||||||
|
//! DDC/CI is the VESA command channel to the monitor itself: an I²C bus inside the video cable
|
||||||
|
//! (dedicated pins on VGA/DVI/HDMI, tunneled over the AUX channel on DisplayPort) whose MCCS
|
||||||
|
//! "VCP codes" expose the monitor's OSD knobs to software. VCP 0xD6 is the power mode; we command
|
||||||
|
//! `0x04` (DPMS off — panel + backlight dark, firmware still listening) and never `0x05`
|
||||||
|
//! (power-button off — many monitors kill their DDC controller in that state and need a physical
|
||||||
|
//! button press to come back).
|
||||||
|
//!
|
||||||
|
//! Why: the "periodic double-jolt while the virtual display is the SOLE active display" stutter
|
||||||
|
//! class (Apollo #179/#358/#368/#563/#776 and our own field report). When an `Exclusive` isolate
|
||||||
|
//! deactivates the physical monitor, its link drops and the monitor falls into its no-signal flow:
|
||||||
|
//! standby with periodic auto-input-scan / link probing that the GPU driver services with
|
||||||
|
//! display-subsystem stalls at a seconds-scale cadence. A panel commanded off over DDC/CI believes
|
||||||
|
//! it has an owner and (on cooperating firmware) stops probing. This is deliberately shipped as an
|
||||||
|
//! experiment: whether it helps discriminates *who initiates* the churn — monitor firmware (DDC-off
|
||||||
|
//! fixes it) vs. the driver servicing a dark head regardless (only a driven link fixes it, i.e.
|
||||||
|
//! topology `primary`/`extend`).
|
||||||
|
//!
|
||||||
|
//! Everything here is best-effort and warn-and-continue: monitors without DDC/CI support (or with
|
||||||
|
//! it disabled in the OSD), docks/KVMs that don't pass the channel through, and laptop-internal
|
||||||
|
//! panels (ACPI backlight, no DDC) all simply probe as unsupported and are skipped. Each DDC
|
||||||
|
//! transaction can block for tens of ms — callers run at session acquire/teardown, never on the
|
||||||
|
//! frame path.
|
||||||
|
|
||||||
|
use windows::Win32::Devices::Display::{
|
||||||
|
DestroyPhysicalMonitors, GetNumberOfPhysicalMonitorsFromHMONITOR,
|
||||||
|
GetPhysicalMonitorsFromHMONITOR, GetVCPFeatureAndVCPFeatureReply, SetVCPFeature,
|
||||||
|
PHYSICAL_MONITOR,
|
||||||
|
};
|
||||||
|
use windows::Win32::Foundation::LPARAM;
|
||||||
|
use windows::Win32::Graphics::Gdi::{
|
||||||
|
EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFOEXW,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// MCCS VCP code 0xD6 — display power mode.
|
||||||
|
const VCP_POWER_MODE: u8 = 0xD6;
|
||||||
|
/// VCP 0xD6 value: on.
|
||||||
|
const POWER_ON: u32 = 0x01;
|
||||||
|
/// VCP 0xD6 value: DPMS off (dark panel, DDC controller stays responsive). Deliberately NOT 0x05.
|
||||||
|
const POWER_OFF: u32 = 0x04;
|
||||||
|
|
||||||
|
/// One active display: its HMONITOR and GDI device name (`\\.\DISPLAYn`).
|
||||||
|
struct ActiveMonitor {
|
||||||
|
hmon: HMONITOR,
|
||||||
|
device: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate the active displays (HMONITOR + GDI name). HMONITORs are only valid while a display
|
||||||
|
/// is part of the desktop — which is exactly why the off-command must run BEFORE a CCD isolate
|
||||||
|
/// and the on-command AFTER the restore.
|
||||||
|
fn active_monitors() -> Vec<ActiveMonitor> {
|
||||||
|
unsafe extern "system" fn collect(
|
||||||
|
hmon: HMONITOR,
|
||||||
|
_hdc: HDC,
|
||||||
|
_rect: *mut windows::Win32::Foundation::RECT,
|
||||||
|
data: LPARAM,
|
||||||
|
) -> windows::core::BOOL {
|
||||||
|
// SAFETY: `data` is the `&mut Vec<ActiveMonitor>` passed by `active_monitors` below,
|
||||||
|
// valid for the duration of the synchronous EnumDisplayMonitors call that invokes us.
|
||||||
|
let out = unsafe { &mut *(data.0 as *mut Vec<ActiveMonitor>) };
|
||||||
|
let mut info = MONITORINFOEXW::default();
|
||||||
|
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
|
||||||
|
// SAFETY: `hmon` is the live monitor handle the enumeration just handed us; `info` is a
|
||||||
|
// properly-sized MONITORINFOEXW local whose cbSize is set, which GetMonitorInfoW requires
|
||||||
|
// to safely write the extended (szDevice) variant.
|
||||||
|
if unsafe { GetMonitorInfoW(hmon, &mut info.monitorInfo) }.as_bool() {
|
||||||
|
let len = info
|
||||||
|
.szDevice
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(info.szDevice.len());
|
||||||
|
out.push(ActiveMonitor {
|
||||||
|
hmon,
|
||||||
|
device: String::from_utf16_lossy(&info.szDevice[..len]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
true.into() // keep enumerating
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<ActiveMonitor> = Vec::new();
|
||||||
|
// SAFETY: `collect` matches MONITORENUMPROC; `&mut out` outlives the synchronous enumeration
|
||||||
|
// and is only dereferenced inside the callback (single-threaded — user32 invokes it inline).
|
||||||
|
let _ = unsafe {
|
||||||
|
EnumDisplayMonitors(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(collect),
|
||||||
|
LPARAM(&mut out as *mut Vec<ActiveMonitor> as isize),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `value` to VCP 0xD6 on every physical monitor behind `hmon` that answers a 0xD6 probe.
|
||||||
|
/// Returns how many panels acknowledged the set. `device` is for the log lines only.
|
||||||
|
fn set_power(hmon: HMONITOR, device: &str, value: u32) -> u32 {
|
||||||
|
let mut n = 0u32;
|
||||||
|
// SAFETY: `hmon` is a live monitor handle from the enumeration; `n` is a valid out-param.
|
||||||
|
if unsafe { GetNumberOfPhysicalMonitorsFromHMONITOR(hmon, &mut n) }.is_err() || n == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut phys = vec![PHYSICAL_MONITOR::default(); n as usize];
|
||||||
|
// SAFETY: `phys` is sized to exactly the count the API just reported for this handle.
|
||||||
|
if unsafe { GetPhysicalMonitorsFromHMONITOR(hmon, &mut phys) }.is_err() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut acked = 0u32;
|
||||||
|
for p in &phys {
|
||||||
|
// PHYSICAL_MONITOR is `packed(1)` (dxva2 header pragma) — copy the fields OUT by value
|
||||||
|
// before touching them; a reference into a packed field is rejected (E0793, UB).
|
||||||
|
let handle = p.hPhysicalMonitor;
|
||||||
|
let desc_raw = p.szPhysicalMonitorDescription;
|
||||||
|
let len = desc_raw
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(desc_raw.len());
|
||||||
|
let desc = String::from_utf16_lossy(&desc_raw[..len]);
|
||||||
|
// Probe first: a monitor without DDC/CI (or with it disabled in the OSD, or behind a
|
||||||
|
// dock/KVM that drops the channel) fails here and is skipped — never blind-write to a
|
||||||
|
// bus we can't read.
|
||||||
|
let (mut current, mut max) = (0u32, 0u32);
|
||||||
|
// SAFETY: `handle` is the live physical-monitor handle (valid until
|
||||||
|
// DestroyPhysicalMonitors below); the value pointers are valid locals ('None' for the
|
||||||
|
// code-type out-param we don't need).
|
||||||
|
let probe = unsafe {
|
||||||
|
GetVCPFeatureAndVCPFeatureReply(
|
||||||
|
handle,
|
||||||
|
VCP_POWER_MODE,
|
||||||
|
None,
|
||||||
|
&mut current,
|
||||||
|
Some(&mut max),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if probe == 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
device,
|
||||||
|
monitor = desc,
|
||||||
|
"DDC/CI: no reply to the power-mode (0xD6) probe — skipping (no DDC/CI, \
|
||||||
|
disabled in the OSD, or not passed through)"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// SAFETY: as the probe above — same live physical-monitor handle, plain value args.
|
||||||
|
let set = unsafe { SetVCPFeature(handle, VCP_POWER_MODE, value) };
|
||||||
|
if set == 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
device,
|
||||||
|
monitor = desc,
|
||||||
|
value,
|
||||||
|
"DDC/CI: power-mode set failed after a successful probe"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
device,
|
||||||
|
monitor = desc,
|
||||||
|
from = current,
|
||||||
|
to = value,
|
||||||
|
"DDC/CI: panel power mode commanded"
|
||||||
|
);
|
||||||
|
acked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SAFETY: `phys` holds exactly the handles GetPhysicalMonitorsFromHMONITOR opened for us;
|
||||||
|
// each is destroyed once, here.
|
||||||
|
if let Err(e) = unsafe { DestroyPhysicalMonitors(&phys) } {
|
||||||
|
tracing::debug!(device, "DDC/CI: DestroyPhysicalMonitors failed: {e}");
|
||||||
|
}
|
||||||
|
acked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Command every physical panel EXCEPT `exclude_gdi` (the virtual display) off via DDC/CI
|
||||||
|
/// (VCP 0xD6 → DPMS off). Call while the physical displays are still ACTIVE — i.e. immediately
|
||||||
|
/// before the `Exclusive` CCD isolate. Returns how many panels acknowledged.
|
||||||
|
pub fn panel_off_except(exclude_gdi: &str) -> u32 {
|
||||||
|
let mut acked = 0;
|
||||||
|
for m in active_monitors() {
|
||||||
|
if m.device.eq_ignore_ascii_case(exclude_gdi) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
acked += set_power(m.hmon, &m.device, POWER_OFF);
|
||||||
|
}
|
||||||
|
if acked == 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"DDC/CI: no panel accepted the off command — the experiment is a no-op on this box \
|
||||||
|
(monitors without DDC/CI, or none besides the virtual display)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
acked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort wake: command ON to every physical panel that answers. Call AFTER the CCD restore
|
||||||
|
/// has re-activated the physical paths — the returning signal alone wakes DPMS-off panels on most
|
||||||
|
/// firmware; this is the belt-and-braces for the rest.
|
||||||
|
pub fn panel_on_all() -> u32 {
|
||||||
|
let mut acked = 0;
|
||||||
|
for m in active_monitors() {
|
||||||
|
acked += set_power(m.hmon, &m.device, POWER_ON);
|
||||||
|
}
|
||||||
|
acked
|
||||||
|
}
|
||||||
@@ -102,6 +102,12 @@
|
|||||||
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
"display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.",
|
||||||
"display_game_session_auto": "Auto",
|
"display_game_session_auto": "Auto",
|
||||||
"display_game_session_dedicated": "Dediziert",
|
"display_game_session_dedicated": "Dediziert",
|
||||||
|
"display_experimental": "Experimentell",
|
||||||
|
"display_ddc": "Monitore beim Streamen ausschalten (DDC/CI)",
|
||||||
|
"display_ddc_help": "Nur Windows, wirkt bei Topologie Exklusiv. Bevor die physischen Monitore deaktiviert werden, weist der Host sie zusätzlich über den DDC/CI-Steuerkanal an, ihr Panel auszuschalten — und weckt sie am Ende des Streams wieder. Das kann ein periodisches Ruckeln beheben, das manche Setups zeigen, wenn die gestreamte Anzeige die einzige aktive ist (der dunkle Monitor sucht sonst weiter seine Eingänge ab; ein schlafen gelegtes Panel nicht). Monitore ohne DDC/CI werden übersprungen — falls ein Monitor danach nicht aufwacht, einmal seinen Power-Knopf drücken und die Option ausschalten.",
|
||||||
|
"display_ddc_disabled": "Aus",
|
||||||
|
"display_ddc_enabled": "Ein",
|
||||||
|
"display_ddc_badge": "Monitore aus via DDC/CI",
|
||||||
"display_identity_shared": "Geteilt",
|
"display_identity_shared": "Geteilt",
|
||||||
"display_identity_per_client": "Pro Client",
|
"display_identity_per_client": "Pro Client",
|
||||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||||
|
|||||||
@@ -102,6 +102,12 @@
|
|||||||
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
"display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.",
|
||||||
"display_game_session_auto": "Auto",
|
"display_game_session_auto": "Auto",
|
||||||
"display_game_session_dedicated": "Dedicated",
|
"display_game_session_dedicated": "Dedicated",
|
||||||
|
"display_experimental": "Experimental",
|
||||||
|
"display_ddc": "Turn monitors off while streaming (DDC/CI)",
|
||||||
|
"display_ddc_help": "Windows only, takes effect with Exclusive topology. Before disabling your physical monitors, the host also tells them to power their panel off over the DDC/CI monitor-control channel, and wakes them again when the stream ends. This can eliminate a periodic stutter some setups see when the streamed display is the only active one (the dark monitor keeps probing its inputs; a panel that was told to sleep doesn't). Monitors without DDC/CI support are skipped — if your monitor doesn't wake up afterwards, press its power button once and turn this off.",
|
||||||
|
"display_ddc_disabled": "Off",
|
||||||
|
"display_ddc_enabled": "On",
|
||||||
|
"display_ddc_badge": "Monitors off via DDC/CI",
|
||||||
"display_identity_shared": "Shared",
|
"display_identity_shared": "Shared",
|
||||||
"display_identity_per_client": "Per client",
|
"display_identity_per_client": "Per client",
|
||||||
"display_identity_per_client_mode": "Per client + resolution",
|
"display_identity_per_client_mode": "Per client + resolution",
|
||||||
|
|||||||
@@ -165,8 +165,9 @@ const DisplayForm: FC<{
|
|||||||
identity: effective.identity,
|
identity: effective.identity,
|
||||||
layout: effective.layout,
|
layout: effective.layout,
|
||||||
max_displays: effective.max_displays,
|
max_displays: effective.max_displays,
|
||||||
// Game-session is orthogonal to the preset — carry it through the Custom switch.
|
// Game-session + DDC are orthogonal to the preset — carry them through the Custom switch.
|
||||||
game_session: draft.game_session ?? "auto",
|
game_session: draft.game_session ?? "auto",
|
||||||
|
ddc_power_off: draft.ddc_power_off ?? false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
apply({ ...draft, preset: id as Preset });
|
apply({ ...draft, preset: id as Preset });
|
||||||
@@ -181,6 +182,8 @@ const DisplayForm: FC<{
|
|||||||
preset: "custom",
|
preset: "custom",
|
||||||
...p.fields,
|
...p.fields,
|
||||||
game_session: p.game_session ?? "auto",
|
game_session: p.game_session ?? "auto",
|
||||||
|
// The experimental DDC axis isn't part of a preset — keep the current setting.
|
||||||
|
ddc_power_off: draft.ddc_power_off ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
|
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
|
||||||
@@ -464,6 +467,37 @@ const DisplayForm: FC<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* EXPERIMENTAL: DDC/CI panel power-off — orthogonal like game-session (survives preset
|
||||||
|
switches, applies immediately). Windows-only in effect, acted on at the Exclusive isolate. */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="block">{m.display_ddc()}</Label>
|
||||||
|
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
|
||||||
|
{m.display_experimental()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{([false, true] as const).map((on) => (
|
||||||
|
<Button
|
||||||
|
key={String(on)}
|
||||||
|
size="sm"
|
||||||
|
variant={(draft.ddc_power_off ?? false) === on ? "default" : "outline"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...draft, ddc_power_off: on };
|
||||||
|
setDraft(next);
|
||||||
|
apply(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{on ? m.display_ddc_enabled() : m.display_ddc_disabled()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="max-w-prose text-xs text-muted-foreground">{m.display_ddc_help()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* What's in force right now */}
|
{/* What's in force right now */}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||||
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
||||||
@@ -476,6 +510,9 @@ const DisplayForm: FC<{
|
|||||||
{(draft.game_session ?? "auto") === "dedicated" && (
|
{(draft.game_session ?? "auto") === "dedicated" && (
|
||||||
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge>
|
<Badge variant="secondary">{m.display_game_session_dedicated()}</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(draft.ddc_power_off ?? false) && (
|
||||||
|
<Badge variant="outline">{m.display_ddc_badge()}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user