From 745ad65b0cd9c2122c0cdee7edb6558ea58e09b8 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Wed, 19 Aug 2026 10:31:54 -0700 Subject: [PATCH] Narrow the release cycle to the interface that had the controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acquire path has cycled a single interface since the receiver work; the release path still took down all four. Only one slot is ever occupied, so the other three were being disabled and re-enabled for nothing — and each one handed Steam another arrival to react to, when the only arrival that matters is on the interface it will actually read the controller from. The cost was hidden until the reopen veto was fixed, because the vetoes dominated: a release cycle ran 15.47s, of which about 13s was two vetoed interfaces. Measured end to end by launching a Steam game in auto mode 2, which is the only thing that exercises this path: 10:33:30.394 AUTO: steam state 2, want=0 10:33:30.394 RELEASE: closing all device handles 10:33:30.459 RELEASE: cycling device so Steam sees an arrival 10:33:31.512 CYCLE: done after 1063 ms 10:33:31.518 CONNECT mi_02, and the rest after it 1.06s against 15.47s, one interface cycled rather than four, no Kernel-PnP 225 events, and the reopens land after the cycle instead of 26ms into it. The handoff itself is confirmed rather than assumed: quitting the game had our next claim refused with "another process holds a write handle", so Steam had genuinely adopted the controller in the interval. We then took it back on one narrowed cycle, 2.2s from the game exiting to game mode being live. Unconditional here, unlike the acquire path, which narrows only its first attempt so that a wrong guess cannot keep costing it across retries. A release has no second attempt to protect, and an empty path already deletes the request file and falls back to cycling everything. LastLivePath is read before ReleaseDevices rather than after. The value survives either way — it is only ever assigned, never cleared — but taking it while the slots are still up has the two paths asking the same question at the same point. Co-Authored-By: Claude Opus 5 --- src/app/TrayApp.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index ec9123c..0de3599 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -746,6 +746,10 @@ void TrayApp::ReleaseControl() { // foreground watcher keeps it current whether or not the device is ours, // which is what that reset was standing in for. const bool hadControl = m_controller->IsGameModeActive(); + // Read while the slots are still up, as the acquire path does. The value + // outlives them either way — it is only ever assigned — but taking it here + // keeps the two paths asking the same question at the same point. + const std::wstring livePath = m_controller->LastLivePath(); // Good citizen: restore lizard mode and close our HID handles so // Steam can claim the controller without contention. m_controller->ReleaseDevices(); @@ -757,6 +761,19 @@ void TrayApp::ReleaseControl() { // announces itself; this one used to not, which made a device left // disabled by an interrupted release look like it came from nowhere. EventLog::Write("RELEASE: cycling device so Steam sees an arrival"); + // Narrowed to the interface that actually had the controller, for the + // same reason the acquire path narrows: a receiver publishes one per + // slot and only one is ever occupied, so cycling all four multiplies + // the downtime and hands Steam an arrival per interface to react to. + // What Steam has to see is an arrival on the interface it will read + // the controller from, and that is this one. + // + // Unconditional here, unlike the acquire path, which narrows only its + // first attempt so a wrong guess cannot keep costing it. A release has + // no second attempt to protect, and an empty path already falls back + // to cycling everything. + m_cycleRequestPath = livePath; + RequestNarrowCycle(m_cycleRequestPath); RestartControllerDevices(); } }