perf(host/windows): SendInput retry-on-failure model (two-process step 2)

The injector reattached the input desktop (OpenInputDesktop + SetThreadDesktop,
two syscalls) before EVERY event. Now it stays bound to its desktop and only
reattaches on a SendInput short write (the input desktop switched into UAC/lock)
+ retries once — Sunshine's model. No steady-state per-event overhead; still
follows the desktop across the secure boundary, serving both desktops.

Validated on the RTX 4090 (host as SYSTEM): client-rs --input-test injected for
~6s with no "blocked desktop" errors. Completes all 6 steps of the two-process
secure-desktop build; only a real-UAC user smoke test remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 08:30:49 +00:00
parent 63a96ae285
commit 36c3b11a7f
+22 -11
View File
@@ -1,8 +1,9 @@
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of //! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games, //! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games,
//! scancode keyboard, scroll, buttons. The client already sends Windows VK codes, so there is no //! scancode keyboard, scroll, buttons. The client already sends Windows VK codes, so there is no
//! keycode table. Survives UAC/lock desktop switches by reattaching the thread to the current //! keycode table. Survives UAC/lock desktop switches with Sunshine's retry-on-failure model: the
//! input desktop before each event (`OpenInputDesktop`/`SetThreadDesktop`). //! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when
//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead.
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
@@ -68,11 +69,20 @@ impl SendInputInjector {
} }
} }
fn send(inputs: &[INPUT]) -> Result<()> { /// Inject with Sunshine's retry-on-failure model: the thread stays bound to whatever desktop it
/// last attached to (no per-event `OpenInputDesktop`/`SetThreadDesktop` — two syscalls saved on
/// every mouse move), and only when `SendInput` reports a short write (0 = the input desktop
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
if n as usize == inputs.len() {
return Ok(());
}
// Short write → the input desktop likely changed. Reattach + retry once.
self.reattach_input_desktop();
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) }; let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
if n as usize != inputs.len() { if n as usize != inputs.len() {
// 0 = blocked (different/secure desktop). Surface as Err so the host service drops +
// reopens the injector (which reattaches the input desktop).
anyhow::bail!( anyhow::bail!(
"SendInput injected {n}/{} events (blocked desktop?)", "SendInput injected {n}/{} events (blocked desktop?)",
inputs.len() inputs.len()
@@ -94,7 +104,8 @@ impl Drop for SendInputInjector {
impl InputInjector for SendInputInjector { impl InputInjector for SendInputInjector {
fn inject(&mut self, event: &InputEvent) -> Result<()> { fn inject(&mut self, event: &InputEvent) -> Result<()> {
self.reattach_input_desktop(); // No per-event desktop reattach — `send` reattaches lazily only on a short write (desktop
// switch). The injector is bound to the input desktop at open() and follows switches on demand.
match event.kind { match event.kind {
InputKind::MouseMove => { InputKind::MouseMove => {
let mi = MOUSEINPUT { let mi = MOUSEINPUT {
@@ -105,7 +116,7 @@ impl InputInjector for SendInputInjector {
time: 0, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
}; };
Self::send(&[mouse(mi)]) self.send(&[mouse(mi)])
} }
InputKind::MouseMoveAbs => { InputKind::MouseMoveAbs => {
let w = (event.flags >> 16) & 0xffff; let w = (event.flags >> 16) & 0xffff;
@@ -128,7 +139,7 @@ impl InputInjector for SendInputInjector {
time: 0, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
}; };
Self::send(&[mouse(mi)]) self.send(&[mouse(mi)])
} }
InputKind::MouseButtonDown | InputKind::MouseButtonUp => { InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
let down = event.kind == InputKind::MouseButtonDown; let down = event.kind == InputKind::MouseButtonDown;
@@ -183,7 +194,7 @@ impl InputInjector for SendInputInjector {
time: 0, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
}; };
Self::send(&[mouse(mi)]) self.send(&[mouse(mi)])
} }
InputKind::MouseScroll => { InputKind::MouseScroll => {
// GameStream WHEEL_DELTA(120) units. Windows WHEEL positive=up (matches GameStream — // GameStream WHEEL_DELTA(120) units. Windows WHEEL positive=up (matches GameStream —
@@ -201,7 +212,7 @@ impl InputInjector for SendInputInjector {
time: 0, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
}; };
Self::send(&[mouse(mi)]) self.send(&[mouse(mi)])
} }
InputKind::KeyDown | InputKind::KeyUp => { InputKind::KeyDown | InputKind::KeyUp => {
let down = event.kind == InputKind::KeyDown; let down = event.kind == InputKind::KeyDown;
@@ -226,7 +237,7 @@ impl InputInjector for SendInputInjector {
time: 0, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
}; };
Self::send(&[key(ki)]) self.send(&[key(ki)])
} }
// Gamepad goes through ViGEm (separate backend). Touch: no SendInput equivalent -> no-op. // Gamepad goes through ViGEm (separate backend). Touch: no SendInput equivalent -> no-op.
InputKind::GamepadButton InputKind::GamepadButton