fix(pf-win-display): follow the input desktop so a UAC prompt can't refuse display writes
Field-reported 2026-07-23: a consent prompt left up on an unattended host made
the box unreachable. Every connect black-screened and failed after ~60 s, and
the host had to be reached over SSH to clear the prompt.
Windows refuses ChangeDisplaySettingsEx/SetDisplayConfig issued from a thread
that is not on the desktop currently receiving input. While UAC (or the lock /
logon screen) is up that desktop is Winlogon, and the host — which the service
launches explicitly onto WinSta0\Default — got DISP_CHANGE_FAILED /
ERROR_ACCESS_DENIED for every write. A session starting in that window could
never set its virtual display's mode, so the capturer sized its ring to a stale
mode, every composed frame was dropped for a size mismatch, and bring-up burned
8 retries before giving up.
Measured on glass (RTX box, SYSTEM host in console session 2, real consent
prompt up, virtual display active):
INPUT desktop = Winlogon
UNBOUND CDS_TEST -> -1 (DISP_CHANGE_FAILED)
UNBOUND SDC_VALIDATE -> 0x5 (ERROR_ACCESS_DENIED)
BOUND CDS_TEST -> 0 (DISP_CHANGE_SUCCESSFUL)
BOUND SDC_VALIDATE -> 0x0 (ERROR_SUCCESS)
New input_desktop.rs mirrors pf-inject's sendinput.rs retry model: issue the
write, and only when it fails the way a wrong-desktop write fails, rebind this
thread to the current input desktop and retry once. A working write is never
touched, so the normal path is unchanged. The retry predicate stays narrow
(ERROR_ACCESS_DENIED only) so the unrelated 0x57 exclusive-mode topology bug is
not re-issued and mis-attributed. Unlike sendinput's dedicated injector thread,
the binding here is SCOPED — a shared display-write thread left on a Winlogon
desktop that is later destroyed would fail every subsequent write, which is the
wedge this removes.
Applied to all eight SetDisplayConfig sites and both ChangeDisplaySettingsExW
calls in set_active_mode. The isolate site now decides its supplied config once,
outside the write, so a retry re-issues the identical config rather than logging
its escalation twice.
A save is logged, because a silent one is indistinguishable in a field log from
a write that never needed saving — which made the first on-glass verification of
this change inconclusive.
Both ACCESS_DENIED diagnostics now ask which cause applies instead of naming
only the disconnected-RDP one: that phrasing sent this investigation chasing a
phantom RDP session on a host that was already service-launched in the console
session, while a consent prompt was the actual cause.
Verified on glass: connect with a consent prompt up now reaches first frame in
3.0 s (was a black screen then "Connection failed" after ~60 s), with six
"retried bound to it and it applied desktop=Winlogon" lines and rc=0x0
throughout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,5 +31,8 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
# console_session_mismatch: WTSGetActiveConsoleSessionId + ProcessIdToSessionId + GetCurrentProcessId.
|
# console_session_mismatch: WTSGetActiveConsoleSessionId + ProcessIdToSessionId + GetCurrentProcessId.
|
||||||
"Win32_System_RemoteDesktop",
|
"Win32_System_RemoteDesktop",
|
||||||
|
# input_desktop: OpenInputDesktop/SetThreadDesktop/GetUserObjectInformationW — display writes
|
||||||
|
# follow the input desktop so a UAC/lock screen can't refuse them.
|
||||||
|
"Win32_System_StationsAndDesktops",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! Make display-config writes follow the INPUT desktop.
|
||||||
|
//!
|
||||||
|
//! Windows refuses `ChangeDisplaySettingsEx` / `SetDisplayConfig` issued from a thread that is not
|
||||||
|
//! on the desktop currently receiving input. While a UAC consent prompt (or the lock / logon
|
||||||
|
//! screen) is up, the input desktop is `Winlogon`; a host thread sitting on `WinSta0\Default` — the
|
||||||
|
//! desktop the service explicitly launches it onto — then gets `DISP_CHANGE_FAILED` /
|
||||||
|
//! `ERROR_ACCESS_DENIED` for EVERY write. A session starting in that window can never set its
|
||||||
|
//! virtual display's mode, so the capturer sizes its ring to a stale mode, every composed frame is
|
||||||
|
//! dropped for a size mismatch, and the client sits on a black screen until bring-up runs out of
|
||||||
|
//! retries. Field-reported 2026-07-23: a consent prompt left up on an unattended host made the box
|
||||||
|
//! unreachable until someone walked over to it.
|
||||||
|
//!
|
||||||
|
//! Measured on-glass that same day (RTX 4090 box, SYSTEM host in console session 2, a real
|
||||||
|
//! interactive consent prompt up, virtual display `\\.\DISPLAY42` active):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! INPUT desktop = Winlogon
|
||||||
|
//! UNBOUND CDS_TEST -> -1 (DISP_CHANGE_FAILED) <- the field failure, reproduced
|
||||||
|
//! UNBOUND SDC_VALIDATE -> 0x5 (ERROR_ACCESS_DENIED) <- the field failure, reproduced
|
||||||
|
//! bound thread desktop -> Winlogon
|
||||||
|
//! BOUND CDS_TEST -> 0 (DISP_CHANGE_SUCCESSFUL)
|
||||||
|
//! BOUND SDC_VALIDATE -> 0x0 (ERROR_SUCCESS)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The retry model mirrors `pf-inject`'s `sendinput.rs`: do NOT pay
|
||||||
|
//! `OpenInputDesktop`/`SetThreadDesktop` on every write — issue the write, and only when it fails
|
||||||
|
//! the way a wrong-desktop write fails do we rebind and retry once. That keeps the normal path
|
||||||
|
//! byte-for-byte as it was (a working write is never touched) and makes this strictly additive.
|
||||||
|
//!
|
||||||
|
//! Unlike sendinput's injector — a dedicated thread that KEEPS its binding — these helpers run on
|
||||||
|
//! shared/task threads, so the binding here is SCOPED: [`InputDesktopBinding`] restores the
|
||||||
|
//! thread's original desktop on drop. A thread left bound to a `Winlogon` desktop that is later
|
||||||
|
//! destroyed (prompt dismissed) would fail every subsequent display write for the life of the
|
||||||
|
//! process — the exact wedge this module exists to remove, so it must not be introduced here.
|
||||||
|
|
||||||
|
use windows::Win32::Foundation::HANDLE;
|
||||||
|
use windows::Win32::System::StationsAndDesktops::{
|
||||||
|
CloseDesktop, GetThreadDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
|
||||||
|
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::GetCurrentThreadId;
|
||||||
|
|
||||||
|
/// `GENERIC_ALL` for the desktop open. The `windows` crate models desktop access as its own flag
|
||||||
|
/// type and doesn't export the generic rights, so spell it out (same constant `sendinput.rs` and
|
||||||
|
/// the cursor poller use).
|
||||||
|
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||||
|
|
||||||
|
/// This thread's binding to the input desktop, restored on drop.
|
||||||
|
///
|
||||||
|
/// `GetThreadDesktop` returns a BORROWED handle (documented: it creates no new handle and must not
|
||||||
|
/// be closed), so only the handle `OpenInputDesktop` produced is closed here — and only after the
|
||||||
|
/// thread has been moved back off it.
|
||||||
|
pub(crate) struct InputDesktopBinding {
|
||||||
|
previous: HDESK,
|
||||||
|
input: HDESK,
|
||||||
|
/// `UOI_NAME` of the desktop bound to, for the "this write only landed because we rebound"
|
||||||
|
/// log. Read once at bind time (the failure path only), never in steady state.
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputDesktopBinding {
|
||||||
|
/// Bind the calling thread to the desktop currently receiving input. `None` when there is no
|
||||||
|
/// reachable input desktop (not privileged for `Winlogon` — a host that is not SYSTEM) or the
|
||||||
|
/// rebind is refused, in which case the caller keeps whatever result it already had rather than
|
||||||
|
/// changing behaviour.
|
||||||
|
pub(crate) fn bind() -> Option<Self> {
|
||||||
|
// SAFETY: all four are FFI calls taking by-value args only. `GetThreadDesktop` yields a
|
||||||
|
// borrowed handle for THIS thread (never closed here). `OpenInputDesktop` yields an owned
|
||||||
|
// `HDESK` only on `Ok`; it is either installed by `SetThreadDesktop` (and then owned by the
|
||||||
|
// returned guard, which closes it exactly once in `Drop`) or closed right here on failure —
|
||||||
|
// so it is closed exactly once on every path and never used after close. `SetThreadDesktop`
|
||||||
|
// rebinds only the calling thread, which owns no windows or hooks (these are display-config
|
||||||
|
// worker threads), so it cannot fail on that account.
|
||||||
|
unsafe {
|
||||||
|
let previous = GetThreadDesktop(GetCurrentThreadId()).ok()?;
|
||||||
|
let input = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let name = desktop_name(input).unwrap_or_else(|| "<unnamed>".into());
|
||||||
|
if SetThreadDesktop(input).is_err() {
|
||||||
|
let _ = CloseDesktop(input);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
previous,
|
||||||
|
input,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputDesktopBinding {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.previous` is the borrowed desktop this thread started on and `self.input`
|
||||||
|
// is the handle this guard uniquely owns. The thread is moved back FIRST, so the handle is
|
||||||
|
// no longer the thread's desktop when it is closed — closed exactly once, never used after.
|
||||||
|
unsafe {
|
||||||
|
let _ = SetThreadDesktop(self.previous);
|
||||||
|
let _ = CloseDesktop(self.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UOI_NAME` of the current input desktop — `Some("Winlogon")` while a UAC consent prompt, the
|
||||||
|
/// lock screen or the logon screen owns input, `Some("Default")` in normal operation, `None` when
|
||||||
|
/// it cannot be opened at all.
|
||||||
|
pub(crate) fn input_desktop_name() -> Option<String> {
|
||||||
|
// SAFETY: `OpenInputDesktop` yields an owned handle only on `Ok`, which is closed exactly once
|
||||||
|
// below and not used after.
|
||||||
|
unsafe {
|
||||||
|
let h = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let name = desktop_name(h);
|
||||||
|
let _ = CloseDesktop(h);
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UOI_NAME` of an already-open desktop handle. Borrows `h` — closing it stays the caller's job.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `h` must be a live desktop handle for the duration of the call.
|
||||||
|
unsafe fn desktop_name(h: HDESK) -> Option<String> {
|
||||||
|
let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare
|
||||||
|
let mut needed = 0u32;
|
||||||
|
// SAFETY: `h` is live per this fn's contract; `name`/`needed` are live out-params and the call
|
||||||
|
// writes at most `nlength` bytes, exactly the size passed.
|
||||||
|
GetUserObjectInformationW(
|
||||||
|
HANDLE(h.0),
|
||||||
|
UOI_NAME,
|
||||||
|
Some(name.as_mut_ptr().cast()),
|
||||||
|
(name.len() * 2) as u32,
|
||||||
|
Some(&mut needed),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
|
||||||
|
Some(String::from_utf16_lossy(&name[..len]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when the input desktop is a SECURE one (`UOI_NAME` != `Default`: `Winlogon` for UAC
|
||||||
|
/// consent / lock / logon, or a screen-saver desktop) — i.e. when display writes from the host's
|
||||||
|
/// own desktop are being refused for that reason. `false` when it is normal OR unreadable: this
|
||||||
|
/// only ever phrases a diagnostic, so an unknown desktop must not claim the secure one is up.
|
||||||
|
pub(crate) fn input_desktop_is_secure() -> bool {
|
||||||
|
input_desktop_name().is_some_and(|n| !n.eq_ignore_ascii_case("Default"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ERROR_ACCESS_DENIED` — exactly how Windows refuses a `SetDisplayConfig` issued from a thread
|
||||||
|
/// that is not on the input desktop (measured, see the module header). Narrower than "any error" on
|
||||||
|
/// purpose: the CCD path also returns `ERROR_INVALID_PARAMETER` (0x57) for the unrelated
|
||||||
|
/// exclusive-mode topology bug, and re-issuing THAT on another desktop would only confuse its
|
||||||
|
/// diagnosis.
|
||||||
|
const SDC_ACCESS_DENIED: i32 = 5;
|
||||||
|
|
||||||
|
/// [`retry_on_input_desktop`] specialised for the CCD writes, which all return a Win32 error code.
|
||||||
|
pub(crate) fn retry_set_display_config(write: impl FnMut() -> i32) -> i32 {
|
||||||
|
retry_on_input_desktop(|rc| *rc == SDC_ACCESS_DENIED, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a display-config write; if it comes back the way a write from the wrong desktop comes back,
|
||||||
|
/// bind this thread to the CURRENT input desktop and run it exactly once more.
|
||||||
|
///
|
||||||
|
/// `denied` decides which results are worth a retry — keep it NARROW (the specific
|
||||||
|
/// wrong-desktop verdict), so a genuinely bad mode or a driver-level refusal is not re-issued and
|
||||||
|
/// mis-attributed. The binding is dropped before returning, so the calling thread always leaves on
|
||||||
|
/// the desktop it arrived on.
|
||||||
|
pub(crate) fn retry_on_input_desktop<T>(
|
||||||
|
denied: impl Fn(&T) -> bool,
|
||||||
|
mut write: impl FnMut() -> T,
|
||||||
|
) -> T {
|
||||||
|
let first = write();
|
||||||
|
if !denied(&first) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
// Only worth the rebind when the input desktop is genuinely elsewhere; on a normal desktop the
|
||||||
|
// refusal means something else entirely and a retry would just repeat it.
|
||||||
|
let Some(binding) = InputDesktopBinding::bind() else {
|
||||||
|
return first;
|
||||||
|
};
|
||||||
|
let second = write();
|
||||||
|
if !denied(&second) {
|
||||||
|
// Say so. A silent save is indistinguishable in a log from a write that never needed
|
||||||
|
// saving, which makes "did the fix fire?" unanswerable in the field — and made the first
|
||||||
|
// on-glass verification of this very change inconclusive.
|
||||||
|
tracing::info!(
|
||||||
|
desktop = %binding.name,
|
||||||
|
"display write was refused off the input desktop — retried bound to it and it applied \
|
||||||
|
(a UAC prompt / lock screen owns input; the session continues normally)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
second
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub mod display_events;
|
pub mod display_events;
|
||||||
|
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod input_desktop;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub mod monitor_devnode;
|
pub mod monitor_devnode;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ use windows::Win32::Devices::Display::{
|
|||||||
use windows::Win32::Foundation::POINTL;
|
use windows::Win32::Foundation::POINTL;
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT,
|
||||||
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||||
};
|
};
|
||||||
|
|
||||||
use punktfunk_core::Mode;
|
use punktfunk_core::Mode;
|
||||||
@@ -62,7 +62,9 @@ use punktfunk_core::Mode;
|
|||||||
pub unsafe fn force_extend_topology() {
|
pub unsafe fn force_extend_topology() {
|
||||||
// A topology flag with no supplied path/mode arrays tells the OS to recompute + apply that preset
|
// A topology flag with no supplied path/mode arrays tells the OS to recompute + apply that preset
|
||||||
// for the currently-connected displays (the same code path DisplaySwitch.exe drives).
|
// for the currently-connected displays (the same code path DisplaySwitch.exe drives).
|
||||||
let rc = SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND);
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"display topology forced to EXTEND (a new IddCx monitor would otherwise be CLONED onto the \
|
"display topology forced to EXTEND (a new IddCx monitor would otherwise be CLONED onto the \
|
||||||
@@ -152,11 +154,13 @@ pub unsafe fn activate_target_path(target_id: u32) -> bool {
|
|||||||
// SAVE_TO_DATABASE so Windows remembers the arrangement — the next same-identity ADD (the driver
|
// SAVE_TO_DATABASE so Windows remembers the arrangement — the next same-identity ADD (the driver
|
||||||
// reuses the slot's EDID serial/ConnectorIndex) then auto-activates from the persistence DB and
|
// reuses the slot's EDID serial/ConnectorIndex) then auto-activates from the persistence DB and
|
||||||
// skips this whole fallback ladder.
|
// skips this whole fallback ladder.
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
Some(supplied.as_slice()),
|
SetDisplayConfig(
|
||||||
Some(modes.as_slice()),
|
Some(supplied.as_slice()),
|
||||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
Some(modes.as_slice()),
|
||||||
);
|
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
||||||
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id,
|
target_id,
|
||||||
@@ -273,14 +277,16 @@ pub unsafe fn force_mode_reenumeration() -> bool {
|
|||||||
let Some((paths, modes)) = query_active_config() else {
|
let Some((paths, modes)) = query_active_config() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
Some(paths.as_slice()),
|
SetDisplayConfig(
|
||||||
Some(modes.as_slice()),
|
Some(paths.as_slice()),
|
||||||
SDC_APPLY
|
Some(modes.as_slice()),
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
SDC_APPLY
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_ALLOW_CHANGES
|
||||||
);
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
|
)
|
||||||
|
});
|
||||||
if rc != 0 {
|
if rc != 0 {
|
||||||
tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}");
|
tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}");
|
||||||
}
|
}
|
||||||
@@ -625,9 +631,15 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
|
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
|
||||||
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
|
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
|
||||||
// trailing args are null, and the API only reads its inputs.
|
// trailing args are null, and the API only reads its inputs.
|
||||||
let test = unsafe {
|
// A CDS write from a thread that is not on the input desktop is refused with DISP_CHANGE_FAILED
|
||||||
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
// (UAC consent / lock screen up) — retry it bound to that desktop rather than declaring the mode
|
||||||
};
|
// unsupported, which is what stranded sessions on a black screen for a whole bring-up.
|
||||||
|
let test = crate::input_desktop::retry_on_input_desktop(
|
||||||
|
|rc| *rc == DISP_CHANGE_FAILED,
|
||||||
|
|| unsafe {
|
||||||
|
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
||||||
|
},
|
||||||
|
);
|
||||||
if test != DISP_CHANGE_SUCCESSFUL {
|
if test != DISP_CHANGE_SUCCESSFUL {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
result = test.0,
|
result = test.0,
|
||||||
@@ -642,15 +654,20 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
|
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
|
||||||
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
|
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
|
||||||
// and the API only reads its inputs.
|
// and the API only reads its inputs.
|
||||||
let apply = unsafe {
|
// Same wrong-desktop retry as the validate above: the two calls bind independently, so an apply
|
||||||
ChangeDisplaySettingsExW(
|
// still lands even when the secure desktop came up between them.
|
||||||
PCWSTR(wname.as_ptr()),
|
let apply = crate::input_desktop::retry_on_input_desktop(
|
||||||
Some(&dm),
|
|rc| *rc == DISP_CHANGE_FAILED,
|
||||||
None,
|
|| unsafe {
|
||||||
CDS_UPDATEREGISTRY,
|
ChangeDisplaySettingsExW(
|
||||||
None,
|
PCWSTR(wname.as_ptr()),
|
||||||
)
|
Some(&dm),
|
||||||
};
|
None,
|
||||||
|
CDS_UPDATEREGISTRY,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
if apply == DISP_CHANGE_SUCCESSFUL {
|
if apply == DISP_CHANGE_SUCCESSFUL {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"{gdi_name}: active mode set to {}x{}@{}",
|
"{gdi_name}: active mode set to {}x{}@{}",
|
||||||
@@ -672,12 +689,20 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
|
|
||||||
/// Human decode for a failed `ChangeDisplaySettingsExW` result. The two codes worth telling apart
|
/// Human decode for a failed `ChangeDisplaySettingsExW` result. The two codes worth telling apart
|
||||||
/// in a field log: `BADMODE` (the display's mode list genuinely lacks the mode) vs `FAILED` (the
|
/// in a field log: `BADMODE` (the display's mode list genuinely lacks the mode) vs `FAILED` (the
|
||||||
/// write itself was rejected — on a healthy driver that is the signature of a host process without
|
/// write itself was rejected). An earlier revision printed "mode not advertised?" for BOTH, which
|
||||||
/// console-session access, e.g. one trapped in a disconnected RDP session). An earlier revision
|
/// sent a lid-closed field report chasing the wrong cause.
|
||||||
/// printed "mode not advertised?" for BOTH, which sent a lid-closed field report chasing the wrong
|
///
|
||||||
/// cause.
|
/// `FAILED` itself has two causes needing opposite fixes — no console-session access (disconnected
|
||||||
|
/// RDP) versus the right session but the wrong desktop (UAC / lock / logon screen owns input) — so
|
||||||
|
/// it asks which before naming one. See [`sdc_access_denied_hint`] for the same split on the CCD
|
||||||
|
/// side.
|
||||||
fn disp_change_reason(rc: i32) -> &'static str {
|
fn disp_change_reason(rc: i32) -> &'static str {
|
||||||
match rc {
|
match rc {
|
||||||
|
-1 if crate::input_desktop::input_desktop_is_secure() => {
|
||||||
|
"DISP_CHANGE_FAILED: the SECURE desktop owns input — a UAC consent prompt, the lock \
|
||||||
|
screen or the logon screen is up, and display writes are refused off it. Dismiss the \
|
||||||
|
prompt on the host"
|
||||||
|
}
|
||||||
-1 => {
|
-1 => {
|
||||||
"DISP_CHANGE_FAILED: the display write was rejected — a host without console-session \
|
"DISP_CHANGE_FAILED: the display write was rejected — a host without console-session \
|
||||||
access (disconnected RDP session / non-console session) fails ALL display writes \
|
access (disconnected RDP session / non-console session) fails ALL display writes \
|
||||||
@@ -691,15 +716,28 @@ fn disp_change_reason(rc: i32) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appended to `SetDisplayConfig` failure logs when rc is `ERROR_ACCESS_DENIED` (0x5) — per MS
|
/// Appended to `SetDisplayConfig` failure logs when rc is `ERROR_ACCESS_DENIED` (0x5). Every other
|
||||||
/// docs "the caller does not have access to the console session", the field signature of a host
|
/// rc gets no hint.
|
||||||
/// running in a disconnected RDP / non-console session. Every other rc gets no hint.
|
///
|
||||||
|
/// `ERROR_ACCESS_DENIED` has TWO field causes and they need opposite fixes, so ask which one before
|
||||||
|
/// naming it. MS docs say only "the caller does not have access to the console session", which is
|
||||||
|
/// the disconnected-RDP / non-console case — but the SAME rc comes back when the host IS in the
|
||||||
|
/// console session and merely off the input desktop (UAC consent, lock or logon screen up). Naming
|
||||||
|
/// only the first sent a 2026-07-23 investigation chasing a phantom RDP session while a consent
|
||||||
|
/// prompt was the actual cause, on a host the message told to "run via the installed service" —
|
||||||
|
/// which it already was. [`crate::input_desktop`] now retries these bound to the input desktop, so
|
||||||
|
/// seeing this at all means even that did not help.
|
||||||
fn sdc_access_denied_hint(rc: i32) -> &'static str {
|
fn sdc_access_denied_hint(rc: i32) -> &'static str {
|
||||||
if rc == 5 {
|
if rc != 5 {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if crate::input_desktop::input_desktop_is_secure() {
|
||||||
|
" (ERROR_ACCESS_DENIED: the SECURE desktop owns input — a UAC consent prompt, the lock \
|
||||||
|
screen or the logon screen is up, and display writes are refused off it. Dismiss the \
|
||||||
|
prompt on the host)"
|
||||||
|
} else {
|
||||||
" (ERROR_ACCESS_DENIED: the host has no console-session access — disconnected RDP \
|
" (ERROR_ACCESS_DENIED: the host has no console-session access — disconnected RDP \
|
||||||
session? run via the installed service so it tracks the console session)"
|
session? run via the installed service so it tracks the console session)"
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,7 +966,9 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
||||||
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
|
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
|
||||||
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
|
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
|
||||||
let rc = if others > 0 && attempt >= 2 {
|
// The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a
|
||||||
|
// wrong-desktop retry re-issues the identical config instead of logging the escalation twice.
|
||||||
|
let keep_only = (others > 0 && attempt >= 2).then(|| {
|
||||||
// ESCALATION (attempt 2+): supply ONLY the keep paths. Field-reported (AMD +
|
// ESCALATION (attempt 2+): supply ONLY the keep paths. Field-reported (AMD +
|
||||||
// pf-vdisplay): carrying the doomed path in the array — inactive, modes unpinned —
|
// pf-vdisplay): carrying the doomed path in the array — inactive, modes unpinned —
|
||||||
// gets the whole config rejected 0x57 on EVERY retry, so the loop alone never
|
// gets the whole config rejected 0x57 on EVERY retry, so the loop alone never
|
||||||
@@ -946,17 +986,21 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
"display isolate (CCD): escalating to a keep-only supplied config (attempt {attempt}/4, paths {}→{}, modes {}→{})",
|
"display isolate (CCD): escalating to a keep-only supplied config (attempt {attempt}/4, paths {}→{}, modes {}→{})",
|
||||||
paths.len(), kp.len(), modes.len(), km.len()
|
paths.len(), kp.len(), modes.len(), km.len()
|
||||||
);
|
);
|
||||||
SetDisplayConfig(Some(kp.as_slice()), Some(km.as_slice()), esc)
|
(kp, km, esc)
|
||||||
} else {
|
});
|
||||||
let mut flags = SDC_APPLY
|
let rc = crate::input_desktop::retry_set_display_config(|| match &keep_only {
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
Some((kp, km, esc)) => SetDisplayConfig(Some(kp.as_slice()), Some(km.as_slice()), *esc),
|
||||||
| SDC_ALLOW_CHANGES
|
None => {
|
||||||
| SDC_FORCE_MODE_ENUMERATION;
|
let mut flags = SDC_APPLY
|
||||||
if others == 0 {
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
flags |= SDC_SAVE_TO_DATABASE;
|
| SDC_ALLOW_CHANGES
|
||||||
|
| SDC_FORCE_MODE_ENUMERATION;
|
||||||
|
if others == 0 {
|
||||||
|
flags |= SDC_SAVE_TO_DATABASE;
|
||||||
|
}
|
||||||
|
SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags)
|
||||||
}
|
}
|
||||||
SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags)
|
});
|
||||||
};
|
|
||||||
// A failed apply must be VISIBLE even when the verification below passes vacuously (nothing
|
// A failed apply must be VISIBLE even when the verification below passes vacuously (nothing
|
||||||
// else was active to deactivate — the lid-closed laptop case, where the success INFO used to
|
// else was active to deactivate — the lid-closed laptop case, where the success INFO used to
|
||||||
// swallow rc=0x5): the re-commit above is load-bearing (COMMIT_MODES → ASSIGN_SWAPCHAIN),
|
// swallow rc=0x5): the re-commit above is load-bearing (COMMIT_MODES → ASSIGN_SWAPCHAIN),
|
||||||
@@ -1134,14 +1178,16 @@ pub unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) {
|
|||||||
if moved == 0 {
|
if moved == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
Some(paths.as_slice()),
|
SetDisplayConfig(
|
||||||
Some(modes.as_slice()),
|
Some(paths.as_slice()),
|
||||||
SDC_APPLY
|
Some(modes.as_slice()),
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
SDC_APPLY
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_ALLOW_CHANGES
|
||||||
);
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?positions,
|
?positions,
|
||||||
@@ -1226,14 +1272,16 @@ pub unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
Some(paths.as_slice()),
|
SetDisplayConfig(
|
||||||
Some(modes.as_slice()),
|
Some(paths.as_slice()),
|
||||||
SDC_APPLY
|
Some(modes.as_slice()),
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
SDC_APPLY
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_ALLOW_CHANGES
|
||||||
);
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
||||||
} else {
|
} else {
|
||||||
@@ -1253,11 +1301,13 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
|||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
Some(paths.as_slice()),
|
SetDisplayConfig(
|
||||||
Some(modes.as_slice()),
|
Some(paths.as_slice()),
|
||||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
Some(modes.as_slice()),
|
||||||
);
|
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||||
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!("display isolate (CCD): restored original topology");
|
tracing::info!("display isolate (CCD): restored original topology");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user