Cycle the controller through the helper even when already elevated

RestartControllerDevices took a shortcut on an elevated run: rather than
firing the helper, it cycled every interface itself, in a loop, on the UI
thread. Each interface disables, waits a second for the removal to propagate,
and enables, so a four-interface receiver spent about five seconds inside that
loop with no message pump running.

Nothing was pumping, so every WM_DEVICECHANGE the cycle produced sat in the
queue until it finished. The first interface was back — and taken by Steam —
seconds before we drained those messages and claimed. A reporter's log in #79
shows it exactly: RELEASE at 15:49:26.049, then five seconds of no log lines at
all, then four CONNECTs inside 22ms as the queue emptied, then the claim
refused. Eleven of that session's fourteen cycles ran this path and lost; the
three that went through the helper are the ones that behaved.

It also explains the app appearing to hang mid-cycle, reported as the tray menu
opening as a white rectangle: the UI thread was inside the cycle.

The helper is a separate process, so the cycle runs while this one keeps
pumping and the arrival drives the claim as soon as it lands. Measured on the
same elevated configuration: helper starts 56ms after the request, whole
cycle-to-claim 1.27s, message loop live throughout.

Dropping the shortcut also removes a fork that had already drifted — the
in-process path wrote nothing to cycle.log, so an elevated run left no record
of a cycle having happened at all, which is why the helper's holder scan never
appeared in that reporter's logs.

The three EnsureCycleTaskRegistered call sites no longer skip registration when
elevated. They assumed an elevated run never needed the task; it does now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dylan DeverillandClaude Opus 5 committed 2026-08-19 22:46:57 -07:00
1 parent c3f58de856
commit 5e220d4724
1 file changed
+22 -35
+22 -35
View File
@@ -242,8 +242,10 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
// Taking the device back from a running Steam needs the
// elevated helper. Settle the one-time UAC prompt on this
// click instead of letting it ambush the first cycle.
if (!IsProcessElevated())
EnsureCycleTaskRegistered();
// Registered even when already elevated: every cycle goes
// through the helper now, so an elevated run needs the task
// just as much.
EnsureCycleTaskRegistered();
m_wantControl = true;
m_acquireRetries = 0;
TryAcquireController();
@@ -260,16 +262,14 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
break;
case IDM_MODE_GAME:
// Make sure the elevated helper task exists (one-time UAC when
// the installer didn't register it) before the mode needs it.
if (!IsProcessElevated())
EnsureCycleTaskRegistered();
// the installer did not register it) before the mode needs it.
EnsureCycleTaskRegistered();
SetAutoMode(AutoMode::OffOnlyInGame);
break;
case IDM_MODE_PROFILE:
// Same reason as the mode above: this one can be asked to take the
// controller while Steam is running it, and that needs the helper.
if (!IsProcessElevated())
EnsureCycleTaskRegistered();
EnsureCycleTaskRegistered();
SetAutoMode(AutoMode::OffUnlessProfile);
// With nothing to match, this mode holds the controller for
// nothing at all. Said once, on the click, because a controller
@@ -1045,34 +1045,21 @@ bool TrayApp::RestartControllerDevices() {
m_inFlightLogged = false;
m_lastCycleTick = GetTickCount64();
// On the rare elevated run, cycle directly. This works on every transport
// including Bluetooth: the devnode being restarted is the HID child, not
// the pairing (which lives up at the BTHLEDEVICE layer), and it
// re-enumerates as fast as a USB replug.
if (IsProcessElevated()) {
bool anyRestarted = false;
DeviceRestart::CycleResult summary;
for (const auto& path : SteamController::EnumerateAll()) {
DeviceRestart::CycleResult r;
if (DeviceRestart::RestartInterfaceDevice(path, r))
anyRestarted = true;
const bool better = r.kind > summary.kind
|| (r.kind == summary.kind
&& summary.vetoType == PNP_VetoTypeUnknown
&& r.vetoType != PNP_VetoTypeUnknown);
if (better) summary = r;
}
// Same status file the helper writes, so the verdict logic below does
// not care which path performed the cycle.
summary.ticks = GetTickCount64();
DeviceRestart::WriteCycleStatus(summary);
// Synchronous: the cycle has already finished and the device is back,
// so there is nothing left to hold the claim off for.
m_cycleInFlight = false;
return anyRestarted;
}
// Normal path: fire the elevated helper via its scheduled task. Starting
// Always through the helper, elevated or not. Cycling in this process is
// possible when elevated, but it is synchronous: four interfaces at a
// second apiece means five seconds with no message pump, so every
// WM_DEVICECHANGE the cycle produces sits in the queue until it finishes.
// The first interface is back and taken by Steam long before we drain
// those messages and claim, which is how an elevated run loses a race the
// helper path wins (#79) — and it is the tray menu failing to paint that
// users report as the app hanging mid-cycle.
//
// The helper is a separate process, so the cycle runs while this one keeps
// pumping and the arrival drives the claim the moment it lands. Keeping one
// path also keeps the two from drifting: the shortcut had diverged into
// different timing and different logging (it wrote no cycle.log at all).
//
// Fire the elevated helper via its scheduled task. Starting
// a task the user authored needs no elevation, so no UAC prompt here.
std::wstring run = L"schtasks.exe /Run /TN \"";
run += CYCLE_TASK_NAME;