feat(host,web): experimental PnP monitor-devnode disable for Exclusive sessions
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m9s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
windows-host / package (push) Failing after 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m54s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m21s
flatpak / build-publish (push) Failing after 2m26s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Failing after 19m24s

Second experiment against the connected-but-dark-head stutter (field-proven
on the reporter's box: unplugging his standby HDMI TV removes a metronomic
~4 s double-jolt; DDC/CI is a dead end for TVs — measured on the lab LG, VCP
0xD6 gets no I2C ACK). An Exclusive isolate only removes physical monitors
from the CCD topology; their PnP devnodes stay live, so every standby wake
(auto input scan, Instant-On HPD cycling) still triggers the full Windows
reaction: PnP arrival/removal, CCD re-evaluation, DWM invalidation — the
suspected hiccup mechanism (Apollo #368's Device-Manager-refresh signature).

- New `pnp_disable_monitors` display-policy axis (default off): orthogonal
  to presets like game_session/ddc_power_off, surfaced in GET/PUT
  /display/settings + the enforced list, carried through the layout
  transform.
- windows/monitor_devnode.rs: after the isolate takes, disable exactly the
  deactivated monitors' devnodes — CCD target → monitor device path
  (DISPLAYCONFIG_TARGET_DEVICE_NAME) → PnP instance id → CM_Disable_DevNode
  with CM_DISABLE_PERSIST, so a hot-plug RE-ARRIVAL stays disabled (that
  persistence is the whole point). Teardown re-enables BEFORE the CCD
  restore (+300 ms re-arrival settle) so restored paths have their monitors
  back. Precise selection — co-installed third-party virtual displays are
  never touched.
- Crash safety: instance ids journal to <config>/pnp-disabled-monitors.json
  before disabling; serve startup re-enables leftovers from a crashed host.
  Worst case is documented in the console help (Device Manager re-enable).
- Web console: second Experimental-badged toggle (the DDC block refactored
  into a shared ExperimentalToggle), EN/DE strings, preset-switch carry.

Verified: Linux 263 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:47:40 +02:00
parent d1770c3476
commit a011aebef5
9 changed files with 362 additions and 36 deletions
+25 -3
View File
@@ -234,6 +234,15 @@ pub struct DisplayPolicy {
/// untouched.
#[serde(default)]
pub ddc_power_off: bool,
/// EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,
/// additionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose
/// hot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same
/// "connected-but-dark head" periodic-stutter class as [`Self::ddc_power_off`], but at the
/// Windows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
}
fn one() -> u32 {
@@ -258,6 +267,7 @@ impl Default for DisplayPolicy {
max_displays: 4,
game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
}
}
}
@@ -318,6 +328,7 @@ impl EffectivePolicy {
positions: BTreeMap<String, Position>,
game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
) -> DisplayPolicy {
DisplayPolicy {
version: 1,
@@ -334,6 +345,7 @@ impl EffectivePolicy {
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
game_session,
ddc_power_off,
pnp_disable_monitors,
}
}
}
@@ -454,6 +466,13 @@ impl DisplayPolicyStore {
self.get().ddc_power_off
}
/// The experimental PnP monitor-devnode-disable axis — orthogonal to the preset (like
/// [`Self::game_session`]), read directly off the stored policy (default off when
/// unconfigured).
pub fn pnp_disable_monitors(&self) -> bool {
self.get().pnp_disable_monitors
}
/// 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.
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
@@ -769,10 +788,12 @@ mod tests {
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true);
// The orthogonal axes (game-session, DDC power-off) are preserved through the transform.
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
// the transform.
assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors);
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim…
@@ -797,8 +818,9 @@ mod tests {
assert_eq!(p.keep_alive, KeepAlive::default());
assert_eq!(p.topology, Topology::Auto);
assert_eq!(p.version, 1);
// A file written before the experimental DDC axis existed defaults it OFF.
// A file written before the experimental DDC/PnP axes existed defaults them OFF.
assert!(!p.ddc_power_off);
assert!(!p.pnp_disable_monitors);
}
#[test]
@@ -121,6 +121,9 @@ struct Monitor {
/// 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,
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled
/// at this monitor's isolate — teardown re-enables them BEFORE the CCD restore.
pnp_disabled: Vec<String>,
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
gen: u64,
}
@@ -672,6 +675,7 @@ impl VirtualDisplayManager {
}
let mut ccd_saved: Option<SavedConfig> = None;
let mut ddc_panels_off = 0u32;
let mut pnp_disabled: Vec<String> = Vec::new();
match &gdi_name {
Some(n) => {
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
@@ -699,6 +703,19 @@ impl VirtualDisplayManager {
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
// crosses), under the `state` lock — the sole topology mutator.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
// additionally disable the deactivated monitors' PnP devnodes (persistent
// across hot-plug re-arrival) so a standby monitor/TV's periodic wake
// events no longer trigger the Windows reaction cascade — the suspected
// hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`).
if crate::vdisplay::policy::prefs().pnp_disable_monitors() {
if let Some(saved) = &ccd_saved {
pnp_disabled = crate::monitor_devnode::disable_for_deactivated(
saved,
added.target_id,
);
}
}
}
Topology::Primary => {
// On a headless box the IDD auto-activates as the SOLE display, so a physical
@@ -757,6 +774,7 @@ impl VirtualDisplayManager {
pinger: Some(pinger),
ccd_saved,
ddc_panels_off,
pnp_disabled,
gen: self.gen.fetch_add(1, Ordering::Relaxed),
})
}
@@ -818,6 +836,13 @@ impl VirtualDisplayManager {
}
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
if let Some(saved) = &mon.ccd_saved {
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
// them re-arrive, so the CCD restore below re-activates paths whose monitors exist
// again (a disabled devnode would leave the restored path modeless/EDID-less).
if !mon.pnp_disabled.is_empty() {
crate::monitor_devnode::enable_instances(&mon.pnp_disabled);
thread::sleep(Duration::from_millis(300));
}
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