From c8db96e03008f8501a6d4173a0fd0654b242338f Mon Sep 17 00:00:00 2001 From: potpiemuncher Date: Fri, 31 Jul 2026 22:33:58 -0500 Subject: [PATCH 1/3] Ask Windows before believing the backend is idle The stop-on-exit decision proved "idle" entirely from VIIPER's census, which is the backend's own bookkeeping. A devnode Windows still shows after the backend has forgotten it -- the problem-24 phantom the old fork's present-only SetupAPI probe existed for -- would have passed it. That is the gap lifecycle invariant (c) names: do not declare teardown finished on a probe that cannot distinguish "gone" from "cannot tell". CmTreePnpAbsenceProbe walks the Configuration Manager tree under every present devnode carrying the UDE controller's hardware ID (ROOT\USBIP_WIN2\UDE -- all of them, not the first, or a device under a second controller instance would be invisible to a probe whose whole point is proving absence). Root hubs are descended into; every non-hub node found is reported as an attached device with its problem code, and not descended into, because a composite pad's interface and HID children are that same device rather than more of them. Position rather than identity, deliberately. A pad attached over USB/IP carries the same VID/PID as a real one on a physical port, and the personas VIIPER can host make an ID list a moving target; what makes a devnode usbip-attached is living under the emulated controller. ViiperBackendStopPolicy.Decide takes the probe as a deferred Func and consults it only after every census gate has passed -- a cross-check on the final verdict, not a routine exit cost -- and judges it the way this policy judges everything: devices present, unproven, a null result and a thrown exception all leave the backend running. One asymmetry with the census is intentional. A missing controller is proven absence, because nothing can be attached through a controller that is not there; a failed census stays a failure, because devices can be alive behind an API that will not answer. The probe itself never throws -- every failure is an Unproven verdict carrying whatever stopped it, since that string is the only lead whoever reads the log will get. Ten tests: cross-check ordering (a census-level refusal must settle the matter without touching the device tree), each fail-closed path, the wording that carries the evidence, and the real walk, which must answer on any machine. Co-Authored-By: Claude Fable 5 --- .../Viiper/ViiperBackendLifecycle.cs | 67 ++- .../Viiper/ViiperPnpAbsenceProbe.cs | 444 ++++++++++++++++++ .../ViiperBackendLifecycleTests.cs | 125 ++++- DS4WindowsTests/ViiperPnpAbsenceProbeTests.cs | 86 ++++ 4 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs create mode 100644 DS4WindowsTests/ViiperPnpAbsenceProbeTests.cs diff --git a/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs b/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs index 3eca031..97cfe25 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs @@ -348,15 +348,34 @@ public static ViiperBackendStopDecision Leave(string reason) => /// reason at all leaves the backend running, because a backend left running /// costs a few megabytes while a backend killed under a live consumer takes /// that consumer's controller away. + /// + /// The census also cannot see Windows. It is the backend's + /// own bookkeeping, and a devnode Windows still shows after the backend + /// has forgotten it — a phantom — would pass it. So the final idle + /// verdict takes a second opinion from the PnP tree (invariant (c)'s + /// "prove exact-device absence"), supplied as pnpCrossCheck and + /// judged by the same rule as everything else here: anything short of + /// proven absence leaves the backend running. /// public static class ViiperBackendStopPolicy { + /// + /// Lifecycle invariant (c)'s second opinion: after the census has + /// proven the backend idle, ask Windows whether it agrees that no + /// usbip-attached device remains. Invoked only when every census gate + /// has already passed — it is a cross-check on the final "idle" + /// verdict, not a first probe — and judged fail-closed: a check that + /// reports devices, cannot answer, returns nothing, or throws all + /// leave the backend running. Null means no cross-check was requested, + /// which existing callers and tests rely on. + /// public static ViiperBackendStopDecision Decide( bool settingEnabled, ViiperOwnedBackend ownedBackend, bool backendProcessAlive, ViiperBackendCensus census, - IReadOnlyCollection ourLiveDevices) + IReadOnlyCollection ourLiveDevices, + Func pnpCrossCheck = null) { if (!settingEnabled) { @@ -425,6 +444,52 @@ public static ViiperBackendStopDecision Decide( bus.ToString(CultureInfo.InvariantCulture))))); } + if (pnpCrossCheck != null) + { + // The census is the backend's own view of what it hosts. A + // devnode Windows still shows after the backend has forgotten + // it — the phantom the old fork's present-only probe existed + // for — is invisible to it, so the final idle verdict gets a + // second opinion from the PnP tree. Every way this check can + // fall short of "proven absent" resolves to leaving the + // backend running. + ViiperPnpAbsenceProof proof; + try + { + proof = pnpCrossCheck(); + } + catch (Exception ex) + { + return ViiperBackendStopDecision.Leave( + "the PnP cross-check threw " + ex.GetType().Name + + ": " + ex.Message); + } + + if (proof == null) + { + return ViiperBackendStopDecision.Leave( + "the PnP cross-check returned no result"); + } + + if (proof.Verdict == ViiperPnpAbsenceVerdict.DevicesPresent) + { + return ViiperBackendStopDecision.Leave(string.Format( + CultureInfo.InvariantCulture, + "the backend reports itself idle, but Windows still shows {0} device(s) attached through the usbip-win2 controller ({1})", + proof.Devices.Count, string.Join("; ", proof.Devices))); + } + + if (proof.Verdict != ViiperPnpAbsenceVerdict.ProvenAbsent) + { + return ViiperBackendStopDecision.Leave( + "could not prove at the Windows PnP level that no usbip-attached device remains (" + + proof.Detail + ")"); + } + + return ViiperBackendStopDecision.Stop( + "we started it, it is hosting no buses or devices, and Windows shows no device attached through the usbip-win2 controller"); + } + return ViiperBackendStopDecision.Stop( "we started it and it is hosting no buses or devices"); } diff --git a/DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs b/DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs new file mode 100644 index 0000000..bad1374 --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs @@ -0,0 +1,444 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +namespace DS4Windows +{ + /// What the PnP cross-check could establish. + public enum ViiperPnpAbsenceVerdict + { + /// + /// Windows shows no device attached through the usbip-win2 controller + /// — either the controller hosts nothing, or the controller itself is + /// not present, in which case nothing can be attached through it. + /// + ProvenAbsent, + + /// + /// Windows still shows at least one devnode attached through the + /// usbip-win2 controller. + /// + DevicesPresent, + + /// + /// The device tree could not be read far enough to answer. Not + /// absence: "cannot tell" and "gone" are different verdicts, and only + /// one of them permits a stop. + /// + Unproven, + } + + /// + /// The answer to "does Windows agree that no usbip-attached device + /// remains?". + /// + /// This exists for lifecycle invariant (c): prove exact-device + /// absence before releasing the final protection. The backend census + /// () is the backend's own view of + /// what it hosts; a devnode Windows still shows after the backend has + /// forgotten it — the phantom case the old fork's present-only SetupAPI + /// probe was written for — is invisible to it. This type carries the + /// second opinion, taken from the PnP tree itself. + /// + public sealed class ViiperPnpAbsenceProof + { + private ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict verdict, + string detail, IReadOnlyList devices) + { + Verdict = verdict; + Detail = detail ?? string.Empty; + Devices = devices ?? Array.Empty(); + } + + public ViiperPnpAbsenceVerdict Verdict { get; } + + /// + /// Plain-language support for the verdict: what proved absence, or why + /// nothing could be proven. Empty for , + /// where is the evidence. + /// + public string Detail { get; } + + /// + /// One entry per device Windows still shows attached through the + /// controller: the device instance ID, plus its problem code when it + /// has one — a phantom devnode reads "(problem 24)" here, which is + /// exactly the state that must not be mistaken for absence. + /// + public IReadOnlyList Devices { get; } + + public static ViiperPnpAbsenceProof Absent(string detail) => + new ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict.ProvenAbsent, + detail, null); + + public static ViiperPnpAbsenceProof Present( + IReadOnlyList devices) => + new ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict.DevicesPresent, + null, devices); + + public static ViiperPnpAbsenceProof Unproven(string reason) => + new ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict.Unproven, + string.IsNullOrEmpty(reason) ? "unknown error" : reason, null); + + public override string ToString() + { + switch (Verdict) + { + case ViiperPnpAbsenceVerdict.ProvenAbsent: + return "absent (" + Detail + ")"; + case ViiperPnpAbsenceVerdict.DevicesPresent: + return string.Format(CultureInfo.InvariantCulture, + "{0} device(s) present: {1}", Devices.Count, + string.Join("; ", Devices)); + default: + return "unproven (" + Detail + ")"; + } + } + } + + /// + /// Seam over "ask Windows what is attached through the usbip-win2 + /// controller". The real implementation walks the PnP tree; tests inject + /// a fake. + /// + public interface IViiperPnpAbsenceProbe + { + ViiperPnpAbsenceProof Probe(); + } + + /// + /// Proves usbip-device absence from the Configuration Manager device tree. + /// + /// Why the tree and not a device-ID filter. A virtual pad + /// attached over USB/IP carries the same USB\VID_054C&PID_0CE6 + /// identity as a real one on a physical port, and the personas VIIPER can + /// host make the ID list a moving target. Position is the stable fact: + /// everything attached through usbip-win2 — and nothing else — lives under + /// its emulated host controller. So the probe finds every present devnode + /// whose hardware ID matches the controller + /// () and + /// walks its subtree: root hubs are descended into, and every non-hub node + /// found is reported as an attached device, without descending further — + /// a composite pad's interface and HID children are that same device, not + /// additional ones. + /// + /// What counts as present. Membership in the tree, not + /// health. A devnode with a problem code — including the problem-24 + /// "device not there" phantom that outlived teardown in the old fork — is + /// still a devnode Windows can see, so it is reported (with its problem + /// code) rather than skipped. A devnode whose status cannot be read is + /// likewise reported: unreadable is not absent. + /// + /// Failure shape. Never throws. Any error — enumeration, + /// tree walk, ID read — becomes , + /// and the caller's policy treats that exactly like "present": the stop + /// does not happen. The only cheap verdict here is the fail-closed + /// one. + /// + public sealed class CmTreePnpAbsenceProbe : IViiperPnpAbsenceProbe + { + private const int ErrorNoMoreItems = 259; + private const uint CrSuccess = 0; + private const uint CrNoSuchDevnode = 0x0000000D; + private static readonly IntPtr InvalidHandleValue = new IntPtr(-1); + + // Root hubs enumerate as USB\ROOT_HUB / ROOT_HUB20 / ROOT_HUB30; the + // prefix match covers all three without naming a controller + // generation. + private const string RootHubInstanceIdPrefix = @"USB\ROOT_HUB"; + + // MAX_DEVICE_ID_LEN, plus the terminator. + private const int DeviceIdBufferLength = 201; + + public ViiperPnpAbsenceProof Probe() + { + try + { + return ProbeCore(); + } + catch (Exception ex) + { + return ViiperPnpAbsenceProof.Unproven( + ex.GetType().Name + ": " + ex.Message); + } + } + + private static ViiperPnpAbsenceProof ProbeCore() + { + List controllers = FindControllers(out string failure); + if (failure != null) + { + return ViiperPnpAbsenceProof.Unproven(failure); + } + + if (controllers.Count == 0) + { + return ViiperPnpAbsenceProof.Absent( + "the usbip-win2 host controller (" + + ViiperDriverManifest.UdeHostControllerHardwareId + + ") is not present, so nothing can be attached through it"); + } + + List devices = new List(); + string walkFailure = CollectAttachedDevices(controllers, devices); + if (walkFailure != null) + { + return ViiperPnpAbsenceProof.Unproven(walkFailure); + } + + return devices.Count > 0 + ? ViiperPnpAbsenceProof.Present(devices) + : ViiperPnpAbsenceProof.Absent( + "the usbip-win2 host controller is present and hosts no attached device"); + } + + /// + /// Every present devnode whose hardware IDs include the usbip-win2 UDE + /// controller ID. All of them, not the first: a device under a second + /// controller instance would otherwise be invisible to a probe whose + /// whole point is proving absence. + /// + private static List FindControllers(out string failure) + { + failure = null; + List found = new List(); + + IntPtr deviceInfoSet = SetupDiGetClassDevsWithLastError(IntPtr.Zero, + null, 0, + NativeMethods.DIGCF_PRESENT | NativeMethods.DIGCF_ALLCLASSES); + if (deviceInfoSet == InvalidHandleValue) + { + failure = "SetupDiGetClassDevs could not enumerate present devices (error " + + Marshal.GetLastWin32Error().ToString(CultureInfo.InvariantCulture) + ")"; + return found; + } + + try + { + for (int index = 0; ; index++) + { + var deviceInfo = new NativeMethods.SP_DEVINFO_DATA + { + cbSize = Marshal.SizeOf(), + }; + if (!SetupDiEnumDeviceInfoWithLastError(deviceInfoSet, index, + ref deviceInfo)) + { + int error = Marshal.GetLastWin32Error(); + if (error == ErrorNoMoreItems) + { + break; + } + + failure = "SetupDiEnumDeviceInfo failed while locating the usbip-win2 controller (error " + + error.ToString(CultureInfo.InvariantCulture) + ")"; + return found; + } + + if (HasControllerHardwareId(deviceInfoSet, ref deviceInfo)) + { + found.Add((uint)deviceInfo.DevInst); + } + } + } + finally + { + NativeMethods.SetupDiDestroyDeviceInfoList(deviceInfoSet); + } + + return found; + } + + private static bool HasControllerHardwareId(IntPtr deviceInfoSet, + ref NativeMethods.SP_DEVINFO_DATA deviceInfo) + { + ulong propertyType = 0; + int requiredSize = 0; + if (NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet, + ref deviceInfo, ref NativeMethods.DEVPKEY_Device_HardwareIds, + ref propertyType, null, 0, ref requiredSize, 0)) + { + return false; + } + + if (requiredSize <= 0) + { + return false; + } + + byte[] buffer = new byte[requiredSize]; + if (!NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet, + ref deviceInfo, ref NativeMethods.DEVPKEY_Device_HardwareIds, + ref propertyType, buffer, buffer.Length, ref requiredSize, 0)) + { + return false; + } + + string raw = Encoding.Unicode.GetString(buffer); + foreach (string id in raw.Split('\0', + StringSplitOptions.RemoveEmptyEntries)) + { + if (string.Equals(id.Trim(), + ViiperDriverManifest.UdeHostControllerHardwareId, + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Walks the subtree of each controller. Returns null on success — + /// with holding one entry per attached + /// device — or the reason the walk could not be completed. + /// + private static string CollectAttachedDevices(List controllers, + List devices) + { + // Nodes whose children still need visiting: the controllers + // themselves and any root hub found under them. Non-hub nodes are + // recorded and not descended into. + Stack pending = new Stack(); + foreach (uint controller in controllers) + { + pending.Push(controller); + } + + while (pending.Count > 0) + { + uint parent = pending.Pop(); + uint result = CM_Get_Child(out uint node, parent, 0); + if (result == CrNoSuchDevnode) + { + continue; + } + + if (result != CrSuccess) + { + return "CM_Get_Child returned CONFIGRET " + + result.ToString(CultureInfo.InvariantCulture); + } + + while (true) + { + string instanceId = GetDeviceInstanceId(node); + if (instanceId == null) + { + return "CM_Get_Device_ID failed for a devnode under the usbip-win2 controller"; + } + + if (instanceId.StartsWith(RootHubInstanceIdPrefix, + StringComparison.OrdinalIgnoreCase)) + { + pending.Push(node); + } + else + { + devices.Add(DescribeDevice(node, instanceId)); + } + + uint sibling = CM_Get_Sibling(out uint next, node, 0); + if (sibling == CrNoSuchDevnode) + { + break; + } + + if (sibling != CrSuccess) + { + return "CM_Get_Sibling returned CONFIGRET " + + sibling.ToString(CultureInfo.InvariantCulture); + } + + node = next; + } + } + + return null; + } + + private static string GetDeviceInstanceId(uint devInst) + { + var buffer = new StringBuilder(DeviceIdBufferLength); + uint result = CM_Get_Device_ID(devInst, buffer, buffer.Capacity, 0); + if (result != CrSuccess) + { + return null; + } + + string id = buffer.ToString().Trim(); + return string.IsNullOrEmpty(id) ? null : id; + } + + private static string DescribeDevice(uint devInst, string instanceId) + { + uint result = CM_Get_DevNode_Status(out _, out uint problem, + devInst, 0); + if (result != CrSuccess) + { + // A node whose status cannot be read still exists; say so + // rather than pretending it is healthy or absent. + return instanceId + " (status unreadable)"; + } + + return problem == 0 + ? instanceId + : string.Format(CultureInfo.InvariantCulture, + "{0} (problem {1})", instanceId, problem); + } + + // Local declarations with SetLastError, for the same reason + // SetupApiDriverPackageInspector carries its own: the legacy + // declarations lose the Win32 error, which makes normal + // ERROR_NO_MORE_ITEMS termination indistinguishable from a failure. + [DllImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW", + CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr SetupDiGetClassDevsWithLastError( + IntPtr classGuid, string enumerator, int hwndParent, int flags); + + [DllImport("setupapi.dll", EntryPoint = "SetupDiEnumDeviceInfo", + SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetupDiEnumDeviceInfoWithLastError( + IntPtr deviceInfoSet, int memberIndex, + ref NativeMethods.SP_DEVINFO_DATA deviceInfoData); + + [DllImport("cfgmgr32.dll")] + private static extern uint CM_Get_Child(out uint childDevInst, + uint devInst, uint flags); + + [DllImport("cfgmgr32.dll")] + private static extern uint CM_Get_Sibling(out uint siblingDevInst, + uint devInst, uint flags); + + [DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode, + EntryPoint = "CM_Get_Device_IDW")] + private static extern uint CM_Get_Device_ID(uint devInst, + StringBuilder buffer, int bufferLength, uint flags); + + [DllImport("cfgmgr32.dll")] + private static extern uint CM_Get_DevNode_Status(out uint status, + out uint problemNumber, uint devInst, uint flags); + } +} diff --git a/DS4WindowsTests/ViiperBackendLifecycleTests.cs b/DS4WindowsTests/ViiperBackendLifecycleTests.cs index 675f646..a865b31 100644 --- a/DS4WindowsTests/ViiperBackendLifecycleTests.cs +++ b/DS4WindowsTests/ViiperBackendLifecycleTests.cs @@ -148,9 +148,11 @@ private static ViiperBackendStopDecision Decide( ViiperOwnedBackend owned = null, bool alive = true, ViiperBackendCensus census = null, - IReadOnlyCollection ours = null) => + IReadOnlyCollection ours = null, + Func pnp = null) => ViiperBackendStopPolicy.Decide(settingEnabled, owned ?? SomeBackend(), - alive, census ?? Idle(), ours ?? Array.Empty()); + alive, census ?? Idle(), ours ?? Array.Empty(), + pnp); [TestMethod] public void AnIdleBackendWeStartedIsStopped() @@ -260,6 +262,125 @@ public void ACensusThatCouldNotBeTakenLeavesTheBackendRunning() StringAssert.Contains(missing.Reason, "could not confirm"); } + // ---- PnP cross-check: invariant (c) -------------------------------- + + /// + /// The cross-check is a second opinion on the final idle verdict, not a + /// first probe: any census-level refusal must already have settled the + /// matter without touching the device tree. + /// + [TestMethod] + public void TheCrossCheckRunsOnlyAfterTheCensusHasProvenIdle() + { + int calls = 0; + Func counting = () => + { + calls++; + return ViiperPnpAbsenceProof.Absent("controller hosts nothing"); + }; + + ViiperBackendCensus busy = ViiperBackendCensus.Success( + new uint[] { 0 }, + new[] { new ViiperCensusDevice(0, "7", "dualshock4") }); + Decide(census: busy, pnp: counting); + Assert.AreEqual(0, calls, + "A census that already blocks the stop settles it; walking the " + + "PnP tree afterwards would be pure cost."); + + Decide(settingEnabled: false, pnp: counting); + Assert.AreEqual(0, calls); + + ViiperBackendStopDecision idle = Decide(pnp: counting); + Assert.AreEqual(1, calls); + Assert.IsTrue(idle.ShouldStop, idle.Reason); + } + + /// + /// The phantom case the check exists for: the backend census says idle, + /// Windows still shows a devnode. The devnode wins, and the log line + /// names it. + /// + [TestMethod] + public void ADeviceWindowsStillShowsBlocksTheStop() + { + ViiperBackendStopDecision decision = Decide(pnp: () => + ViiperPnpAbsenceProof.Present(new[] + { + @"USB\VID_054C&PID_0CE6\9&2AB44E7&0&1 (problem 24)", + })); + + Assert.IsFalse(decision.ShouldStop); + StringAssert.Contains(decision.Reason, "Windows still shows"); + StringAssert.Contains(decision.Reason, @"USB\VID_054C&PID_0CE6"); + StringAssert.Contains(decision.Reason, "problem 24", + "The problem code is the evidence that this is a phantom rather " + + "than a live device; the log line has to carry it."); + } + + /// + /// "Cannot tell" and "gone" are different answers, and only one of them + /// permits a stop. Same rule the census follows. + /// + [TestMethod] + public void AnAbsenceThatCouldNotBeProvenBlocksTheStop() + { + ViiperBackendStopDecision decision = Decide(pnp: () => + ViiperPnpAbsenceProof.Unproven( + "SetupDiGetClassDevs could not enumerate present devices (error 5)")); + + Assert.IsFalse(decision.ShouldStop); + StringAssert.Contains(decision.Reason, "could not prove"); + StringAssert.Contains(decision.Reason, "error 5", + "Whatever stopped the probe is the only lead whoever reads the " + + "log will get."); + } + + [TestMethod] + public void AProvenAbsenceLetsTheStopProceedAndTheReasonRecordsBothProofs() + { + ViiperBackendStopDecision decision = Decide(pnp: () => + ViiperPnpAbsenceProof.Absent( + "the usbip-win2 host controller is present and hosts no attached device")); + + Assert.IsTrue(decision.ShouldStop, decision.Reason); + StringAssert.Contains(decision.Reason, "no buses or devices"); + StringAssert.Contains(decision.Reason, "Windows shows no device"); + } + + /// + /// A cross-check that dies is not a cross-check that passed. The policy + /// converts the exception into the same fail-closed verdict as every + /// other unprovable state, so no caller has to remember to. + /// + [TestMethod] + public void ACrossCheckThatThrowsLeavesTheBackendRunning() + { + ViiperBackendStopDecision thrown = Decide(pnp: () => + throw new InvalidOperationException("walk exploded")); + Assert.IsFalse(thrown.ShouldStop); + StringAssert.Contains(thrown.Reason, "walk exploded"); + + ViiperBackendStopDecision empty = Decide(pnp: () => null); + Assert.IsFalse(empty.ShouldStop); + StringAssert.Contains(empty.Reason, "no result"); + } + + /// + /// Callers that do not request a cross-check keep the census-only + /// behaviour, wording included — that is what every pre-existing test in + /// this file pins down. + /// + [TestMethod] + public void NoCrossCheckMeansTheCensusVerdictStands() + { + ViiperBackendStopDecision decision = Decide(); + + Assert.IsTrue(decision.ShouldStop); + Assert.AreEqual( + "we started it and it is hosting no buses or devices", + decision.Reason); + } + // ---- Census over the API ------------------------------------------- [TestMethod] diff --git a/DS4WindowsTests/ViiperPnpAbsenceProbeTests.cs b/DS4WindowsTests/ViiperPnpAbsenceProbeTests.cs new file mode 100644 index 0000000..1a6deb8 --- /dev/null +++ b/DS4WindowsTests/ViiperPnpAbsenceProbeTests.cs @@ -0,0 +1,86 @@ +using System; +using DS4Windows; + +namespace DS4WindowsTests; + +/// +/// The PnP absence proof itself: the shape policy code consumes, and the one +/// promise the real probe makes that can be tested on any machine — it +/// answers, and it never throws. +/// +/// The interesting verdicts (a phantom under the controller, a walk +/// that fails half-way) need a machine wearing the usbip-win2 driver in a +/// broken state, which is [VM] territory; what the suite pins down instead is +/// how treats each verdict, over in +/// . +/// +[TestClass] +public class ViiperPnpAbsenceProbeTests +{ + /// + /// Runs the real SetupAPI/cfgmgr32 walk. On a machine without the driver + /// it proves absence by the controller's absence; with the driver it + /// walks the live tree. Either way the contract is the same: a non-null + /// proof whose verdict carries its evidence. + /// + [TestMethod] + public void TheRealProbeAlwaysAnswersAndNeverThrows() + { + ViiperPnpAbsenceProof proof = new CmTreePnpAbsenceProbe().Probe(); + + Assert.IsNotNull(proof); + switch (proof.Verdict) + { + case ViiperPnpAbsenceVerdict.ProvenAbsent: + case ViiperPnpAbsenceVerdict.Unproven: + Assert.IsFalse(string.IsNullOrWhiteSpace(proof.Detail), + "A verdict without its reasoning cannot be audited from " + + "the log line it ends up in."); + break; + case ViiperPnpAbsenceVerdict.DevicesPresent: + Assert.IsTrue(proof.Devices.Count > 0, + "Claiming presence without naming a device is the " + + "unfalsifiable kind of claim this type exists to prevent."); + break; + } + } + + [TestMethod] + public void APresentProofNamesItsDevices() + { + ViiperPnpAbsenceProof proof = ViiperPnpAbsenceProof.Present(new[] + { + @"USB\VID_054C&PID_0CE6\1&0&1", + @"USB\VID_054C&PID_0DF2\1&0&2 (problem 24)", + }); + + Assert.AreEqual(ViiperPnpAbsenceVerdict.DevicesPresent, proof.Verdict); + Assert.AreEqual(2, proof.Devices.Count); + StringAssert.Contains(proof.ToString(), "2 device(s)"); + StringAssert.Contains(proof.ToString(), "problem 24"); + } + + [TestMethod] + public void AnUnprovenProofNeverCarriesAnEmptyReason() + { + Assert.AreEqual("unknown error", + ViiperPnpAbsenceProof.Unproven(null).Detail); + Assert.AreEqual("unknown error", + ViiperPnpAbsenceProof.Unproven(string.Empty).Detail); + StringAssert.Contains( + ViiperPnpAbsenceProof.Unproven("CM_Get_Child returned CONFIGRET 3") + .ToString(), + "CONFIGRET 3"); + } + + [TestMethod] + public void AnAbsentProofSaysWhatProvedIt() + { + ViiperPnpAbsenceProof proof = ViiperPnpAbsenceProof.Absent( + "the usbip-win2 host controller is not present, so nothing can be attached through it"); + + Assert.AreEqual(ViiperPnpAbsenceVerdict.ProvenAbsent, proof.Verdict); + Assert.AreEqual(0, proof.Devices.Count); + StringAssert.Contains(proof.ToString(), "not present"); + } +} From 281d4e784d918731a88c07dee543584f20dd05ab Mon Sep 17 00:00:00 2001 From: potpiemuncher Date: Fri, 31 Jul 2026 22:34:27 -0500 Subject: [PATCH 2/3] Say something when a backend outlives the session that started it If a session dies hard while owning a backend it started, the backend and any attached pad survive it. The next session sees a backend it did not start and leaves it alone, which is right -- ownership is (pid, start time), held in memory only, and a crashed session must not hand a later one a licence to kill somebody else's process. But the refusal was silent, so the user was left with a stale virtual controller and no in-app explanation of why nothing cleaned it up. Lifecycle invariant (d) has no dangerous case in this architecture; this is its untidy residue, and it belongs in front of the user rather than in a comment. ViiperUnownedBackendPolicy classifies the backend on the API port: managed by this session, unowned and idle, unowned but serving this session's own pads, unowned and holding devices this session cannot account for, or unreadable. A Backend process card in the Settings VIIPER section renders the verdict with the holdings listed. The card does not pretend to know more than it does. Leftovers of a dead session and another program's live controllers are indistinguishable from here, so the headline gives both readings and the confirmation names what happens if the second one is true. The stop is offered exactly when the report says so: unowned, holdings readable, and none of them this session's live controller. An unreadable census offers nothing, because consent to stop a backend is consent to what it is holding, and that could not be shown. The gate re-runs at commit time, so a card left open while the world moved cannot stop a backend that has since started serving this session. The process is identified as the owner of the listening socket on the API port via GetExtendedTcpTable, never by executable name: name matching would find any viiper.exe, including one serving a different port, while the socket table names the process that actually answered. Stopping it is the clean unplug path for whatever is still attached -- the USB/IP peer disappears and the driver surprise-removes the devices, the same order VIIPER's own exit produces. Startup logs one line when an unowned backend is holding devices, naming the Settings path that can act on it. Deliberately still not a lifecycle change: nothing happens without a click, and the exit path's refusal to touch unowned backends is untouched. Thirty-one tests across the classification, the locator's pure half, the commit-time gate (ping, census and pid all seamed, so the real port is never touched) and the card's wording. Co-Authored-By: Claude Fable 5 --- DS4Windows/DS4Control/ControlService.cs | 18 + .../DS4Control/Viiper/ViiperSetupManager.cs | 177 ++++++- .../DS4Control/Viiper/ViiperUnownedBackend.cs | 469 ++++++++++++++++++ DS4Windows/DS4Forms/MainWindow.xaml | 56 +++ DS4Windows/DS4Forms/MainWindow.xaml.cs | 63 +++ .../DS4Forms/ViewModels/SettingsViewModel.cs | 9 + .../ViiperBackendStatusViewModel.cs | 251 ++++++++++ .../ViiperBackendStatusViewModelTests.cs | 224 +++++++++ DS4WindowsTests/ViiperUnownedBackendTests.cs | 371 ++++++++++++++ 9 files changed, 1636 insertions(+), 2 deletions(-) create mode 100644 DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs create mode 100644 DS4Windows/DS4Forms/ViewModels/ViiperBackendStatusViewModel.cs create mode 100644 DS4WindowsTests/ViiperBackendStatusViewModelTests.cs create mode 100644 DS4WindowsTests/ViiperUnownedBackendTests.cs diff --git a/DS4Windows/DS4Control/ControlService.cs b/DS4Windows/DS4Control/ControlService.cs index f6989fc..bc58ac2 100644 --- a/DS4Windows/DS4Control/ControlService.cs +++ b/DS4Windows/DS4Control/ControlService.cs @@ -1667,6 +1667,24 @@ public bool Start(bool showlog = true) StartupDiag($"Viiper status probe end ready={viiperStatus.Ready} helper={viiperStatus.ViiperInstalled} usbip={viiperStatus.UsbipInstalled} server={viiperStatus.ServerRunning}"); LogDebug(viiperStatus.StartupLogLine); + // Lifecycle invariant (d): a session that dies hard leaves its + // backend and pads running, and the next session - this one - + // correctly refuses to touch them. That refusal must not be + // silent, or the user is left with a stale virtual controller + // and no lead. One warning line, pointing at the card that can + // act on it. + ViiperUnownedBackendReport unownedBackend = + ViiperSetupManager.AssessUnownedBackend(viiperStatus.ServerRunning); + if (unownedBackend.State == ViiperUnownedBackendState.UnownedInUse) + { + LogDebug("A VIIPER backend " + ProductInfo.ProductName + + " does not manage is running and holding " + + unownedBackend.DescribeHoldings() + + ". If these are leftovers of a session that did not exit cleanly, " + + "Settings > VIIPER Virtual Controller Support > Backend process can stop it.", + true); + } + DS4Devices.isExclusiveMode = getUseExclusiveMode(); //Re-enable Exclusive Mode StartupDiag($"UpdateHidHiddenAttributes begin exclusive={DS4Devices.isExclusiveMode}"); diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs index 65382d6..d851f9a 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs @@ -598,8 +598,10 @@ private static void RecordOwnership(Process process) /// /// Receives one line describing what was decided and why. /// Test seam; defaults to the live API. + /// Test seam; defaults to the real PnP tree walk. public static ViiperBackendStopMethod StopOwnedBackendOnExit( - Action log = null, IViiperBackendCensusSource censusSource = null) + Action log = null, IViiperBackendCensusSource censusSource = null, + IViiperPnpAbsenceProbe pnpProbe = null) { ViiperOwnedBackend owned = OwnedBackend; Process process = owned?.TryResolve(); @@ -613,9 +615,16 @@ public static ViiperBackendStopMethod StopOwnedBackendOnExit( .TakeCensus(); } + // Handed to the policy as a deferred call so the SetupAPI walk + // only runs when the census has already proven the backend + // idle - it is the cross-check on the final verdict, not a + // routine exit cost. + Func pnpCrossCheck = () => + (pnpProbe ?? new CmTreePnpAbsenceProbe()).Probe(); + ViiperBackendStopDecision decision = ViiperBackendStopPolicy.Decide( Global.StopViiperBackendOnExit, owned, alive, census, - ViiperOwnedDeviceRegistry.Snapshot()); + ViiperOwnedDeviceRegistry.Snapshot(), pnpCrossCheck); if (!decision.ShouldStop) { @@ -652,6 +661,170 @@ public static ViiperBackendStopMethod StopOwnedBackendOnExit( } } + /// + /// Classifies the backend on the API port for the Settings card and + /// the startup log: is it ours, somebody's, or a leftover — and what + /// is it holding. Read-only. + /// + /// + /// Pass the ping result if one was just taken (the Settings refresh + /// has it in hand); null probes again. + /// + /// Test seam; defaults to the live API. + public static ViiperUnownedBackendReport AssessUnownedBackend( + bool? serverResponding = null, + IViiperBackendCensusSource censusSource = null) + { + bool responding; + try + { + responding = serverResponding ?? CanPingServer(); + } + catch + { + responding = false; + } + + ViiperOwnedBackend owned = OwnedBackend; + bool alive = false; + if (owned != null) + { + Process resolved = owned.TryResolve(); + alive = resolved != null; + try { resolved?.Dispose(); } catch { } + } + + ViiperBackendCensus census = null; + if (responding && !(owned != null && alive)) + { + census = (censusSource ?? new ViiperApiBackendCensusSource()) + .TakeCensus(); + } + + return ViiperUnownedBackendPolicy.Assess(responding, owned, alive, + census, ViiperOwnedDeviceRegistry.Snapshot()); + } + + /// + /// The user-initiated stop of a backend this session does not own — + /// the (d) affordance, and deliberately not a lifecycle change: it + /// runs only from an explicit click, after the card has shown what + /// the backend is holding. + /// + /// The gate re-runs at commit time. Whatever the card said when + /// the button was clicked, the state that counts is the one read + /// here, so a backend that has started serving this session's own + /// pads — or whose census stopped answering — refuses rather than + /// proceeds. Stopping the process is the clean unplug path for + /// anything still attached to it: the USB/IP peer disappears and the + /// driver surprise-removes the devices, the same order VIIPER's own + /// exit produces. + /// + /// Receives one line describing what happened. + /// Test seam; defaults to the live API. + /// Test seam; defaults to the socket table. + /// Test seam; null re-pings at commit time. + public static ViiperUnownedBackendStopOutcome StopUnownedBackend( + Action log = null, + IViiperBackendCensusSource censusSource = null, + Func listenerPidSource = null, + bool? serverResponding = null) + { + ViiperUnownedBackendReport report = + AssessUnownedBackend(serverResponding, censusSource); + if (!report.OffersStop) + { + ViiperUnownedBackendStopOutcome refused = + ViiperUnownedBackendStopOutcome.Refused( + DescribeStopRefusal(report)); + log?.Invoke("VIIPER unowned backend not stopped: " + + refused.Reason + "."); + return refused; + } + + int? processId; + try + { + processId = (listenerPidSource ?? + ViiperBackendProcessLocator.FindApiListenerProcessId)(); + } + catch + { + processId = null; + } + + if (processId == null) + { + ViiperUnownedBackendStopOutcome refused = + ViiperUnownedBackendStopOutcome.Refused( + "could not identify the process listening on port " + + ApiPort.ToString(CultureInfo.InvariantCulture)); + log?.Invoke("VIIPER unowned backend not stopped: " + + refused.Reason + "."); + return refused; + } + + Process process = null; + try + { + string identity; + try + { + process = Process.GetProcessById(processId.Value); + identity = string.Format(CultureInfo.InvariantCulture, + "{0} (pid {1})", process.ProcessName, process.Id); + } + catch (Exception ex) + { + ViiperUnownedBackendStopOutcome refused = + ViiperUnownedBackendStopOutcome.Refused( + "the listening process (pid " + processId.Value + + ") could not be opened: " + ex.Message); + log?.Invoke("VIIPER unowned backend not stopped: " + + refused.Reason + "."); + return refused; + } + + ViiperBackendStopResult result = ViiperBackendStopper.Stop( + process, BackendStopGracePeriod); + ViiperUnownedBackendStopOutcome outcome = + ViiperUnownedBackendStopOutcome.From(result, identity); + log?.Invoke(string.Format(CultureInfo.InvariantCulture, + "VIIPER unowned backend stop ({0}; was holding {1}): {2}.", + identity, report.DescribeHoldings(), result.Detail)); + return outcome; + } + finally + { + try { process?.Dispose(); } catch { } + } + } + + private static string DescribeStopRefusal( + ViiperUnownedBackendReport report) + { + switch (report.State) + { + case ViiperUnownedBackendState.NoBackend: + return "no backend is running"; + case ViiperUnownedBackendState.ManagedByThisApp: + return "the running backend is managed by this session; " + + "it stops with the app when the exit setting allows"; + case ViiperUnownedBackendState.UnownedServingThisApp: + return "the backend is serving this session's own " + + "controller(s); disconnect them first"; + case ViiperUnownedBackendState.UnownedInUse + when report.ServesThisApp: + return "the backend is serving this session's own " + + "controller(s) alongside others; disconnect them first"; + case ViiperUnownedBackendState.UnownedUnreadable: + return "what the backend is holding could not be read (" + + report.Detail + ")"; + default: + return "the backend's state changed while the request was in flight"; + } + } + private static bool CanPingServer() { string response = ViiperApiProbe.Request("ping", timeoutMilliseconds: 1000); diff --git a/DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs b/DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs new file mode 100644 index 0000000..1007c25 --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs @@ -0,0 +1,469 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; + +namespace DS4Windows +{ + /// + /// What the running VIIPER backend is, from this process's point of view. + /// + /// This classification exists for lifecycle invariant (d)'s + /// follow-up. If this application dies hard while owning a backend it + /// started, the backend and any attached virtual pad survive; the next + /// session sees a backend it did not start and — correctly — refuses to + /// touch it on exit. That refusal is the safe half of the design. The + /// missing half was telling the user, who is otherwise left with a stale + /// virtual controller and no in-app explanation. These states drive that + /// diagnostics card. + /// + public enum ViiperUnownedBackendState + { + /// Nothing answered on the API port. + NoBackend, + + /// + /// The backend that is running is the one this session started; the + /// exit path manages it and the card has nothing to warn about. + /// + ManagedByThisApp, + + /// + /// A backend this session did not start is running and hosting + /// nothing: no devices, no buses. + /// + UnownedIdle, + + /// + /// A backend this session did not start is running, and everything it + /// hosts is a live device of this session — the normal shape when the + /// user runs VIIPER themselves and this application attaches to it. + /// + UnownedServingThisApp, + + /// + /// A backend this session did not start is hosting devices (or + /// registered buses) this session cannot account for. Leftovers of a + /// session that died hard look exactly like another consumer's live + /// devices from here; only the user knows which it is, which is why + /// this state gets a description and a button rather than an + /// automatic action. + /// + UnownedInUse, + + /// + /// A backend this session did not start is running, and the census + /// that would say what it hosts failed. Nothing is offered on this + /// state: consent to stop a backend means consent to what it is + /// holding, and that could not be read. + /// + UnownedUnreadable, + } + + /// + /// The evidence behind a , in the + /// units the card renders: which registered devices are this session's, + /// which are not, and which buses hold no device at all. + /// + public sealed class ViiperUnownedBackendReport + { + public ViiperUnownedBackendReport(ViiperUnownedBackendState state, + IReadOnlyList foreignDevices, + IReadOnlyList ourDevices, + IReadOnlyList emptyBuses, + string detail) + { + State = state; + ForeignDevices = foreignDevices ?? Array.Empty(); + OurDevices = ourDevices ?? Array.Empty(); + EmptyBuses = emptyBuses ?? Array.Empty(); + Detail = detail ?? string.Empty; + } + + public ViiperUnownedBackendState State { get; } + + /// Registered devices this session cannot account for. + public IReadOnlyList ForeignDevices { get; } + + /// Registered devices that are this session's live pads. + public IReadOnlyList OurDevices { get; } + + /// Registered buses hosting no device at all. + public IReadOnlyList EmptyBuses { get; } + + /// + /// Supporting text: the census failure for + /// , empty + /// otherwise. + /// + public string Detail { get; } + + /// + /// True when stopping the backend would take one of this session's + /// own live controllers down with it. + /// + public bool ServesThisApp => OurDevices.Count > 0; + + /// + /// Whether the card may offer its stop button. Policy, not + /// presentation: a stop is offered only when the user can be shown + /// exactly what they would be stopping (idle, or in use with the + /// holdings listed) and none of it is this session's own live + /// controller. An unreadable census offers nothing — uninformed + /// consent is not consent. + /// + public bool OffersStop => + State == ViiperUnownedBackendState.UnownedIdle || + (State == ViiperUnownedBackendState.UnownedInUse && !ServesThisApp); + + /// One line for the log, matching what the card shows. + public string DescribeHoldings() + { + List parts = new List(); + if (ForeignDevices.Count > 0) + { + parts.Add(string.Format(CultureInfo.InvariantCulture, + "{0} device(s) not created by this session: {1}", + ForeignDevices.Count, + string.Join("; ", ForeignDevices))); + } + + if (OurDevices.Count > 0) + { + parts.Add(string.Format(CultureInfo.InvariantCulture, + "{0} of this session's device(s): {1}", + OurDevices.Count, string.Join("; ", OurDevices))); + } + + if (EmptyBuses.Count > 0) + { + parts.Add(string.Format(CultureInfo.InvariantCulture, + "{0} empty bus(es): {1}", EmptyBuses.Count, + string.Join(", ", EmptyBuses.Select(bus => + bus.ToString(CultureInfo.InvariantCulture))))); + } + + return parts.Count == 0 ? "nothing registered" + : string.Join("; ", parts); + } + } + + /// + /// Classifies the running backend. Pure: every input is handed in, so + /// every state is reachable from a test. + /// + public static class ViiperUnownedBackendPolicy + { + /// Whether the API ping answered. + /// This session's ownership record, if any. + /// + /// Whether that record still resolves to a live process. A record + /// whose process is gone confers nothing: whatever is answering the + /// port now is somebody else. + /// + /// + /// What the backend says it hosts. Only consulted for a responding, + /// unowned backend; pass null otherwise. + /// + /// + /// The devices this session currently holds, from + /// . + /// + public static ViiperUnownedBackendReport Assess( + bool serverResponding, + ViiperOwnedBackend ownedBackend, + bool ownedBackendAlive, + ViiperBackendCensus census, + IReadOnlyCollection ourLiveDevices) + { + if (!serverResponding) + { + return new ViiperUnownedBackendReport( + ViiperUnownedBackendState.NoBackend, null, null, null, null); + } + + if (ownedBackend != null && ownedBackendAlive) + { + return new ViiperUnownedBackendReport( + ViiperUnownedBackendState.ManagedByThisApp, + null, null, null, ownedBackend.ToString()); + } + + if (census == null || !census.Succeeded) + { + return new ViiperUnownedBackendReport( + ViiperUnownedBackendState.UnownedUnreadable, + null, null, null, + census?.FailureReason ?? "no census taken"); + } + + HashSet ours = ourLiveDevices == null + ? new HashSet() + : new HashSet(ourLiveDevices); + + List foreign = census.Devices + .Where(device => !ours.Contains(device)).ToList(); + List oursPresent = census.Devices + .Where(device => ours.Contains(device)).ToList(); + + // A bus that hosts devices is described by those devices; the + // extra signal worth naming is a bus with nothing on it, which is + // registered state all the same. + HashSet busesWithDevices = new HashSet( + census.Devices.Select(device => device.BusId)); + List emptyBuses = census.Buses + .Where(bus => !busesWithDevices.Contains(bus)).ToList(); + + if (foreign.Count > 0 || emptyBuses.Count > 0) + { + return new ViiperUnownedBackendReport( + ViiperUnownedBackendState.UnownedInUse, + foreign, oursPresent, emptyBuses, null); + } + + if (oursPresent.Count > 0) + { + return new ViiperUnownedBackendReport( + ViiperUnownedBackendState.UnownedServingThisApp, + null, oursPresent, null, null); + } + + return new ViiperUnownedBackendReport( + ViiperUnownedBackendState.UnownedIdle, null, null, null, null); + } + } + + /// + /// What came of a user-initiated stop of an unowned backend: either it + /// was refused before anything was touched, with the reason, or the + /// stopper ran and this carries its result. + /// + public sealed class ViiperUnownedBackendStopOutcome + { + private ViiperUnownedBackendStopOutcome(bool attempted, + ViiperBackendStopMethod method, string reason, + string processIdentity) + { + Attempted = attempted; + Method = method; + Reason = reason ?? string.Empty; + ProcessIdentity = processIdentity ?? string.Empty; + } + + /// False when the gate refused before touching anything. + public bool Attempted { get; } + + public ViiperBackendStopMethod Method { get; } + + /// The refusal reason, or the stopper's detail line. + public string Reason { get; } + + /// "name (pid N)" of the process that was stopped, when one was. + public string ProcessIdentity { get; } + + public bool Succeeded => Attempted && + (Method == ViiperBackendStopMethod.Graceful || + Method == ViiperBackendStopMethod.Killed); + + public static ViiperUnownedBackendStopOutcome Refused(string reason) => + new ViiperUnownedBackendStopOutcome(false, + ViiperBackendStopMethod.None, reason, null); + + public static ViiperUnownedBackendStopOutcome From( + ViiperBackendStopResult result, string processIdentity) => + new ViiperUnownedBackendStopOutcome(true, + result?.Method ?? ViiperBackendStopMethod.None, + result?.Detail, processIdentity); + } + + /// + /// One row of the IPv4 listener table, reduced to what the locator needs. + /// + public readonly struct ViiperTcpListenerRow + { + public ViiperTcpListenerRow(uint localAddressNetworkOrder, + int localPort, uint state, int owningProcessId) + { + LocalAddressNetworkOrder = localAddressNetworkOrder; + LocalPort = localPort; + State = state; + OwningProcessId = owningProcessId; + } + + /// The dwLocalAddr DWORD exactly as the table carries it. + public uint LocalAddressNetworkOrder { get; } + + /// Host-order port. + public int LocalPort { get; } + + /// MIB_TCP_STATE; 2 is LISTEN. + public uint State { get; } + + public int OwningProcessId { get; } + } + + /// + /// Finds the process behind the VIIPER API port. + /// + /// A backend this session did not start left no process handle + /// behind, so stopping it needs an identity, and the only honest one is + /// "the process that owns the listening socket the API answered on". Name + /// matching would find any viiper.exe, including one serving a different + /// port; the socket table names the one that is actually this + /// backend. + /// + /// Split OS-side / pure-side like the rest of this area: the table + /// read is a P/Invoke, the row selection is a function of rows. + /// + public static class ViiperBackendProcessLocator + { + private const uint MibTcpStateListen = 2; + private const uint LoopbackNetworkOrder = 0x0100007F; // 127.0.0.1 + private const uint AnyAddress = 0; // 0.0.0.0 + private const int AfInet = 2; + private const int TcpTableOwnerPidListener = 3; + private const int ErrorInsufficientBuffer = 122; + private const int NoError = 0; + + /// + /// The process id listening on the API port, or null when it cannot + /// be established. Null is an answer: the caller reports "could not + /// identify the process" instead of guessing. + /// + public static int? FindApiListenerProcessId() + { + try + { + return FindListenerProcessId(ViiperSetupManager.ApiPort, + ReadIpv4Listeners()); + } + catch + { + return null; + } + } + + /// + /// Selects the listener for . Loopback binding + /// is preferred, then the wildcard address, then anything else + /// claiming the port — the API host is 127.0.0.1, so the closer the + /// binding is to that, the stronger the identification. + /// + public static int? FindListenerProcessId(int port, + IEnumerable rows) + { + if (rows == null) + { + return null; + } + + List candidates = rows + .Where(row => row.State == MibTcpStateListen && + row.LocalPort == port) + .ToList(); + if (candidates.Count == 0) + { + return null; + } + + foreach (uint preferred in new[] { LoopbackNetworkOrder, AnyAddress }) + { + foreach (ViiperTcpListenerRow row in candidates) + { + if (row.LocalAddressNetworkOrder == preferred) + { + return row.OwningProcessId; + } + } + } + + return candidates[0].OwningProcessId; + } + + private static List ReadIpv4Listeners() + { + List rows = new List(); + + int size = 0; + int result = GetExtendedTcpTable(IntPtr.Zero, ref size, false, + AfInet, TcpTableOwnerPidListener, 0); + if (result != ErrorInsufficientBuffer || size <= 0) + { + return rows; + } + + IntPtr table = Marshal.AllocHGlobal(size); + try + { + result = GetExtendedTcpTable(table, ref size, false, AfInet, + TcpTableOwnerPidListener, 0); + if (result != NoError) + { + return rows; + } + + int count = Marshal.ReadInt32(table); + IntPtr rowPtr = IntPtr.Add(table, 4); + int rowSize = Marshal.SizeOf(); + for (int i = 0; i < count; i++) + { + MibTcpRowOwnerPid row = + Marshal.PtrToStructure(rowPtr); + rows.Add(new ViiperTcpListenerRow(row.LocalAddr, + DecodePort(row.LocalPort), row.State, + unchecked((int)row.OwningPid))); + rowPtr = IntPtr.Add(rowPtr, rowSize); + } + } + finally + { + Marshal.FreeHGlobal(table); + } + + return rows; + } + + /// + /// dwLocalPort carries the port in network byte order in its low two + /// bytes; the swap is spelled out rather than routed through socket + /// helpers so the units are visible here. + /// + public static int DecodePort(uint dwLocalPort) => + (int)(((dwLocalPort & 0xFF) << 8) | ((dwLocalPort >> 8) & 0xFF)); + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcpRowOwnerPid + { + public uint State; + public uint LocalAddr; + public uint LocalPort; + public uint RemoteAddr; + public uint RemotePort; + public uint OwningPid; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern int GetExtendedTcpTable(IntPtr pTcpTable, + ref int pdwSize, [MarshalAs(UnmanagedType.Bool)] bool bOrder, + int ulAf, int tableClass, uint reserved); + } +} diff --git a/DS4Windows/DS4Forms/MainWindow.xaml b/DS4Windows/DS4Forms/MainWindow.xaml index 2f7aea7..5b836b1 100644 --- a/DS4Windows/DS4Forms/MainWindow.xaml +++ b/DS4Windows/DS4Forms/MainWindow.xaml @@ -729,6 +729,62 @@ + + + + + + + + + + + + + + + + + + +