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/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/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 @@ + + + + + + + + + + + + + + + + + + +