From e663a04c288e47a9f398ff67697a588bc1af3aa1 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:31:30 -0400 Subject: [PATCH 01/13] Bump version to 1.2.0 across manifest, resources, main, and release workflow --- .github/workflows/release.yml | 1 + src/app.manifest | 2 +- src/main.cpp | 2 +- src/resource.rc | 8 ++++---- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bbd811..48a799b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + - "[0-9]+.[0-9]+*" jobs: release-windows: diff --git a/src/app.manifest b/src/app.manifest index f0ba885..b86cf2b 100644 --- a/src/app.manifest +++ b/src/app.manifest @@ -1,7 +1,7 @@ Date: Wed, 30 Sep 2026 13:40:58 -0400 Subject: [PATCH 02/13] Overhaul README documentation and update header comments in reset.h --- README.md | 167 +++++++++++++++++++++++------------------------ src/util/reset.h | 5 +- 2 files changed, 86 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 434bbbf..035e21d 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,141 @@

- + GPU-Switcher Logo

GPU‑Switcher

-

A lightweight GPU switching utility for Windows

+

+ A lightweight, zero-overhead GPU switching utility for Windows +

+

+ Latest Release + Windows 10 / 11 64-bit + DirectX 11 + MIT License +

  +**GPU-Switcher** is a tiny, battery-friendly Windows system tray utility that keeps your laptop's discrete GPU (dGPU) activated with **zero CPU usage** and no background polling threads. -A tiny Windows tray utility that activates the discrete GPU (dGPU), with zero CPU usage and no background threads. +Designed primarily for hybrid laptops equipped with **NVIDIA Advanced Optimus** (or MUX switch systems) where you want the dGPU ready for low-latency gaming, external displays, or 3D workloads without needing to keep a heavy game or benchmark running in the background. -Designed for hybrid GPU laptops with **NVIDIA Advanced Optimus** where you want the dGPU ready without running a heavy application and without configuring every app manually through the driver control panel. +--- + +## Features -Please let me know if this also works for non-Nvidia display adapters. +- ⚡ **Zero Overhead**: Creates a persistent Direct3D 11 device on the dGPU and sleeps in the Windows message pump. No background loops, no telemetry, and 0% CPU consumption. +- 🎨 **Dynamic Vendor Tray Icons**: Instantly see which GPU is active with color-coded tray icons: + - 🟢 **Green**: Active NVIDIA discrete GPU + - 🔴 **Red**: Active AMD discrete GPU + - 🔵 **Blue**: Active Intel discrete GPU + - ⚪ **Grey**: Other / Unknown adapter + - 🟡 **Yellow**: Display reset in progress or dGPU acquisition warning +- 🩺 **Live Health Monitoring & Auto-Recovery**: Detects GPU crashes, driver TDR recovery, or disconnected/reconnected eGPUs via `GetDeviceRemovedReason()` and automatically re-acquires the device. +- 💤 **Non-Blocking Power Management**: Flushes and releases the DirectX context cleanly before sleep (`PBT_APMSUSPEND`), and re-acquires automatically with a brief delay upon system resume without freezing the message loop. +- 🔄 **Safe Display Adapter Restart**: Safely cycles physical graphics drivers using the Windows Configuration Manager API (`CfgMgr32`), skips virtual/software adapters, and automatically re-launches the tray application when finished. +- 🖥️ **CLI Controls**: Supports `--exit` / `--quit` for graceful shutdown from scripts or terminals, and `--help` for usage information. +- 🔔 **Single-Instance Aware**: Launching a duplicate instance highlights and displays the active GPU status balloon from the existing tray process. +- 🔍 **High-DPI & Per-User Logging**: Full Per-Monitor V2 DPI awareness and rolling diagnostic logs stored safely in `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log`. --- -## Requirements +## Download & Installation -- Windows 10 or 11 (64‑bit) -- Designed and tested for NVIDIA Advanced Optimus capable laptops, but this should work for any hybrid system with a MUX switch supporting on-the-fly dGPU display switching -- D3D11‑capable discrete GPU +GPU-Switcher is a standalone portable application: +1. Download the latest `GPU-Switcher-*-windows-x64.zip` from the **[Releases](https://github.com/Tiflit/GPU-Switcher/releases)** page. +2. Extract `GPU-Switcher.exe` to a permanent location (e.g., `C:\Program Files\GPU-Switcher` or your tools directory). +3. Run `GPU-Switcher.exe`. It will appear in your notification area (system tray). --- -## One‑time setup +## One‑Time NVIDIA Optimus Setup -1. Run `GPU-Switcher.exe` -2. Open **NVIDIA Control Panel** -3. Go to **Manage 3D settings → Program Settings** +For NVIDIA Advanced Optimus laptops to automatically switch internal display routing to the dGPU on launch: -4. Add `GPU-Switcher.exe` -5. Set **Preferred graphics processor → High‑performance NVIDIA processor** -6. Enable **Automatic display switching** -7. Exit and relaunch `GPU-Switcher.exe` +1. Open **NVIDIA Control Panel**. +2. Navigate to **Manage 3D settings → Program Settings**. +3. Click **Add** and select `GPU-Switcher.exe`. +4. Set **Preferred graphics processor** to **High-performance NVIDIA processor**. +5. Ensure **Automatic display switching** is enabled in your global display mode settings. +6. Right-click the tray icon and select **Exit**, then relaunch `GPU-Switcher.exe`. -After this, GPU‑Switcher will consistently trigger a dGPU display switch on launch. Right-click on the tray icon to access extra options. +*After this one-time configuration, launching GPU-Switcher will consistently trigger the discrete GPU display switch.* --- -## Tray icon - -- **Dynamic status icon**: - - 🟢 **Green**: Active NVIDIA discrete GPU - - 🔴 **Red**: Active AMD discrete GPU - - 🔵 **Blue**: Active Intel discrete GPU - - ⚪ **Grey**: Other / Unknown adapter - - 🟡 **Yellow**: Reset in progress or dGPU acquisition warning - -- **Left‑click**: Displays active GPU model and health status balloon. -- **Hover**: Shows current adapter name and status in the tooltip. +## Tray Controls -- **Right‑click for options**: - - **Start with Windows** — toggle a `HKCU\...\Run` registry entry - - **Restart Display Adapters** — safely cycles all physical display adapters and automatically re-launches GPU-Switcher → requires UAC - - **Exit** — cleanly releases the GPU context and removes the tray icon +- **Hover**: Displays the active GPU description in the tooltip (e.g. `GPU-Switcher: NVIDIA GeForce RTX 4060 Laptop GPU`). +- **Left‑click**: Displays a status balloon with the active GPU name and health state. +- **Right‑click Context Menu**: + - **Start with Windows**: Toggles a startup entry in `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. + - **Restart Display Adapters**: Safely cycles physical display adapters via elevated helper to fix rendering stutters, then automatically restarts the utility (requires UAC confirmation). + - **Exit**: Cleanly releases the DirectX context and terminates the program. --- -## Command‑line options +## Command‑Line Options | Option | Description | |---|---| -| *(none)* | Launches the tray utility. If an instance is already running, highlights the active instance in the tray. | +| *(none)* | Launches the tray utility. If already running, activates and notifies the existing instance. | | `--exit` / `--quit` | Gracefully closes the running GPU-Switcher instance. | -| `--help` / `-h` | Shows usage instructions and available flags. | +| `--help` / `-h` / `/?` | Displays the help dialog with available options. | +| `--reset-gpu` | Helper flag used internally to perform the elevated display adapter reset. | --- -## How it works +## How It Works -On startup, GPU‑Switcher: - -1. **Exports GPU driver hints** (`NvOptimusEnablement`, `AmdPowerXpressRequestHighPerformance`) -2. **Creates a persistent D3D11 device** on the adapter with the most dedicated VRAM -3. **Sits in the system tray** with zero CPU usage. - -Exiting releases the D3D device and the driver returns to normal routing. +1. **Driver Enablement Hints**: Exports documented vendor activation symbols: + - `NvOptimusEnablement = 1` (NVIDIA Optimus rendering hint) + - `AmdPowerXpressRequestHighPerformance = 1` (AMD PowerXpress hint) +2. **DXGI Adapter Scoring**: Enumerates all DXGI graphics adapters, filters out software renderers (`DXGI_ADAPTER_FLAG_SOFTWARE`), and scores adapters based on discrete vendor priority (NVIDIA > AMD > Intel) and dedicated video memory (VRAM). +3. **Direct3D 11 Pinning**: Creates a persistent `ID3D11Device` on the winning adapter with `D3D11_CREATE_DEVICE_BGRA_SUPPORT`. This signals the graphics driver that a high-performance 3D process is active, preventing the GPU from entering deep sleep and keeping Advanced Optimus routed to the discrete GPU. +4. **Event-Driven Architecture**: Runs purely within the standard Win32 message pump, reacting to system power events, taskbar recreation, and device lost notifications with zero resident CPU usage. --- ## Compatibility -| System | Behaviour | -|--------|-----------| -| NVIDIA Advanced Optimus | Everything should work | -| Standard Optimus (no MUX) | dGPU will activate but display switch is not available | -| AMD hybrid | Driver hint is sent; behaviour may vary by OEM | - ---- - -## Building - -Requires CMake 3.20+ and Visual Studio / MSVC with Windows SDK. - -``` -cmake -B build -cmake --build build --config Release -``` +| System Type | Behavior | +|---|---| +| **NVIDIA Advanced Optimus** | Full dynamic display switching and dGPU activation. | +| **Standard Optimus (Muxless)** | Activates dGPU; internal display routing remains on iGPU (by hardware design). | +| **AMD Hybrid / SmartAccess** | Driver hints exported; display switching depends on OEM MUX architecture. | --- -## Known behaviour and limitations - -**Display switching requires a one‑time NVCP profile** -GPU‑Switcher does not switch the display on its own. It registers with the NVIDIA driver, which then triggers switching based on your NVCP profile. Without the profile configured, the app has no effect on display routing. +## Building from Source -**The display switch happens at process load, not at runtime** -The driver hints (`NvOptimusEnablement`) are read once when the process starts. There is no way to trigger or cancel the switch after launch without restarting the app. +Requires CMake 3.20+ and Visual Studio 2019/2022 (MSVC) with the Windows 10/11 SDK. -**Standard Optimus (no MUX switch) cannot switch the display** -On most non-Advanced-Optimus laptops, the iGPU is hardwired to the display panel. GPU‑Switcher will still register the dGPU with the driver and make it visible in NVCP, but no display switch will occur regardless of settings. +```powershell +# Clone the repository +git clone https://github.com/Tiflit/GPU-Switcher.git +cd GPU-Switcher -**The tray icon reflects the acquired dGPU adapter and vendor** -The tray icon reflects whether a discrete GPU is successfully acquired and running (with vendor color coding) and warns if the device is lost or removed. Note that detecting internal MUX display panel routing via DXGI is not supported by Optimus architecture, so the icon reflects the acquired DirectX device state rather than display panel muxing. +# Configure with CMake +cmake -B build -G "Visual Studio 17 2022" -A x64 -**Rendering stutters after a display switch** -On some systems, switching display output between GPUs leaves the driver in a partially initialized state and causes rendering stutters. Use **Restart Display Adapters** from the tray menu to resolve this without rebooting. - -**"Restart Display Adapters" automatically re-launches the app** -Re-triggering display switching and refreshing adapter state requires cycling the physical graphics devices and reloading. GPU-Switcher safely performs this cycle in an elevated helper, filters out virtual adapters, waits for driver initialization, and automatically re-launches the user tray instance when completed. +# Build Release binary +cmake --build build --config Release +``` -**Sleep and wake** -The app handles sleep/wake cycles by releasing and re‑acquiring the D3D device on resume. This re‑registers the process with the driver. Whether the display switch re‑triggers on resume depends on your system's driver behaviour and is not guaranteed. +The compiled binary will be located at `build/Release/GPU-Switcher.exe` (or `build/GPU-Switcher.exe` if using Ninja). -**AMD hybrid systems** -The `AmdPowerXpressRequestHighPerformance` hint is exported and will be read by AMD drivers, but display switching behaviour on AMD hybrid systems has not been tested and may vary by OEM and driver versions. +--- -**UAC prompt required** -The app runs almost entirely in user space. The only exception is the Restart Display Adapters feature, which performs a full graphics driver reload. Restarting display adapters requires elevated privileges, so Windows will show a UAC prompt when this option is selected. +## Troubleshooting & Diagnostics -**Logging** -A rolling log file (`%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log`, capped at 16 KB) records adapter detection, reset operations, and any errors. +- **Diagnostic Logs**: A rolling log file is written to `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` (capped at 16 KB with automatic rotation). Check this file if a device fails to acquire or to inspect detected adapter scores. +- **Display Stuttering**: If your laptop panel stutters after switching display modes, use **Restart Display Adapters** from the tray menu to reload the graphics driver stack without rebooting Windows. +- **Display Not Switching**: Ensure you have configured the program profile in NVIDIA Control Panel under Program Settings as described in the [One-Time Setup](#one-time-nvidia-optimus-setup). --- ## License -MIT — do whatever you want, just link back to the original. +This project is licensed under the [MIT License](LICENSE) — Copyright © 2026 Tiflit. diff --git a/src/util/reset.h b/src/util/reset.h index cc7b129..7a54ab7 100644 --- a/src/util/reset.h +++ b/src/util/reset.h @@ -1,6 +1,7 @@ #pragma once -// Performs a full disable/enable cycle on all display adapters. +// Performs a full disable/enable cycle on physical PCI display adapters, +// skipping virtual and software display devices. // Must be called from an elevated process. -// Blocks until all adapters are re-enabled. +// Blocks until all target adapters are re-enabled. void CycleAllDisplayAdapters(); From 69deb9f95ce9f4eb1c136d305b85e1f8e65fa16f Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:43:42 -0400 Subject: [PATCH 03/13] Refactor GPU identity via LUID, add state machine, verify display reset, and fix release tag pattern - Distinguish discrete GPU candidates from integrated graphics; remember preferred adapter by Windows Locally Unique Identifier (LUID) to prevent trapping onto integrated GPUs on disconnect. - Introduce explicit AppState machine (NoDiscreteGpu, Acquiring, Active, DeviceLost, WaitingForDiscreteGpu, Resetting). - Add WM_DEVICECHANGE (DBT_DEVNODES_CHANGED) handling for instantaneous eGPU/dGPU plug-and-play re-acquisition. - Overhaul CycleAllDisplayAdapters with ResetResult enum and CM_Get_DevNode_Status driver startup verification. - Propagate reset exit codes to parent process to prevent relaunch loops on failure and warn user via balloon. - Fix GitHub Actions release tag trigger to standard glob 'v*'. - Update README for Windows 10/11 Graphics Settings alongside NVIDIA Control Panel. --- .github/workflows/release.yml | 1 - README.md | 25 +- src/main.cpp | 450 ++++++++++++++++++++++++++-------- src/util/reset.cpp | 98 +++++++- src/util/reset.h | 18 +- 5 files changed, 468 insertions(+), 124 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48a799b..4bbd811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: tags: - "v*" - - "[0-9]+.[0-9]+*" jobs: release-windows: diff --git a/README.md b/README.md index 035e21d..b2530fd 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ Designed primarily for hybrid laptops equipped with **NVIDIA Advanced Optimus** - 🔵 **Blue**: Active Intel discrete GPU - ⚪ **Grey**: Other / Unknown adapter - 🟡 **Yellow**: Display reset in progress or dGPU acquisition warning -- 🩺 **Live Health Monitoring & Auto-Recovery**: Detects GPU crashes, driver TDR recovery, or disconnected/reconnected eGPUs via `GetDeviceRemovedReason()` and automatically re-acquires the device. +- 🩺 **Live Health Monitoring & eGPU Auto-Recovery**: Tracks discrete adapters by **Locally Unique Identifier (LUID)** and listens for hardware notifications (`WM_DEVICECHANGE` / `DBT_DEVNODES_CHANGED`). If an eGPU is disconnected or driver reset occurs (`GetDeviceRemovedReason()`), the app enters an explicit waiting state without trapping the integrated GPU, and automatically re-acquires the dGPU the instant it reconnects. - 💤 **Non-Blocking Power Management**: Flushes and releases the DirectX context cleanly before sleep (`PBT_APMSUSPEND`), and re-acquires automatically with a brief delay upon system resume without freezing the message loop. -- 🔄 **Safe Display Adapter Restart**: Safely cycles physical graphics drivers using the Windows Configuration Manager API (`CfgMgr32`), skips virtual/software adapters, and automatically re-launches the tray application when finished. +- 🔄 **Verified Display Adapter Restart**: Safely cycles physical graphics drivers using the Windows Configuration Manager API (`CfgMgr32`), skips virtual/software devices, deterministically verifies driver initialization via `CM_Get_DevNode_Status` (`DN_STARTED`), and propagates granular error codes. - 🖥️ **CLI Controls**: Supports `--exit` / `--quit` for graceful shutdown from scripts or terminals, and `--help` for usage information. - 🔔 **Single-Instance Aware**: Launching a duplicate instance highlights and displays the active GPU status balloon from the existing tray process. - 🔍 **High-DPI & Per-User Logging**: Full Per-Monitor V2 DPI awareness and rolling diagnostic logs stored safely in `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log`. @@ -50,10 +50,18 @@ GPU-Switcher is a standalone portable application: --- -## One‑Time NVIDIA Optimus Setup +## One‑Time GPU Preference Setup -For NVIDIA Advanced Optimus laptops to automatically switch internal display routing to the dGPU on launch: +Starting with Windows 10 (version 20H1+) and Windows 11, the operating system's per-application **Graphics Settings** can override driver-level preferences. To ensure your discrete GPU is consistently selected: +### Step 1: Windows Graphics Settings (Recommended) +1. Open Windows **Settings → System → Display → Graphics** (or search "Graphics Settings" in the Start menu). +2. Under **Custom options for apps**, click **Browse** and select `GPU-Switcher.exe`. +3. Click **Options** on the newly added entry and select **High performance** (identifying your discrete GPU, e.g. NVIDIA / AMD / Intel Arc). +4. Click **Save**. + +### Step 2: NVIDIA Control Panel (for Advanced Optimus laptops) +For NVIDIA Advanced Optimus laptops to trigger dynamic internal display switching on launch: 1. Open **NVIDIA Control Panel**. 2. Navigate to **Manage 3D settings → Program Settings**. 3. Click **Add** and select `GPU-Switcher.exe`. @@ -61,7 +69,7 @@ For NVIDIA Advanced Optimus laptops to automatically switch internal display rou 5. Ensure **Automatic display switching** is enabled in your global display mode settings. 6. Right-click the tray icon and select **Exit**, then relaunch `GPU-Switcher.exe`. -*After this one-time configuration, launching GPU-Switcher will consistently trigger the discrete GPU display switch.* +*After this one-time configuration, launching GPU-Switcher will consistently trigger discrete GPU pinning and Advanced Optimus display switching.* --- @@ -92,9 +100,10 @@ For NVIDIA Advanced Optimus laptops to automatically switch internal display rou 1. **Driver Enablement Hints**: Exports documented vendor activation symbols: - `NvOptimusEnablement = 1` (NVIDIA Optimus rendering hint) - `AmdPowerXpressRequestHighPerformance = 1` (AMD PowerXpress hint) -2. **DXGI Adapter Scoring**: Enumerates all DXGI graphics adapters, filters out software renderers (`DXGI_ADAPTER_FLAG_SOFTWARE`), and scores adapters based on discrete vendor priority (NVIDIA > AMD > Intel) and dedicated video memory (VRAM). -3. **Direct3D 11 Pinning**: Creates a persistent `ID3D11Device` on the winning adapter with `D3D11_CREATE_DEVICE_BGRA_SUPPORT`. This signals the graphics driver that a high-performance 3D process is active, preventing the GPU from entering deep sleep and keeping Advanced Optimus routed to the discrete GPU. -4. **Event-Driven Architecture**: Runs purely within the standard Win32 message pump, reacting to system power events, taskbar recreation, and device lost notifications with zero resident CPU usage. +2. **Adapter Classification & LUID Tracking**: Enumerates all DXGI adapters, removes software renderers (`DXGI_ADAPTER_FLAG_SOFTWARE`), and classifies discrete candidates (NVIDIA, AMD dGPU, Intel Arc) separate from integrated APUs. Remembers the preferred adapter via its Windows **Locally Unique Identifier (LUID)** so transient disconnects do not trap the system into integrated graphics. +3. **Direct3D 11 Pinning**: Creates a persistent `ID3D11Device` on the selected discrete adapter with `D3D11_CREATE_DEVICE_BGRA_SUPPORT`. This signals the graphics driver that a high-performance 3D process is active, preventing the GPU from entering deep sleep and keeping Advanced Optimus routed to the discrete GPU. +4. **State-Driven, Zero-Overhead Lifecycle**: An explicit state machine (`Active`, `WaitingForDiscreteGpu`, `DeviceLost`, `Resetting`) handles adapter loss, eGPU hot-plugging via `WM_DEVICECHANGE`, system sleep/resume (`WM_POWERBROADCAST`), and taskbar recreation with 0% resident CPU usage. +5. **Deterministic Driver Reset**: When display restart is requested, an elevated helper cycles physical PCI display adapters via CfgMgr32, poll-verifies driver initialization with `CM_Get_DevNode_Status` (`DN_STARTED`, problem code 0), and propagates verified status back to the parent. --- diff --git a/src/main.cpp b/src/main.cpp index 1659e13..12d6689 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "resource.h" #include "util/logging.h" @@ -33,6 +34,39 @@ extern "C" { using Microsoft::WRL::ComPtr; +enum class AppState +{ + NoDiscreteGpu, // No discrete GPU candidate detected + Acquiring, // Creating D3D11 device + Active, // Holding D3D11 device on discrete GPU + DeviceLost, // D3D11 device was removed or reset + WaitingForDiscreteGpu, // Preferred or discrete GPU is absent (e.g. eGPU disconnected) + Resetting // Elevated display adapter reset in progress +}; + +static const wchar_t* AppStateToString(AppState state) +{ + switch (state) + { + case AppState::NoDiscreteGpu: return L"NoDiscreteGpu"; + case AppState::Acquiring: return L"Acquiring"; + case AppState::Active: return L"Active"; + case AppState::DeviceLost: return L"DeviceLost"; + case AppState::WaitingForDiscreteGpu: return L"WaitingForDiscreteGpu"; + case AppState::Resetting: return L"Resetting"; + default: return L"Unknown"; + } +} + +struct GpuCandidate +{ + ComPtr adapter; + DXGI_ADAPTER_DESC1 desc = {}; + LUID luid = {}; + bool isDiscrete = false; + int score = 0; +}; + static HINSTANCE g_hInst = nullptr; static HANDLE g_hMutex = nullptr; static ComPtr g_device; @@ -40,10 +74,74 @@ static ComPtr g_context; static NOTIFYICONDATAW g_nid = {}; static UINT g_taskbarCreatedMsg = 0; +static AppState g_appState = AppState::NoDiscreteGpu; static UINT g_activeVendorId = 0; static std::wstring g_activeGpuName; -static bool g_dGpuActive = false; -static bool g_resetInProgress = false; +static LUID g_activeLuid = { 0, 0 }; +static LUID g_preferredLuid = { 0, 0 }; +static std::wstring g_preferredGpuName; + +static bool EvaluateAdapter(IDXGIAdapter1* pAdapter, GpuCandidate& info) +{ + if (FAILED(pAdapter->GetDesc1(&info.desc))) + return false; + + if (info.desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) + return false; + + info.adapter = pAdapter; + info.luid = info.desc.AdapterLuid; + + // Distinguish discrete vs integrated: + // - 0x10DE (NVIDIA): Dedicated discrete GPU (Optimus dGPU or eGPU) + // - 0x1002 (AMD): Discrete if dedicated VRAM > 512 MB (filters out integrated APU carveouts) + // - 0x8086 (Intel): Discrete (Arc) if dedicated VRAM > 1024 MB (integrated Iris Xe/UHD typically 0-128 MB) + // - Other: Discrete if dedicated VRAM exceeds shared system memory + if (info.desc.VendorId == 0x10DE) + { + info.isDiscrete = true; + } + else if (info.desc.VendorId == 0x1002) + { + info.isDiscrete = (info.desc.DedicatedVideoMemory > 512 * 1024 * 1024); + } + else if (info.desc.VendorId == 0x8086) + { + info.isDiscrete = (info.desc.DedicatedVideoMemory > 1024 * 1024 * 1024); + } + else + { + info.isDiscrete = (info.desc.DedicatedVideoMemory > info.desc.SharedSystemMemory); + } + + // Scoring: + // Base tier for discrete: NVIDIA: 300, AMD: 200, Intel: 100, Other: 50 + // Base tier for integrated: AMD: 20, Intel: 10, Other: 1 + if (info.isDiscrete) + { + if (info.desc.VendorId == 0x10DE) + info.score = 300; + else if (info.desc.VendorId == 0x1002) + info.score = 200; + else if (info.desc.VendorId == 0x8086) + info.score = 100; + else + info.score = 50; + } + else + { + if (info.desc.VendorId == 0x1002) + info.score = 20; + else if (info.desc.VendorId == 0x8086) + info.score = 10; + else + info.score = 1; + } + + // Add VRAM in 64MB increments to score + info.score += static_cast(info.desc.DedicatedVideoMemory / (1024 * 1024 * 64)); + return true; +} static void ReleaseDGpu() { @@ -54,7 +152,13 @@ static void ReleaseDGpu() g_context.Reset(); } g_device.Reset(); - g_dGpuActive = false; + g_activeVendorId = 0; + g_activeGpuName.clear(); + g_activeLuid = { 0, 0 }; + if (g_appState == AppState::Active) + { + g_appState = AppState::WaitingForDiscreteGpu; + } } static bool AcquireDGpu() @@ -68,57 +172,81 @@ static bool AcquireDGpu() return false; } - ComPtr bestAdapter; - DXGI_ADAPTER_DESC1 bestDesc = {}; - int bestScore = -1; + std::vector discreteCandidates; + std::vector allCandidates; ComPtr adapter; for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) { - DXGI_ADAPTER_DESC1 desc; - if (SUCCEEDED(adapter->GetDesc1(&desc))) + GpuCandidate info; + if (EvaluateAdapter(adapter.Get(), info)) { - if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) - { - adapter.Reset(); - continue; - } - - int score = 0; - // Prioritize dedicated discrete vendors targeting Optimus / PowerXpress - if (desc.VendorId == 0x10DE) // NVIDIA - score = 100; - else if (desc.VendorId == 0x1002) // AMD - score = 50; - else if (desc.VendorId == 0x8086) // Intel - score = 10; - - // Add score proportional to dedicated VRAM (64 MB increments) - score += static_cast(desc.DedicatedVideoMemory / (1024 * 1024 * 64)); - wchar_t logBuf[256]; - swprintf_s(logBuf, L"Detected GPU [%u]: %s (Vendor: 0x%04X, Dedicated VRAM: %llu MB)", - i, desc.Description, desc.VendorId, - static_cast(desc.DedicatedVideoMemory / (1024 * 1024))); + swprintf_s(logBuf, L"Detected GPU [%u]: %s (Vendor: 0x%04X, Dedicated VRAM: %llu MB, Discrete: %s, Score: %d)", + i, info.desc.Description, info.desc.VendorId, + static_cast(info.desc.DedicatedVideoMemory / (1024 * 1024)), + info.isDiscrete ? L"yes" : L"no", info.score); LogInfo(logBuf); - if (score > bestScore) + allCandidates.push_back(info); + if (info.isDiscrete) { - bestScore = score; - bestDesc = desc; - bestAdapter = adapter; + discreteCandidates.push_back(info); } } adapter.Reset(); } - if (!bestAdapter) + if (discreteCandidates.empty()) { - LogError(L"No suitable graphics adapter found"); + if (g_preferredLuid.LowPart != 0 || g_preferredLuid.HighPart != 0) + { + g_appState = AppState::WaitingForDiscreteGpu; + LogInfo(L"Preferred discrete GPU is not present — waiting for reconnection"); + } + else + { + g_appState = AppState::NoDiscreteGpu; + LogInfo(L"No discrete GPU detected on the system"); + } return false; } - LogInfo(std::wstring(L"Selected target adapter: ") + bestDesc.Description); + // Select target adapter: + // If a preferred LUID was previously tracked, look for an exact match among discrete candidates + GpuCandidate best; + bool foundPreferred = false; + if (g_preferredLuid.LowPart != 0 || g_preferredLuid.HighPart != 0) + { + for (const auto& cand : discreteCandidates) + { + if (cand.luid.LowPart == g_preferredLuid.LowPart && + cand.luid.HighPart == g_preferredLuid.HighPart) + { + best = cand; + foundPreferred = true; + LogInfo(std::wstring(L"Matched previously selected discrete GPU: ") + best.desc.Description); + break; + } + } + } + + if (!foundPreferred) + { + // Pick the highest scoring discrete candidate + int bestScore = -1; + for (const auto& cand : discreteCandidates) + { + if (cand.score > bestScore) + { + bestScore = cand.score; + best = cand; + } + } + LogInfo(std::wstring(L"Selected target discrete GPU: ") + best.desc.Description); + } + + g_appState = AppState::Acquiring; D3D_FEATURE_LEVEL level = {}; D3D_FEATURE_LEVEL featureLevels[] = { @@ -129,7 +257,7 @@ static bool AcquireDGpu() }; HRESULT hr = D3D11CreateDevice( - bestAdapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, + best.adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, featureLevels, static_cast(ARRAYSIZE(featureLevels)), D3D11_SDK_VERSION, @@ -140,17 +268,21 @@ static bool AcquireDGpu() wchar_t buf[64]; swprintf_s(buf, L"D3D11CreateDevice failed: 0x%08X", hr); LogError(buf); + g_appState = AppState::DeviceLost; return false; } if (level < D3D_FEATURE_LEVEL_11_0) LogError(L"Warning: acquired adapter feature level is below D3D_FEATURE_LEVEL_11_0"); - g_activeVendorId = bestDesc.VendorId; - g_activeGpuName = bestDesc.Description; - g_dGpuActive = true; + g_activeVendorId = best.desc.VendorId; + g_activeGpuName = best.desc.Description; + g_activeLuid = best.luid; + g_preferredLuid = best.luid; + g_preferredGpuName = best.desc.Description; + g_appState = AppState::Active; - LogInfo(std::wstring(L"Successfully initialized D3D11 device on: ") + bestDesc.Description); + LogInfo(std::wstring(L"Successfully initialized D3D11 device on: ") + best.desc.Description); return true; } @@ -192,38 +324,56 @@ static void UpdateTrayStatus(const wchar_t* customTip = nullptr) if (!g_nid.hWnd) return; - g_nid.hIcon = GetVendorIcon(g_dGpuActive && !g_resetInProgress, g_activeVendorId); + bool isTrayActive = (g_appState == AppState::Active); + g_nid.hIcon = GetVendorIcon(isTrayActive, g_activeVendorId); if (customTip) { wcsncpy_s(g_nid.szTip, customTip, _TRUNCATE); } - else if (g_resetInProgress) - { - wcsncpy_s(g_nid.szTip, L"GPU-Switcher: Restarting display adapters…", _TRUNCATE); - } - else if (g_dGpuActive) - { - swprintf_s(g_nid.szTip, L"GPU-Switcher: %s", g_activeGpuName.c_str()); - } else { - wcsncpy_s(g_nid.szTip, L"GPU-Switcher: dGPU inactive", _TRUNCATE); + switch (g_appState) + { + case AppState::Active: + swprintf_s(g_nid.szTip, L"GPU-Switcher: %s (Active)", g_activeGpuName.c_str()); + break; + case AppState::Resetting: + wcsncpy_s(g_nid.szTip, L"GPU-Switcher: Restarting display adapters…", _TRUNCATE); + break; + case AppState::WaitingForDiscreteGpu: + if (!g_preferredGpuName.empty()) + swprintf_s(g_nid.szTip, L"GPU-Switcher: Waiting for %s", g_preferredGpuName.c_str()); + else + wcsncpy_s(g_nid.szTip, L"GPU-Switcher: Waiting for discrete GPU", _TRUNCATE); + break; + case AppState::NoDiscreteGpu: + wcsncpy_s(g_nid.szTip, L"GPU-Switcher: No discrete GPU detected", _TRUNCATE); + break; + case AppState::DeviceLost: + wcsncpy_s(g_nid.szTip, L"GPU-Switcher: GPU device lost — recovering", _TRUNCATE); + break; + case AppState::Acquiring: + wcsncpy_s(g_nid.szTip, L"GPU-Switcher: Acquiring graphics adapter…", _TRUNCATE); + break; + } } g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP; Shell_NotifyIconW(NIM_MODIFY, &g_nid); } -// Thread proc: waits for the elevated child to exit, then posts WM_RESET_DONE +// Thread proc: waits for the elevated child to exit, then posts WM_RESET_DONE with child's exit code struct WaitCtx { HANDLE hProcess; HWND hwnd; }; static DWORD WINAPI WaitForResetThread(LPVOID param) { auto* ctx = reinterpret_cast(param); WaitForSingleObject(ctx->hProcess, INFINITE); + DWORD exitCode = static_cast(ResetResult::EnableFailed); + GetExitCodeProcess(ctx->hProcess, &exitCode); CloseHandle(ctx->hProcess); - PostMessageW(ctx->hwnd, WM_RESET_DONE, 0, 0); + PostMessageW(ctx->hwnd, WM_RESET_DONE, static_cast(exitCode), 0); delete ctx; return 0; } @@ -249,7 +399,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (event == WM_LBUTTONUP || event == WM_LBUTTONDBLCLK || event == NIN_SELECT || event == NIN_KEYSELECT) { - if (g_device) + if (g_appState == AppState::Active && g_device) { HRESULT hr = g_device->GetDeviceRemovedReason(); if (hr != S_OK) @@ -257,6 +407,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) wchar_t buf[128]; swprintf_s(buf, L"D3D11 device lost (0x%08X) — re-acquiring", hr); LogError(buf); + g_appState = AppState::DeviceLost; AcquireDGpu(); UpdateTrayStatus(); } @@ -264,20 +415,34 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) NOTIFYICONDATAW balloon = g_nid; balloon.uFlags |= NIF_INFO; - balloon.dwInfoFlags = (g_dGpuActive ? NIIF_INFO : NIIF_WARNING) | NIIF_NOSOUND; + balloon.dwInfoFlags = (g_appState == AppState::Active ? NIIF_INFO : NIIF_WARNING) | NIIF_NOSOUND; wcsncpy_s(balloon.szInfoTitle, L"GPU-Switcher", _TRUNCATE); - if (g_resetInProgress) - { - wcsncpy_s(balloon.szInfo, L"Display adapter restart in progress…", _TRUNCATE); - } - else if (g_dGpuActive) + + switch (g_appState) { + case AppState::Active: swprintf_s(balloon.szInfo, L"Active discrete GPU:\n%s", g_activeGpuName.c_str()); + break; + case AppState::Resetting: + wcsncpy_s(balloon.szInfo, L"Display adapter restart in progress…", _TRUNCATE); + break; + case AppState::WaitingForDiscreteGpu: + if (!g_preferredGpuName.empty()) + swprintf_s(balloon.szInfo, L"Discrete GPU (%s) is disconnected or absent.\nWaiting for reconnection.", g_preferredGpuName.c_str()); + else + wcsncpy_s(balloon.szInfo, L"Discrete GPU is absent.\nWaiting for reconnection.", _TRUNCATE); + break; + case AppState::NoDiscreteGpu: + wcsncpy_s(balloon.szInfo, L"No discrete GPU detected on this system.\nRunning on integrated graphics.", _TRUNCATE); + break; + case AppState::DeviceLost: + wcsncpy_s(balloon.szInfo, L"Discrete GPU device was lost or removed.\nAttempting recovery...", _TRUNCATE); + break; + case AppState::Acquiring: + wcsncpy_s(balloon.szInfo, L"Acquiring discrete GPU...", _TRUNCATE); + break; } - else - { - wcsncpy_s(balloon.szInfo, L"dGPU acquisition failed — check log", _TRUNCATE); - } + Shell_NotifyIconW(NIM_MODIFY, &balloon); return 0; } @@ -291,7 +456,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) AppendMenuW(menu, MF_STRING | (startup ? MF_CHECKED : 0), ID_START_WINDOWS, L"Start with Windows"); - AppendMenuW(menu, MF_STRING | (g_resetInProgress ? MF_GRAYED : 0), + AppendMenuW(menu, MF_STRING | (g_appState == AppState::Resetting ? MF_GRAYED : 0), ID_RESET_DISPLAYS, L"Restart Display Adapters"); AppendMenuW(menu, MF_STRING, @@ -315,10 +480,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) case ID_RESET_DISPLAYS: { - if (g_resetInProgress) + if (g_appState == AppState::Resetting) break; - g_resetInProgress = true; + g_appState = AppState::Resetting; UpdateTrayStatus(L"GPU-Switcher: Restarting display adapters…"); LogInfo(L"Restart Display Adapters requested"); @@ -339,7 +504,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (!ShellExecuteExW(&sei) || !sei.hProcess) { LogInfo(L"Elevated launch cancelled or failed — reacquiring dGPU"); - g_resetInProgress = false; + g_appState = AppState::DeviceLost; AcquireDGpu(); UpdateTrayStatus(); break; @@ -377,30 +542,55 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) case WM_RESET_DONE: { - LogInfo(L"Elevated child finished — re-launching GPU-Switcher and exiting"); - Shell_NotifyIconW(NIM_DELETE, &g_nid); + DWORD resetExitCode = static_cast(wParam); + ResetResult res = static_cast(resetExitCode); - if (g_hMutex) + if (res == ResetResult::Success || res == ResetResult::PartialDisable) { - CloseHandle(g_hMutex); - g_hMutex = nullptr; - } + LogInfo(std::wstring(L"Elevated reset completed (") + ResetResultToString(res) + L") — re-launching GPU-Switcher and exiting"); + Shell_NotifyIconW(NIM_DELETE, &g_nid); - wchar_t exePath[MAX_PATH] = {}; - if (GetModuleFileNameW(nullptr, exePath, MAX_PATH) > 0) - { - HINSTANCE hInstApp = ShellExecuteW(nullptr, L"open", exePath, nullptr, nullptr, SW_SHOWNORMAL); - if (reinterpret_cast(hInstApp) <= 32) + if (g_hMutex) { - LogError(L"Failed to re-launch GPU-Switcher after reset"); + CloseHandle(g_hMutex); + g_hMutex = nullptr; } - else + + wchar_t exePath[MAX_PATH] = {}; + if (GetModuleFileNameW(nullptr, exePath, MAX_PATH) > 0) { - LogInfo(L"Successfully re-launched GPU-Switcher instance"); + HINSTANCE hInstApp = ShellExecuteW(nullptr, L"open", exePath, nullptr, nullptr, SW_SHOWNORMAL); + if (reinterpret_cast(hInstApp) <= 32) + { + LogError(L"Failed to re-launch GPU-Switcher after reset"); + } + else + { + LogInfo(L"Successfully re-launched GPU-Switcher instance"); + } } + + PostQuitMessage(0); } + else + { + wchar_t errBuf[256]; + swprintf_s(errBuf, L"Display adapter reset finished with error: %s (code %u)", + ResetResultToString(res), resetExitCode); + LogError(errBuf); - PostQuitMessage(0); + g_appState = AppState::DeviceLost; + AcquireDGpu(); + UpdateTrayStatus(); + + NOTIFYICONDATAW balloon = g_nid; + balloon.uFlags |= NIF_INFO; + balloon.dwInfoFlags = NIIF_WARNING | NIIF_NOSOUND; + wcsncpy_s(balloon.szInfoTitle, L"GPU-Switcher: Reset Warning", _TRUNCATE); + swprintf_s(balloon.szInfo, L"Adapter reset completed with warning: %s (code %u). Check log for details.", + ResetResultToString(res), resetExitCode); + Shell_NotifyIconW(NIM_MODIFY, &balloon); + } return 0; } @@ -409,7 +599,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { LogInfo(L"System entering suspend — releasing dGPU"); ReleaseDGpu(); - UpdateTrayStatus(); + UpdateTrayStatus(L"GPU-Switcher: Suspended"); } else if (wParam == PBT_APMRESUMEAUTOMATIC || wParam == PBT_APMRESUMESUSPEND) { @@ -420,6 +610,38 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } return TRUE; + case WM_DEVICECHANGE: + // 0x0007 = DBT_DEVNODES_CHANGED + if (wParam == 0x0007) + { + if (g_appState != AppState::Resetting) + { + LogInfo(L"Hardware devnodes changed — verifying GPU status"); + if (g_appState == AppState::Active && g_device) + { + HRESULT hr = g_device->GetDeviceRemovedReason(); + if (hr != S_OK) + { + wchar_t buf[128]; + swprintf_s(buf, L"D3D11 device lost on devnodes change (0x%08X) — re-acquiring", hr); + LogError(buf); + g_appState = AppState::DeviceLost; + AcquireDGpu(); + UpdateTrayStatus(); + } + } + else + { + if (AcquireDGpu()) + { + LogInfo(L"Discrete GPU detected and acquired following devnodes notification"); + UpdateTrayStatus(); + } + } + } + } + return TRUE; + case WM_TIMER: if (wParam == TIMER_RESUME_REACQUIRE) { @@ -433,7 +655,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (wParam == TIMER_HEALTH_CHECK) { - if (g_device) + if (g_appState == AppState::Active && g_device) { HRESULT hr = g_device->GetDeviceRemovedReason(); if (hr != S_OK) @@ -441,14 +663,18 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) wchar_t buf[128]; swprintf_s(buf, L"D3D11 device removed or lost (0x%08X) — re-acquiring", hr); LogError(buf); + g_appState = AppState::DeviceLost; AcquireDGpu(); UpdateTrayStatus(); } } - else if (!g_resetInProgress) + else if (g_appState == AppState::WaitingForDiscreteGpu || + g_appState == AppState::NoDiscreteGpu || + g_appState == AppState::DeviceLost) { if (AcquireDGpu()) { + LogInfo(L"Discrete GPU detected and acquired during periodic scan"); UpdateTrayStatus(); } } @@ -460,20 +686,34 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayStatus(); NOTIFYICONDATAW balloon = g_nid; balloon.uFlags |= NIF_INFO; - balloon.dwInfoFlags = (g_dGpuActive ? NIIF_INFO : NIIF_WARNING) | NIIF_NOSOUND; + balloon.dwInfoFlags = (g_appState == AppState::Active ? NIIF_INFO : NIIF_WARNING) | NIIF_NOSOUND; wcsncpy_s(balloon.szInfoTitle, L"GPU-Switcher", _TRUNCATE); - if (g_resetInProgress) - { - wcsncpy_s(balloon.szInfo, L"Already running (display reset in progress)", _TRUNCATE); - } - else if (g_dGpuActive) + + switch (g_appState) { + case AppState::Active: swprintf_s(balloon.szInfo, L"Already active in system tray.\nGPU: %s", g_activeGpuName.c_str()); + break; + case AppState::Resetting: + wcsncpy_s(balloon.szInfo, L"Already running (display reset in progress)", _TRUNCATE); + break; + case AppState::WaitingForDiscreteGpu: + if (!g_preferredGpuName.empty()) + swprintf_s(balloon.szInfo, L"Already running (waiting for %s)", g_preferredGpuName.c_str()); + else + wcsncpy_s(balloon.szInfo, L"Already running (waiting for discrete GPU)", _TRUNCATE); + break; + case AppState::NoDiscreteGpu: + wcsncpy_s(balloon.szInfo, L"Already running (no discrete GPU detected)", _TRUNCATE); + break; + case AppState::DeviceLost: + wcsncpy_s(balloon.szInfo, L"Already running (discrete GPU lost — recovering)", _TRUNCATE); + break; + case AppState::Acquiring: + wcsncpy_s(balloon.szInfo, L"Already running (acquiring discrete GPU)", _TRUNCATE); + break; } - else - { - wcsncpy_s(balloon.szInfo, L"Already running (dGPU inactive)", _TRUNCATE); - } + Shell_NotifyIconW(NIM_MODIFY, &balloon); return 0; } @@ -496,9 +736,12 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) static int RunElevatedReset() { LogInfo(L"GPU-Switcher elevated reset started"); - CycleAllDisplayAdapters(); - LogInfo(L"GPU-Switcher elevated reset complete"); - return 0; + ResetResult res = CycleAllDisplayAdapters(); + wchar_t buf[128]; + swprintf_s(buf, L"GPU-Switcher elevated reset completed with result: %s (%d)", + ResetResultToString(res), static_cast(res)); + LogInfo(buf); + return static_cast(res); } // lpCmdLine is unused because we parse via GetCommandLineW(). @@ -599,21 +842,18 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) Sleep(200); if (!AcquireDGpu()) - LogError(L"Failed to acquire dGPU on startup"); + LogError(L"Failed to acquire discrete GPU on startup"); g_nid.cbSize = sizeof(g_nid); g_nid.hWnd = hwnd; g_nid.uID = TRAY_ID; g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP; g_nid.uCallbackMessage = WM_TRAY; - g_nid.hIcon = GetVendorIcon(g_dGpuActive, g_activeVendorId); + g_nid.hIcon = GetVendorIcon(g_appState == AppState::Active, g_activeVendorId); if (!g_nid.hIcon) g_nid.hIcon = LoadIconW(nullptr, IDI_APPLICATION); - if (g_dGpuActive) - swprintf_s(g_nid.szTip, L"GPU-Switcher: %s", g_activeGpuName.c_str()); - else - wcsncpy_s(g_nid.szTip, L"GPU-Switcher: dGPU inactive", _TRUNCATE); + UpdateTrayStatus(); Shell_NotifyIconW(NIM_ADD, &g_nid); g_nid.uVersion = NOTIFYICON_VERSION_4; diff --git a/src/util/reset.cpp b/src/util/reset.cpp index 2cef844..5cbc83c 100644 --- a/src/util/reset.cpp +++ b/src/util/reset.cpp @@ -9,6 +9,10 @@ #pragma comment(lib, "cfgmgr32.lib") +#ifndef DN_STARTED +#define DN_STARTED 0x00000008 +#endif + struct AdapterTarget { DEVINST inst = 0; @@ -20,6 +24,20 @@ struct AdapterTarget : inst(i), name(n), instanceId(id), disabledSuccessfully(d) {} }; +const wchar_t* ResetResultToString(ResetResult res) +{ + switch (res) + { + case ResetResult::Success: return L"Success"; + case ResetResult::EnumerationFailed: return L"EnumerationFailed"; + case ResetResult::NoAdaptersFound: return L"NoAdaptersFound"; + case ResetResult::PartialDisable: return L"PartialDisable"; + case ResetResult::EnableFailed: return L"EnableFailed"; + case ResetResult::VerifyFailed: return L"VerifyFailed"; + default: return L"Unknown"; + } +} + static std::wstring ConfigRetToString(CONFIGRET cr) { switch (cr) @@ -38,7 +56,7 @@ static std::wstring ConfigRetToString(CONFIGRET cr) } } -void CycleAllDisplayAdapters() +ResetResult CycleAllDisplayAdapters() { std::vector adapters; @@ -51,7 +69,7 @@ void CycleAllDisplayAdapters() ) != CR_SUCCESS || len == 0) { LogError(L"CM_Get_Device_ID_List_Size failed"); - return; + return ResetResult::EnumerationFailed; } // 2. Retrieve multi-string list of device instance IDs @@ -64,7 +82,7 @@ void CycleAllDisplayAdapters() ) != CR_SUCCESS) { LogError(L"CM_Get_Device_ID_List failed"); - return; + return ResetResult::EnumerationFailed; } // 3. Filter for display class: GUID_DEVCLASS_DISPLAY {4d36e968-e325-11ce-bfc1-08002be10318} @@ -133,14 +151,15 @@ void CycleAllDisplayAdapters() if (adapters.empty()) { LogError(L"No physical PCI display adapters found for reset"); - return; + return ResetResult::NoAdaptersFound; } LogInfo(std::wstring(L"Beginning reset of ") + std::to_wstring(adapters.size()) + L" display adapter(s):"); for (const auto& a : adapters) LogInfo(L" - Target: " + a.name + L" (" + a.instanceId + L")"); - // 4. Disable display adapters and verify status + // 4. Disable display adapters and track results + bool hadDisableFailure = false; for (auto& a : adapters) { LogInfo(L"Disabling: " + a.name + L" (" + a.instanceId + L")"); @@ -152,6 +171,7 @@ void CycleAllDisplayAdapters() } else { + hadDisableFailure = true; LogError(L"Failed to disable " + a.name + L" — " + ConfigRetToString(cr)); } } @@ -159,6 +179,7 @@ void CycleAllDisplayAdapters() Sleep(2000); // Allow driver unload // 5. Re-enable display adapters with retry logic + bool hadEnableFailure = false; LogInfo(L"Re-enabling display adapters..."); for (auto& a : adapters) { @@ -181,14 +202,75 @@ void CycleAllDisplayAdapters() if (cr == CR_SUCCESS) { - LogInfo(L"Successfully re-enabled: " + a.name); + LogInfo(L"Successfully requested enable for: " + a.name); } else { + hadEnableFailure = true; LogError(L"CRITICAL: Failed to re-enable " + a.name + L" — " + ConfigRetToString(cr)); } } - Sleep(1500); // Allow driver re-initialization - LogInfo(L"Adapter cycle completed"); + // 6. Deterministic verification: poll CM_Get_DevNode_Status until DN_STARTED with problem == 0 + bool hadVerifyFailure = false; + LogInfo(L"Verifying device startup status with CM_Get_DevNode_Status..."); + for (auto& a : adapters) + { + if (!a.disabledSuccessfully) + continue; + + bool started = false; + ULONG status = 0; + ULONG problem = 0; + + // Poll for up to 3000ms (15 iterations x 200ms) for driver initialization + for (int attempt = 0; attempt < 15; ++attempt) + { + Sleep(200); + CONFIGRET cr = CM_Get_DevNode_Status(&status, &problem, a.inst, 0); + if (cr == CR_SUCCESS) + { + if ((status & DN_STARTED) != 0 && problem == 0) + { + started = true; + break; + } + } + } + + if (started) + { + wchar_t logBuf[256]; + swprintf_s(logBuf, L"Verified %s started successfully (Status: 0x%08X, Problem: %u)", + a.name.c_str(), status, problem); + LogInfo(logBuf); + } + else + { + hadVerifyFailure = true; + wchar_t errBuf[256]; + swprintf_s(errBuf, L"CRITICAL: Verification failed for %s (Status: 0x%08X, Problem: %u)", + a.name.c_str(), status, problem); + LogError(errBuf); + } + } + + if (hadEnableFailure) + { + LogError(L"Adapter cycle completed with errors: one or more adapters failed to re-enable"); + return ResetResult::EnableFailed; + } + if (hadVerifyFailure) + { + LogError(L"Adapter cycle completed with errors: one or more adapters failed verification"); + return ResetResult::VerifyFailed; + } + if (hadDisableFailure) + { + LogInfo(L"Adapter cycle completed: some adapters were not disabled, but all targets verified"); + return ResetResult::PartialDisable; + } + + LogInfo(L"Adapter cycle completed successfully: all display adapters restarted and verified"); + return ResetResult::Success; } diff --git a/src/util/reset.h b/src/util/reset.h index 7a54ab7..b3bdace 100644 --- a/src/util/reset.h +++ b/src/util/reset.h @@ -1,7 +1,21 @@ #pragma once +enum class ResetResult : int +{ + Success = 0, + EnumerationFailed = 1, + NoAdaptersFound = 2, + PartialDisable = 3, + EnableFailed = 4, + VerifyFailed = 5 +}; + +// Returns a human-readable string representation of a ResetResult code. +const wchar_t* ResetResultToString(ResetResult res); + // Performs a full disable/enable cycle on physical PCI display adapters, // skipping virtual and software display devices. // Must be called from an elevated process. -// Blocks until all target adapters are re-enabled. -void CycleAllDisplayAdapters(); +// Verifies via CM_Get_DevNode_Status that all adapters are started with no problem code. +// Blocks until all target adapters are processed and verified. +ResetResult CycleAllDisplayAdapters(); From 52235536a3b2942e12435459d2be2de4ab31e58f Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:54:51 -0400 Subject: [PATCH 04/13] Preserve preferred LUID, handle PartialDisable as warning, and refine docs - Prevent preference drift by locking g_preferredLuid to the initial discrete selection; support temporary fallback adapters and auto-restore preference on reconnection. - Treat PartialDisable as an incomplete reset in WM_RESET_DONE with explicit balloon warning rather than silent relaunch. - Log state transitions via SetAppState using AppStateToString. - Clean up unused candidate vector in main.cpp. - Tighten comments and README regarding PCI device targeting and event-driven devnodes notifications. - Fix troubleshooting anchor link in README to #one-time-gpu-preference-setup. --- README.md | 10 +-- src/main.cpp | 147 +++++++++++++++++++++++++++++++++++---------- src/util/reset.cpp | 6 +- src/util/reset.h | 6 +- 4 files changed, 125 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index b2530fd..6da5341 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ Designed primarily for hybrid laptops equipped with **NVIDIA Advanced Optimus** - 🔵 **Blue**: Active Intel discrete GPU - ⚪ **Grey**: Other / Unknown adapter - 🟡 **Yellow**: Display reset in progress or dGPU acquisition warning -- 🩺 **Live Health Monitoring & eGPU Auto-Recovery**: Tracks discrete adapters by **Locally Unique Identifier (LUID)** and listens for hardware notifications (`WM_DEVICECHANGE` / `DBT_DEVNODES_CHANGED`). If an eGPU is disconnected or driver reset occurs (`GetDeviceRemovedReason()`), the app enters an explicit waiting state without trapping the integrated GPU, and automatically re-acquires the dGPU the instant it reconnects. +- 🩺 **Live Health Monitoring & eGPU Auto-Recovery**: Tracks discrete adapters by **Locally Unique Identifier (LUID)** and provides event-driven hot-plug detection via `WM_DEVICECHANGE` (`DBT_DEVNODES_CHANGED`) with periodic retry fallback. If an eGPU is disconnected or a driver reset occurs (`GetDeviceRemovedReason()`), the app enters an explicit waiting state without trapping onto integrated graphics, and automatically re-acquires the preferred discrete GPU as soon as hardware re-initialization completes. - 💤 **Non-Blocking Power Management**: Flushes and releases the DirectX context cleanly before sleep (`PBT_APMSUSPEND`), and re-acquires automatically with a brief delay upon system resume without freezing the message loop. -- 🔄 **Verified Display Adapter Restart**: Safely cycles physical graphics drivers using the Windows Configuration Manager API (`CfgMgr32`), skips virtual/software devices, deterministically verifies driver initialization via `CM_Get_DevNode_Status` (`DN_STARTED`), and propagates granular error codes. +- 🔄 **Verified Display Adapter Restart**: Safely cycles PCI display adapters using the Windows Configuration Manager API (`CfgMgr32`), skips known virtual/software devices, deterministically verifies driver initialization via `CM_Get_DevNode_Status` (`DN_STARTED`), and surfaces partial or failed reset results. - 🖥️ **CLI Controls**: Supports `--exit` / `--quit` for graceful shutdown from scripts or terminals, and `--help` for usage information. - 🔔 **Single-Instance Aware**: Launching a duplicate instance highlights and displays the active GPU status balloon from the existing tray process. - 🔍 **High-DPI & Per-User Logging**: Full Per-Monitor V2 DPI awareness and rolling diagnostic logs stored safely in `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log`. @@ -50,7 +50,7 @@ GPU-Switcher is a standalone portable application: --- -## One‑Time GPU Preference Setup +## One-Time GPU Preference Setup Starting with Windows 10 (version 20H1+) and Windows 11, the operating system's per-application **Graphics Settings** can override driver-level preferences. To ensure your discrete GPU is consistently selected: @@ -103,7 +103,7 @@ For NVIDIA Advanced Optimus laptops to trigger dynamic internal display switchin 2. **Adapter Classification & LUID Tracking**: Enumerates all DXGI adapters, removes software renderers (`DXGI_ADAPTER_FLAG_SOFTWARE`), and classifies discrete candidates (NVIDIA, AMD dGPU, Intel Arc) separate from integrated APUs. Remembers the preferred adapter via its Windows **Locally Unique Identifier (LUID)** so transient disconnects do not trap the system into integrated graphics. 3. **Direct3D 11 Pinning**: Creates a persistent `ID3D11Device` on the selected discrete adapter with `D3D11_CREATE_DEVICE_BGRA_SUPPORT`. This signals the graphics driver that a high-performance 3D process is active, preventing the GPU from entering deep sleep and keeping Advanced Optimus routed to the discrete GPU. 4. **State-Driven, Zero-Overhead Lifecycle**: An explicit state machine (`Active`, `WaitingForDiscreteGpu`, `DeviceLost`, `Resetting`) handles adapter loss, eGPU hot-plugging via `WM_DEVICECHANGE`, system sleep/resume (`WM_POWERBROADCAST`), and taskbar recreation with 0% resident CPU usage. -5. **Deterministic Driver Reset**: When display restart is requested, an elevated helper cycles physical PCI display adapters via CfgMgr32, poll-verifies driver initialization with `CM_Get_DevNode_Status` (`DN_STARTED`, problem code 0), and propagates verified status back to the parent. +5. **Deterministic Driver Reset**: When display restart is requested, an elevated helper cycles PCI display adapters via CfgMgr32, poll-verifies driver initialization with `CM_Get_DevNode_Status` (`DN_STARTED`, problem code 0), and propagates verified status back to the parent. --- @@ -141,7 +141,7 @@ The compiled binary will be located at `build/Release/GPU-Switcher.exe` (or `bui - **Diagnostic Logs**: A rolling log file is written to `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` (capped at 16 KB with automatic rotation). Check this file if a device fails to acquire or to inspect detected adapter scores. - **Display Stuttering**: If your laptop panel stutters after switching display modes, use **Restart Display Adapters** from the tray menu to reload the graphics driver stack without rebooting Windows. -- **Display Not Switching**: Ensure you have configured the program profile in NVIDIA Control Panel under Program Settings as described in the [One-Time Setup](#one-time-nvidia-optimus-setup). +- **Display Not Switching**: Ensure you have configured the program profile in Windows Graphics Settings and NVIDIA Control Panel as described in the [One-Time GPU Preference Setup](#one-time-gpu-preference-setup). --- diff --git a/src/main.cpp b/src/main.cpp index 12d6689..61c199b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -81,6 +81,22 @@ static LUID g_activeLuid = { 0, 0 }; static LUID g_preferredLuid = { 0, 0 }; static std::wstring g_preferredGpuName; +static bool HasPreferredLuid() +{ + return (g_preferredLuid.LowPart != 0 || g_preferredLuid.HighPart != 0); +} + +static void SetAppState(AppState newState) +{ + if (g_appState != newState) + { + wchar_t buf[128]; + swprintf_s(buf, L"State transition: %s -> %s", AppStateToString(g_appState), AppStateToString(newState)); + LogInfo(buf); + g_appState = newState; + } +} + static bool EvaluateAdapter(IDXGIAdapter1* pAdapter, GpuCandidate& info) { if (FAILED(pAdapter->GetDesc1(&info.desc))) @@ -157,7 +173,7 @@ static void ReleaseDGpu() g_activeLuid = { 0, 0 }; if (g_appState == AppState::Active) { - g_appState = AppState::WaitingForDiscreteGpu; + SetAppState(AppState::WaitingForDiscreteGpu); } } @@ -173,7 +189,6 @@ static bool AcquireDGpu() } std::vector discreteCandidates; - std::vector allCandidates; ComPtr adapter; for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) @@ -188,7 +203,6 @@ static bool AcquireDGpu() info.isDiscrete ? L"yes" : L"no", info.score); LogInfo(logBuf); - allCandidates.push_back(info); if (info.isDiscrete) { discreteCandidates.push_back(info); @@ -199,14 +213,14 @@ static bool AcquireDGpu() if (discreteCandidates.empty()) { - if (g_preferredLuid.LowPart != 0 || g_preferredLuid.HighPart != 0) + if (HasPreferredLuid()) { - g_appState = AppState::WaitingForDiscreteGpu; + SetAppState(AppState::WaitingForDiscreteGpu); LogInfo(L"Preferred discrete GPU is not present — waiting for reconnection"); } else { - g_appState = AppState::NoDiscreteGpu; + SetAppState(AppState::NoDiscreteGpu); LogInfo(L"No discrete GPU detected on the system"); } return false; @@ -216,7 +230,7 @@ static bool AcquireDGpu() // If a preferred LUID was previously tracked, look for an exact match among discrete candidates GpuCandidate best; bool foundPreferred = false; - if (g_preferredLuid.LowPart != 0 || g_preferredLuid.HighPart != 0) + if (HasPreferredLuid()) { for (const auto& cand : discreteCandidates) { @@ -225,7 +239,7 @@ static bool AcquireDGpu() { best = cand; foundPreferred = true; - LogInfo(std::wstring(L"Matched previously selected discrete GPU: ") + best.desc.Description); + LogInfo(std::wstring(L"Matched preferred discrete GPU: ") + best.desc.Description); break; } } @@ -233,7 +247,7 @@ static bool AcquireDGpu() if (!foundPreferred) { - // Pick the highest scoring discrete candidate + // Pick the highest scoring discrete candidate (either initial selection, or temporary fallback) int bestScore = -1; for (const auto& cand : discreteCandidates) { @@ -243,10 +257,18 @@ static bool AcquireDGpu() best = cand; } } - LogInfo(std::wstring(L"Selected target discrete GPU: ") + best.desc.Description); + + if (HasPreferredLuid()) + { + LogInfo(std::wstring(L"Preferred GPU not found — acquiring fallback discrete GPU: ") + best.desc.Description); + } + else + { + LogInfo(std::wstring(L"Selected initial preferred discrete GPU: ") + best.desc.Description); + } } - g_appState = AppState::Acquiring; + SetAppState(AppState::Acquiring); D3D_FEATURE_LEVEL level = {}; D3D_FEATURE_LEVEL featureLevels[] = { @@ -268,19 +290,26 @@ static bool AcquireDGpu() wchar_t buf[64]; swprintf_s(buf, L"D3D11CreateDevice failed: 0x%08X", hr); LogError(buf); - g_appState = AppState::DeviceLost; + SetAppState(AppState::DeviceLost); return false; } if (level < D3D_FEATURE_LEVEL_11_0) LogError(L"Warning: acquired adapter feature level is below D3D_FEATURE_LEVEL_11_0"); - g_activeVendorId = best.desc.VendorId; - g_activeGpuName = best.desc.Description; - g_activeLuid = best.luid; - g_preferredLuid = best.luid; - g_preferredGpuName = best.desc.Description; - g_appState = AppState::Active; + g_activeVendorId = best.desc.VendorId; + g_activeGpuName = best.desc.Description; + g_activeLuid = best.luid; + + // Establish preferred LUID only on initial selection to prevent preference drift + if (!HasPreferredLuid()) + { + g_preferredLuid = best.luid; + g_preferredGpuName = best.desc.Description; + LogInfo(std::wstring(L"Established preferred discrete GPU: ") + best.desc.Description); + } + + SetAppState(AppState::Active); LogInfo(std::wstring(L"Successfully initialized D3D11 device on: ") + best.desc.Description); return true; @@ -336,7 +365,14 @@ static void UpdateTrayStatus(const wchar_t* customTip = nullptr) switch (g_appState) { case AppState::Active: - swprintf_s(g_nid.szTip, L"GPU-Switcher: %s (Active)", g_activeGpuName.c_str()); + if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) + { + swprintf_s(g_nid.szTip, L"GPU-Switcher: %s (Fallback)", g_activeGpuName.c_str()); + } + else + { + swprintf_s(g_nid.szTip, L"GPU-Switcher: %s (Active)", g_activeGpuName.c_str()); + } break; case AppState::Resetting: wcsncpy_s(g_nid.szTip, L"GPU-Switcher: Restarting display adapters…", _TRUNCATE); @@ -407,7 +443,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) wchar_t buf[128]; swprintf_s(buf, L"D3D11 device lost (0x%08X) — re-acquiring", hr); LogError(buf); - g_appState = AppState::DeviceLost; + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); } @@ -421,7 +457,15 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) switch (g_appState) { case AppState::Active: - swprintf_s(balloon.szInfo, L"Active discrete GPU:\n%s", g_activeGpuName.c_str()); + if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) + { + swprintf_s(balloon.szInfo, L"Active discrete GPU (Fallback):\n%s\n\nPreferred GPU:\n%s", + g_activeGpuName.c_str(), g_preferredGpuName.c_str()); + } + else + { + swprintf_s(balloon.szInfo, L"Active discrete GPU:\n%s", g_activeGpuName.c_str()); + } break; case AppState::Resetting: wcsncpy_s(balloon.szInfo, L"Display adapter restart in progress…", _TRUNCATE); @@ -483,7 +527,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (g_appState == AppState::Resetting) break; - g_appState = AppState::Resetting; + SetAppState(AppState::Resetting); UpdateTrayStatus(L"GPU-Switcher: Restarting display adapters…"); LogInfo(L"Restart Display Adapters requested"); @@ -504,7 +548,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (!ShellExecuteExW(&sei) || !sei.hProcess) { LogInfo(L"Elevated launch cancelled or failed — reacquiring dGPU"); - g_appState = AppState::DeviceLost; + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); break; @@ -545,9 +589,9 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) DWORD resetExitCode = static_cast(wParam); ResetResult res = static_cast(resetExitCode); - if (res == ResetResult::Success || res == ResetResult::PartialDisable) + if (res == ResetResult::Success) { - LogInfo(std::wstring(L"Elevated reset completed (") + ResetResultToString(res) + L") — re-launching GPU-Switcher and exiting"); + LogInfo(L"Elevated reset completed successfully — re-launching GPU-Switcher and exiting"); Shell_NotifyIconW(NIM_DELETE, &g_nid); if (g_hMutex) @@ -575,20 +619,31 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) else { wchar_t errBuf[256]; - swprintf_s(errBuf, L"Display adapter reset finished with error: %s (code %u)", + swprintf_s(errBuf, L"Display adapter reset finished with non-success status: %s (code %u)", ResetResultToString(res), resetExitCode); LogError(errBuf); - g_appState = AppState::DeviceLost; + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); NOTIFYICONDATAW balloon = g_nid; balloon.uFlags |= NIF_INFO; balloon.dwInfoFlags = NIIF_WARNING | NIIF_NOSOUND; - wcsncpy_s(balloon.szInfoTitle, L"GPU-Switcher: Reset Warning", _TRUNCATE); - swprintf_s(balloon.szInfo, L"Adapter reset completed with warning: %s (code %u). Check log for details.", - ResetResultToString(res), resetExitCode); + wcsncpy_s(balloon.szInfoTitle, L"GPU-Switcher: Reset Incomplete", _TRUNCATE); + + if (res == ResetResult::PartialDisable) + { + wcsncpy_s(balloon.szInfo, + L"One or more display adapters could not be disabled. The reset was only partially applied.", + _TRUNCATE); + } + else + { + swprintf_s(balloon.szInfo, + L"Adapter reset failed with status: %s (code %u). Check log for details.", + ResetResultToString(res), resetExitCode); + } Shell_NotifyIconW(NIM_MODIFY, &balloon); } return 0; @@ -625,10 +680,19 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) wchar_t buf[128]; swprintf_s(buf, L"D3D11 device lost on devnodes change (0x%08X) — re-acquiring", hr); LogError(buf); - g_appState = AppState::DeviceLost; + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); } + else if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) + { + // Check if preferred discrete GPU reconnected + if (AcquireDGpu()) + { + LogInfo(L"Preferred discrete GPU returned — restored preference"); + UpdateTrayStatus(); + } + } } else { @@ -663,10 +727,19 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) wchar_t buf[128]; swprintf_s(buf, L"D3D11 device removed or lost (0x%08X) — re-acquiring", hr); LogError(buf); - g_appState = AppState::DeviceLost; + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); } + else if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) + { + // Periodically test if preferred GPU has re-appeared + if (AcquireDGpu()) + { + LogInfo(L"Preferred discrete GPU detected and reacquired during periodic scan"); + UpdateTrayStatus(); + } + } } else if (g_appState == AppState::WaitingForDiscreteGpu || g_appState == AppState::NoDiscreteGpu || @@ -692,7 +765,15 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) switch (g_appState) { case AppState::Active: - swprintf_s(balloon.szInfo, L"Already active in system tray.\nGPU: %s", g_activeGpuName.c_str()); + if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) + { + swprintf_s(balloon.szInfo, L"Already active in system tray.\nGPU (Fallback): %s\n(Preferred: %s)", + g_activeGpuName.c_str(), g_preferredGpuName.c_str()); + } + else + { + swprintf_s(balloon.szInfo, L"Already active in system tray.\nGPU: %s", g_activeGpuName.c_str()); + } break; case AppState::Resetting: wcsncpy_s(balloon.szInfo, L"Already running (display reset in progress)", _TRUNCATE); diff --git a/src/util/reset.cpp b/src/util/reset.cpp index 5cbc83c..20da726 100644 --- a/src/util/reset.cpp +++ b/src/util/reset.cpp @@ -120,7 +120,7 @@ ResetResult CycleAllDisplayAdapters() } } - // Query hardware ID to distinguish physical PCI graphics cards from virtual/software adapters + // Query hardware ID to distinguish PCI graphics cards from virtual/software adapters wchar_t hwId[512] = {}; ULONG hwIdLen = sizeof(hwId); CM_Get_DevNode_Registry_PropertyW(inst, CM_DRP_HARDWAREID, nullptr, hwId, &hwIdLen, 0); @@ -130,7 +130,7 @@ ResetResult CycleAllDisplayAdapters() devName, p, hwId[0] ? hwId : L"(none)"); LogInfo(logBuf); - // Only target physical PCI display devices (e.g. PCI\VEN_10DE, PCI\VEN_1002, PCI\VEN_8086) + // Target PCI display adapters (e.g. PCI\VEN_10DE, PCI\VEN_1002, PCI\VEN_8086) // Skip non-PCI devices (ROOT\, SWD\, USB\) and Microsoft Basic Display Adapter (VEN_1414) if (_wcsnicmp(p, L"PCI\\", 4) != 0 && _wcsnicmp(hwId, L"PCI\\", 4) != 0) { @@ -267,7 +267,7 @@ ResetResult CycleAllDisplayAdapters() } if (hadDisableFailure) { - LogInfo(L"Adapter cycle completed: some adapters were not disabled, but all targets verified"); + LogError(L"Adapter cycle completed with warning: one or more adapters could not be disabled; only successfully disabled adapters were cycled and verified"); return ResetResult::PartialDisable; } diff --git a/src/util/reset.h b/src/util/reset.h index b3bdace..76f2510 100644 --- a/src/util/reset.h +++ b/src/util/reset.h @@ -13,9 +13,9 @@ enum class ResetResult : int // Returns a human-readable string representation of a ResetResult code. const wchar_t* ResetResultToString(ResetResult res); -// Performs a full disable/enable cycle on physical PCI display adapters, -// skipping virtual and software display devices. +// Performs a full disable/enable cycle on targeted PCI display adapters, +// skipping known virtual and software display devices. // Must be called from an elevated process. -// Verifies via CM_Get_DevNode_Status that all adapters are started with no problem code. +// Verifies via CM_Get_DevNode_Status that successfully cycled adapters are started with no problem code. // Blocks until all target adapters are processed and verified. ResetResult CycleAllDisplayAdapters(); From 68793e2c2cb629bb6036456895e9fdae33f22bd5 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:01:07 -0400 Subject: [PATCH 05/13] Optimize hot-plug and health check reacquisition with IsAdapterPresent check Avoid tearing down and rebuilding active D3D11 fallback devices on non-GPU devnode changes or periodic timer ticks by checking IsAdapterPresent(g_preferredLuid) before attempting reacquisition. --- src/main.cpp | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 61c199b..5b4d057 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -97,6 +97,29 @@ static void SetAppState(AppState newState) } } +static bool IsAdapterPresent(const LUID& targetLuid) +{ + ComPtr factory; + if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1), reinterpret_cast(factory.GetAddressOf())))) + return false; + + ComPtr adapter; + for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) + { + DXGI_ADAPTER_DESC1 desc; + if (SUCCEEDED(adapter->GetDesc1(&desc))) + { + if (desc.AdapterLuid.LowPart == targetLuid.LowPart && + desc.AdapterLuid.HighPart == targetLuid.HighPart) + { + return true; + } + } + adapter.Reset(); + } + return false; +} + static bool EvaluateAdapter(IDXGIAdapter1* pAdapter, GpuCandidate& info) { if (FAILED(pAdapter->GetDesc1(&info.desc))) @@ -686,11 +709,14 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) { - // Check if preferred discrete GPU reconnected - if (AcquireDGpu()) + // Only re-acquire if the preferred discrete GPU has actually reappeared in the topology + if (IsAdapterPresent(g_preferredLuid)) { - LogInfo(L"Preferred discrete GPU returned — restored preference"); - UpdateTrayStatus(); + LogInfo(L"Preferred discrete GPU re-detected in topology — restoring preference"); + if (AcquireDGpu()) + { + UpdateTrayStatus(); + } } } } @@ -733,11 +759,14 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) { - // Periodically test if preferred GPU has re-appeared - if (AcquireDGpu()) + // Periodically test if preferred GPU has re-appeared in the topology + if (IsAdapterPresent(g_preferredLuid)) { - LogInfo(L"Preferred discrete GPU detected and reacquired during periodic scan"); - UpdateTrayStatus(); + LogInfo(L"Preferred discrete GPU detected in topology during periodic scan — restoring preference"); + if (AcquireDGpu()) + { + UpdateTrayStatus(); + } } } } From d4def55c534c4bc6646cb9223e7fbf3217a8af00 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:07:17 -0400 Subject: [PATCH 06/13] Ground README overhead and CPU usage claims --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6da5341..f070a94 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

GPU‑Switcher

- A lightweight, zero-overhead GPU switching utility for Windows + A lightweight, near-zero overhead GPU switching utility for Windows

@@ -17,7 +17,7 @@   -**GPU-Switcher** is a tiny, battery-friendly Windows system tray utility that keeps your laptop's discrete GPU (dGPU) activated with **zero CPU usage** and no background polling threads. +**GPU-Switcher** is a tiny, battery-friendly Windows system tray utility that keeps your laptop's discrete GPU (dGPU) activated with **near-zero CPU usage** and no persistent background rendering loops. Designed primarily for hybrid laptops equipped with **NVIDIA Advanced Optimus** (or MUX switch systems) where you want the dGPU ready for low-latency gaming, external displays, or 3D workloads without needing to keep a heavy game or benchmark running in the background. @@ -25,7 +25,7 @@ Designed primarily for hybrid laptops equipped with **NVIDIA Advanced Optimus** ## Features -- ⚡ **Zero Overhead**: Creates a persistent Direct3D 11 device on the dGPU and sleeps in the Windows message pump. No background loops, no telemetry, and 0% CPU consumption. +- ⚡ **Near-Zero Overhead**: Creates a persistent Direct3D 11 device on the dGPU and sleeps in the Windows message pump. No heavy background worker loops, no telemetry, and virtually 0% CPU consumption. - 🎨 **Dynamic Vendor Tray Icons**: Instantly see which GPU is active with color-coded tray icons: - 🟢 **Green**: Active NVIDIA discrete GPU - 🔴 **Red**: Active AMD discrete GPU @@ -102,7 +102,7 @@ For NVIDIA Advanced Optimus laptops to trigger dynamic internal display switchin - `AmdPowerXpressRequestHighPerformance = 1` (AMD PowerXpress hint) 2. **Adapter Classification & LUID Tracking**: Enumerates all DXGI adapters, removes software renderers (`DXGI_ADAPTER_FLAG_SOFTWARE`), and classifies discrete candidates (NVIDIA, AMD dGPU, Intel Arc) separate from integrated APUs. Remembers the preferred adapter via its Windows **Locally Unique Identifier (LUID)** so transient disconnects do not trap the system into integrated graphics. 3. **Direct3D 11 Pinning**: Creates a persistent `ID3D11Device` on the selected discrete adapter with `D3D11_CREATE_DEVICE_BGRA_SUPPORT`. This signals the graphics driver that a high-performance 3D process is active, preventing the GPU from entering deep sleep and keeping Advanced Optimus routed to the discrete GPU. -4. **State-Driven, Zero-Overhead Lifecycle**: An explicit state machine (`Active`, `WaitingForDiscreteGpu`, `DeviceLost`, `Resetting`) handles adapter loss, eGPU hot-plugging via `WM_DEVICECHANGE`, system sleep/resume (`WM_POWERBROADCAST`), and taskbar recreation with 0% resident CPU usage. +4. **State-Driven, Lightweight Lifecycle**: An explicit state machine (`Active`, `WaitingForDiscreteGpu`, `DeviceLost`, `Resetting`) handles adapter loss, eGPU hot-plugging via `WM_DEVICECHANGE`, system sleep/resume (`WM_POWERBROADCAST`), and taskbar recreation with negligible resident CPU impact. 5. **Deterministic Driver Reset**: When display restart is requested, an elevated helper cycles PCI display adapters via CfgMgr32, poll-verifies driver initialization with `CM_Get_DevNode_Status` (`DN_STARTED`, problem code 0), and propagates verified status back to the parent. --- From fa5d438d23fd19ba0bb06635409471f192c70e44 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:50:04 -0400 Subject: [PATCH 07/13] Fix UTF-8 log conversion and exclude acquisition during GPU reset --- src/main.cpp | 13 +++++++++++++ src/util/logging.cpp | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index c42022d..b1e480f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,6 +59,10 @@ static void ReleaseDGpu() static bool AcquireDGpu() { + // Every acquisition path must leave the devices alone while the helper resets them. + if (g_resetInProgress) + return false; + ReleaseDGpu(); ComPtr factory; @@ -319,6 +323,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) break; g_resetInProgress = true; + KillTimer(hwnd, TIMER_RESUME_REACQUIRE); UpdateTrayStatus(L"GPU-Switcher: Restarting display adapters…"); LogInfo(L"Restart Display Adapters requested"); @@ -413,6 +418,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (wParam == PBT_APMRESUMEAUTOMATIC || wParam == PBT_APMRESUMESUSPEND) { + // The reset completion/cancellation path owns recovery until the helper exits. + if (g_resetInProgress) + return TRUE; + LogInfo(L"System resumed — scheduling dGPU re-acquisition"); ReleaseDGpu(); UpdateTrayStatus(L"GPU-Switcher: Resuming…"); @@ -424,6 +433,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (wParam == TIMER_RESUME_REACQUIRE) { KillTimer(hwnd, TIMER_RESUME_REACQUIRE); + // KillTimer does not remove a WM_TIMER message that is already queued. + if (g_resetInProgress) + return 0; + LogInfo(L"Power resume timer fired — re-acquiring dGPU"); if (!AcquireDGpu()) { diff --git a/src/util/logging.cpp b/src/util/logging.cpp index 4151e34..c2a2782 100644 --- a/src/util/logging.cpp +++ b/src/util/logging.cpp @@ -56,8 +56,12 @@ static void WriteLog(const wchar_t* level, const std::wstring& msg) int utf8Len = WideCharToMultiByte(CP_UTF8, 0, line.c_str(), -1, nullptr, 0, nullptr, nullptr); if (utf8Len <= 1) return; - std::string utf8Str(utf8Len - 1, '\0'); - WideCharToMultiByte(CP_UTF8, 0, line.c_str(), -1, &utf8Str[0], utf8Len - 1, nullptr, nullptr); + // Both calls use -1, so the required size includes the terminating null. + std::string utf8Str(utf8Len, '\0'); + int bytesWritten = WideCharToMultiByte(CP_UTF8, 0, line.c_str(), -1, + utf8Str.data(), utf8Len, nullptr, nullptr); + if (bytesWritten != utf8Len) return; + utf8Str.resize(bytesWritten - 1); // Do not write the terminator to the log. { std::ofstream out(logPath, std::ios::app | std::ios::binary); From e1470674e2375df3bc483c99c8b5d813f4ca1c7e Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:06:08 -0400 Subject: [PATCH 08/13] Supervise and serialize display resets with bounded adapter recovery --- .github/workflows/build.yml | 2 +- CMakeLists.txt | 20 +++- README.md | 7 +- docs/VALIDATION.md | 46 ++++++-- src/build_info.h.in | 2 + src/main.cpp | 156 +++++++++++++++++++------ src/reset/reset_lock.cpp | 39 +++++++ src/reset/reset_lock.h | 26 +++++ src/reset/reset_policy.h | 83 ++++++++++++++ src/util/logging.cpp | 2 +- src/util/logging.h | 2 + src/util/reset.cpp | 217 ++++++++++++++++++++--------------- src/util/reset.h | 9 +- tests/reset_lock_tests.cpp | 66 +++++++++++ tests/reset_policy_tests.cpp | 68 +++++++++++ 15 files changed, 603 insertions(+), 142 deletions(-) create mode 100644 src/build_info.h.in create mode 100644 src/reset/reset_lock.cpp create mode 100644 src/reset/reset_lock.h create mode 100644 src/reset/reset_policy.h create mode 100644 tests/reset_lock_tests.cpp create mode 100644 tests/reset_policy_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25754d0..7291165 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: - name: Build run: cmake --build build --config Release - - name: Test acquisition recovery logic + - name: Test acquisition and reset recovery run: ctest --test-dir build -C Release --output-on-failure - name: Upload artifact diff --git a/CMakeLists.txt b/CMakeLists.txt index 24ad86f..48db556 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,11 +24,21 @@ add_executable(GPU-Switcher WIN32 src/util/logging.cpp src/util/startup.cpp src/util/reset.cpp + src/reset/reset_lock.cpp ) set_target_properties(GPU-Switcher PROPERTIES OUTPUT_NAME "GPU-Switcher") -target_include_directories(GPU-Switcher PRIVATE src) +execute_process(COMMAND git rev-parse --short=12 HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT_VARIABLE GPU_SWITCHER_BUILD_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +if(NOT GPU_SWITCHER_BUILD_REVISION) + set(GPU_SWITCHER_BUILD_REVISION "unknown") +endif() +configure_file(src/build_info.h.in generated/build_info.h @ONLY) +target_include_directories(GPU-Switcher PRIVATE src "${CMAKE_CURRENT_BINARY_DIR}/generated") target_compile_definitions(GPU-Switcher PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX @@ -53,4 +63,12 @@ if(BUILD_TESTING) add_executable(acquisition_tests tests/acquisition_tests.cpp) target_include_directories(acquisition_tests PRIVATE src) add_test(NAME acquisition_regressions COMMAND acquisition_tests) + add_executable(reset_policy_tests tests/reset_policy_tests.cpp) + target_include_directories(reset_policy_tests PRIVATE src) + add_test(NAME reset_policy_regressions COMMAND reset_policy_tests) + add_executable(reset_lock_tests tests/reset_lock_tests.cpp src/reset/reset_lock.cpp) + target_include_directories(reset_lock_tests PRIVATE src) + target_compile_definitions(reset_lock_tests PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) + add_test(NAME reset_lock_process_exclusion COMMAND reset_lock_tests) + set_tests_properties(reset_lock_process_exclusion PROPERTIES TIMEOUT 15) endif() diff --git a/README.md b/README.md index cb1a2af..9bf5114 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ For NVIDIA Advanced Optimus laptops to trigger dynamic internal display switchin - **Right‑click Context Menu**: - **Start with Windows**: Toggles a startup entry in `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. - **Restart Display Adapters**: Safely cycles physical display adapters via elevated helper to fix rendering stutters, then automatically restarts the utility (requires UAC confirmation). - - **Exit**: Cleanly releases the DirectX context and terminates the program. + - **View Log**: Opens the current user's diagnostic log. + - **Exit**: Cleanly releases the DirectX context and terminates the program; disabled while a reset is being monitored. --- @@ -103,7 +104,7 @@ For NVIDIA Advanced Optimus laptops to trigger dynamic internal display switchin 2. **Adapter Classification & LUID Tracking**: Enumerates all DXGI adapters, removes software renderers (`DXGI_ADAPTER_FLAG_SOFTWARE`), and classifies discrete candidates (NVIDIA, AMD dGPU, Intel Arc) separate from integrated APUs. Remembers the preferred adapter via its Windows **Locally Unique Identifier (LUID)** so transient disconnects do not trap the system into integrated graphics. 3. **Direct3D 11 Pinning**: Creates a persistent `ID3D11Device` on the selected discrete adapter with `D3D11_CREATE_DEVICE_BGRA_SUPPORT`. This signals the graphics driver that a high-performance 3D process is active, preventing the GPU from entering deep sleep and keeping Advanced Optimus routed to the discrete GPU. 4. **State-Driven, Lightweight Lifecycle**: An explicit state machine (`Active`, `WaitingForDiscreteGpu`, `DeviceLost`, `Resetting`) handles adapter loss, eGPU hot-plugging via `WM_DEVICECHANGE`, system sleep/resume (`WM_POWERBROADCAST`), and taskbar recreation with negligible resident CPU impact. -5. **Deterministic Driver Reset**: When display restart is requested, an elevated helper cycles PCI display adapters via CfgMgr32, poll-verifies driver initialization with `CM_Get_DevNode_Status` (`DN_STARTED`, problem code 0), and propagates verified status back to the parent. +5. **Deterministic Driver Reset**: When display restart is requested, an elevated helper cycles PCI display adapters via CfgMgr32, preserves initially disabled or stopped adapters, retries recovery with refreshed device nodes, poll-verifies driver initialization, and propagates status back to the parent. A machine-wide mutex excludes competing helpers. The tray monitors completion without a background wait thread; after 60 seconds it warns and keeps monitoring, with acquisition and another reset still blocked. --- @@ -141,7 +142,7 @@ Run the acquisition regression tests with `ctest --test-dir build -C Release --o ## Troubleshooting & Diagnostics -- **Diagnostic Logs**: A rolling log file is written to `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` (capped at 16 KB with automatic rotation). Check this file if a device fails to acquire or to inspect detected adapter scores. +- **Diagnostic Logs**: Use **View Log** in the tray menu. Startup records the version and build commit (or `unknown` when built without Git metadata). A rolling log file is written to `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` (capped at 16 KB with automatic rotation). Check this file if a device fails to acquire or to inspect detected adapter scores. - **Display Stuttering**: If your laptop panel stutters after switching display modes, use **Restart Display Adapters** from the tray menu to reload the graphics driver stack without rebooting Windows. - **Display Not Switching**: Ensure you have configured the program profile in Windows Graphics Settings and NVIDIA Control Panel as described in the [One-Time GPU Preference Setup](#one-time-gpu-preference-setup). diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 36e940a..da686df 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -2,8 +2,8 @@ ## Automated checks -The Windows build workflow compiles the application and runs the acquisition -regressions with CTest. The tests use the same ordering and replacement helper +The Windows build workflow compiles the application and runs acquisition, reset +policy, and cross-process reset-lock regressions with CTest. The tests use the same ordering and replacement helper as the application, with simulated resources and creation failures. ```powershell @@ -12,11 +12,13 @@ cmake --build build --config Release ctest --test-dir build -C Release --output-on-failure ``` -The acquisition tests are also independent of Windows headers: +The acquisition and reset-policy tests are independent of Windows headers: ```sh g++ -std=c++17 -Wall -Wextra -Werror -pedantic -Isrc tests/acquisition_tests.cpp -o acquisition_tests ./acquisition_tests +g++ -std=c++17 -Wall -Wextra -Werror -pedantic -Isrc tests/reset_policy_tests.cpp -o reset_policy_tests +./reset_policy_tests ``` Coverage includes preferred/current/score ordering, equal-score ties, successful @@ -30,11 +32,20 @@ The vendor and memory classification thresholds remain heuristics. Passing the acquisition tests does not establish that those thresholds classify all GPUs correctly. +Reset-policy tests cover timeout followed by completion, wait failure retaining +exclusion, initial-state eligibility, refusing to enable untouched devices, +transient enable failure, failed startup verification followed by recovery, and +bounded permanent failure. Windows lock tests launch separate test processes to +check rejection while locked, admission after release, and conservative failure +when the mutex name is occupied by a different object type. No test calls the +physical device-reset APIs. Cross-account UAC and cross-session behavior still +require manual validation. + ## Legion hardware checks Use the Windows CI artifact for the exact commit under test. Record the commit, Windows build, graphics driver versions, power source, and display connections. -Save the log after each independent test from +Use **View Log** and save the log after each independent test from `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` before its rolling limit discards it. 1. **Cold launch:** with the documented NVIDIA profile configured, launch the @@ -61,9 +72,28 @@ Real multi-dGPU/eGPU fallback validation requires additional hardware. The automated regressions simulate failed preferred creation; a single-dGPU Legion cannot validate the complete physical fallback scenario. +7. **Slow helper / reopening:** do not manufacture a hardware hang. The automated + policy tests cover the 60-second warning and eventual completion. If a real + operation runs long, verify that the warning appears once, the tooltip stays + in the waiting state, Exit and another reset remain blocked, and completion + is still handled. If the tray was forcibly terminated and reopened, it should + detect the active helper lock and wait; the external helper's final result is + unknown to that new instance and must not be reported as verified success. +8. **Recovery diagnostics:** check the per-device initial state, disable result, + enable attempts, final locate/status CONFIGRET, problem code, and elapsed time. + Preserve deliberately disabled devices when testing on a suitable setup; do + not disable the Legion's panel-driving adapter merely to exercise this case. + ## Remaining limits -Helper timeouts, concurrent reset-helper prevention, recovery after failed -re-enable, and cross-process/cross-account logging remain separate follow-ups. -Device startup verification does not guarantee application-level rendering or -display routing has recovered. +The helper can still fail to restore an adapter, or be forcibly terminated. There +is no persistent recovery journal for a terminated helper. Recovery is bounded; +an unresolved device is reported, not guaranteed repaired. Logging across two +processes or different elevation accounts remains a follow-up (View Log opens +only the current account's log). + +A default mutex security descriptor is used; lock access/inspection errors block +operations rather than bypassing exclusion. This is conservative handling, not +proof of compatibility across every account/session arrangement. Device startup +verification does not guarantee application-level rendering or display routing +has recovered. diff --git a/src/build_info.h.in b/src/build_info.h.in new file mode 100644 index 0000000..c1abe3a --- /dev/null +++ b/src/build_info.h.in @@ -0,0 +1,2 @@ +#pragma once +#define GPU_SWITCHER_BUILD_REVISION L"@GPU_SWITCHER_BUILD_REVISION@" diff --git a/src/main.cpp b/src/main.cpp index 442efb6..ef11f0c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,9 @@ #include "resource.h" #include "gpu/acquisition.h" +#include "reset/reset_lock.h" +#include "reset/reset_policy.h" +#include "build_info.h" #include "util/logging.h" #include "util/startup.h" #include "util/reset.h" @@ -32,9 +35,11 @@ extern "C" { #define ID_START_WINDOWS 1001 #define ID_RESET_DISPLAYS 1002 #define ID_EXIT 1003 +#define ID_VIEW_LOG 1004 #define TIMER_RESUME_REACQUIRE 2001 #define TIMER_HEALTH_CHECK 2002 +#define TIMER_RESET_MONITOR 2003 using Microsoft::WRL::ComPtr; @@ -73,6 +78,9 @@ struct GpuCandidate static HINSTANCE g_hInst = nullptr; static HANDLE g_hMutex = nullptr; +static HWND g_window = nullptr; +static HANDLE g_resetProcess = nullptr; +static reset::Watch g_resetWatch; static ComPtr g_device; static ComPtr g_context; static NOTIFYICONDATAW g_nid = {}; @@ -226,8 +234,17 @@ struct PendingGpu static bool AcquireDGpu() { // All acquisition paths must leave devices alone while the helper resets them. - if (g_appState == AppState::Resetting) + if (g_appState == AppState::Resetting || g_resetWatch.Active()) return false; + if (reset::Active()) + { + ReleaseDGpu(); + SetAppState(AppState::Resetting); + g_resetWatch.Start(GetTickCount64()); + SetTimer(g_window, TIMER_RESET_MONITOR, 500, nullptr); + LogInfo(L"External display reset detected; waiting before GPU acquisition"); + return false; + } const bool currentHealthy = g_device && g_device->GetDeviceRemovedReason() == S_OK; if (!currentHealthy) @@ -417,7 +434,9 @@ static void UpdateTrayStatus(const wchar_t* customTip = nullptr) } break; case AppState::Resetting: - wcsncpy_s(g_nid.szTip, L"GPU-Switcher: Restarting display adapters…", _TRUNCATE); + wcsncpy_s(g_nid.szTip, g_resetWatch.TimedOut() ? + L"GPU-Switcher: Reset taking longer than expected; still waiting" : + L"GPU-Switcher: Restarting display adapters…", _TRUNCATE); break; case AppState::WaitingForDiscreteGpu: if (!g_preferredGpuName.empty()) @@ -441,19 +460,55 @@ static void UpdateTrayStatus(const wchar_t* customTip = nullptr) Shell_NotifyIconW(NIM_MODIFY, &g_nid); } -// Thread proc: waits for the elevated child to exit, then posts WM_RESET_DONE with child's exit code -struct WaitCtx { HANDLE hProcess; HWND hwnd; }; +static void ShowResetNotice(const wchar_t* message) +{ + NOTIFYICONDATAW balloon = g_nid; + balloon.uFlags |= NIF_INFO; + balloon.dwInfoFlags = NIIF_WARNING | NIIF_NOSOUND; + wcsncpy_s(balloon.szInfoTitle, L"GPU-Switcher: Display Reset", _TRUNCATE); + wcsncpy_s(balloon.szInfo, message, _TRUNCATE); + Shell_NotifyIconW(NIM_MODIFY, &balloon); +} -static DWORD WINAPI WaitForResetThread(LPVOID param) +static void PollReset(HWND hwnd) { - auto* ctx = reinterpret_cast(param); - WaitForSingleObject(ctx->hProcess, INFINITE); - DWORD exitCode = static_cast(ResetResult::EnableFailed); - GetExitCodeProcess(ctx->hProcess, &exitCode); - CloseHandle(ctx->hProcess); - PostMessageW(ctx->hwnd, WM_RESET_DONE, static_cast(exitCode), 0); - delete ctx; - return 0; + if (!g_resetWatch.Active()) return; + const bool ownHelper = g_resetProcess != nullptr; + const DWORD waitResult = ownHelper ? WaitForSingleObject(g_resetProcess, 0) : + (reset::Active() ? WAIT_TIMEOUT : WAIT_OBJECT_0); + const auto event = g_resetWatch.Observe(waitResult == WAIT_OBJECT_0 ? reset::WaitState::Complete : + (waitResult == WAIT_TIMEOUT ? reset::WaitState::Running : reset::WaitState::Failed), GetTickCount64()); + if (event == reset::WatchEvent::Completed) + { + KillTimer(hwnd, TIMER_RESET_MONITOR); + if (ownHelper) + { + DWORD exitCode = static_cast(ResetResult::VerifyFailed); + if (!GetExitCodeProcess(g_resetProcess, &exitCode)) + LogError(L"Could not read completed reset helper exit code"); + CloseHandle(g_resetProcess); + g_resetProcess = nullptr; + SendMessageW(hwnd, WM_RESET_DONE, static_cast(exitCode), 0); + } + else + { + LogInfo(L"External reset lock released; recovering GPU (external result unknown)"); + SetAppState(AppState::DeviceLost); + AcquireDGpu(); + UpdateTrayStatus(); + } + } + else if (event == reset::WatchEvent::TimedOut) + { + LogError(L"Reset exceeded 60 seconds; supervision and acquisition exclusion remain active"); + UpdateTrayStatus(); + ShowResetNotice(L"Reset is taking longer than expected. Still waiting for the helper; GPU acquisition and another reset remain blocked."); + } + else if (event == reset::WatchEvent::WaitFailed) + { + LogError(L"Reset process wait failed; retaining the handle and continuing supervision"); + ShowResetNotice(L"Could not query reset completion. The utility will keep monitoring; another reset remains blocked."); + } } LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) @@ -510,7 +565,9 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } break; case AppState::Resetting: - wcsncpy_s(balloon.szInfo, L"Display adapter restart in progress…", _TRUNCATE); + wcsncpy_s(balloon.szInfo, g_resetWatch.TimedOut() ? + L"Reset is taking longer than expected. Still monitoring; acquisition and another reset remain blocked." : + L"Display adapter restart in progress…", _TRUNCATE); break; case AppState::WaitingForDiscreteGpu: if (!g_preferredGpuName.empty()) @@ -545,7 +602,8 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) AppendMenuW(menu, MF_STRING | (g_appState == AppState::Resetting ? MF_GRAYED : 0), ID_RESET_DISPLAYS, L"Restart Display Adapters"); - AppendMenuW(menu, MF_STRING, + AppendMenuW(menu, MF_STRING, ID_VIEW_LOG, L"View Log"); + AppendMenuW(menu, MF_STRING | (g_appState == AppState::Resetting ? MF_GRAYED : 0), ID_EXIT, L"Exit"); POINT pt; @@ -566,8 +624,21 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) case ID_RESET_DISPLAYS: { - if (g_appState == AppState::Resetting) + if (g_appState == AppState::Resetting || g_resetWatch.Active()) + break; + if (reset::Active()) + { + AcquireDGpu(); + UpdateTrayStatus(); + ShowResetNotice(L"Another display reset is active or its lock cannot be inspected. Waiting for it to finish."); + break; + } + if (!SetTimer(hwnd, TIMER_RESET_MONITOR, 500, nullptr)) + { + LogError(L"Could not start reset supervision timer; helper was not launched"); + ShowResetNotice(L"Could not start reset monitoring. No reset was launched."); break; + } SetAppState(AppState::Resetting); KillTimer(hwnd, TIMER_RESUME_REACQUIRE); @@ -590,6 +661,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (!ShellExecuteExW(&sei) || !sei.hProcess) { + KillTimer(hwnd, TIMER_RESET_MONITOR); LogInfo(L"Elevated launch cancelled or failed — reacquiring dGPU"); SetAppState(AppState::DeviceLost); AcquireDGpu(); @@ -600,26 +672,17 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) LogInfo(L"Elevated child launched — waiting for reset completion"); UpdateTrayStatus(L"Restarting display adapters… screen may flicker"); - auto* ctx = new WaitCtx{ sei.hProcess, hwnd }; - HANDLE hThread = CreateThread(nullptr, 0, WaitForResetThread, - ctx, 0, nullptr); - if (hThread) - { - CloseHandle(hThread); - } - else - { - LogError(L"CreateThread failed — exiting immediately"); - CloseHandle(sei.hProcess); - delete ctx; - Shell_NotifyIconW(NIM_DELETE, &g_nid); - PostQuitMessage(0); - } + g_resetProcess = sei.hProcess; + g_resetWatch.Start(GetTickCount64()); break; } + case ID_VIEW_LOG: + ShellExecuteW(hwnd, L"open", GetLogPath().c_str(), nullptr, nullptr, SW_SHOWNORMAL); + break; + case ID_EXIT: - PostQuitMessage(0); + SendMessageW(hwnd, WM_CLOSE, 0, 0); break; } } @@ -757,6 +820,11 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) return TRUE; case WM_TIMER: + if (wParam == TIMER_RESET_MONITOR) + { + PollReset(hwnd); + return 0; + } if (wParam == TIMER_RESUME_REACQUIRE) { KillTimer(hwnd, TIMER_RESUME_REACQUIRE); @@ -773,6 +841,11 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (wParam == TIMER_HEALTH_CHECK) { + if (g_resetWatch.Active()) + { + PollReset(hwnd); // Fallback if the dedicated timer could not be established. + return 0; + } if (g_appState == AppState::Active && g_device) { HRESULT hr = g_device->GetDeviceRemovedReason(); @@ -857,12 +930,18 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } case WM_CLOSE: + if (g_appState == AppState::Resetting || g_resetWatch.Active()) + { + ShowResetNotice(L"A display reset is still being monitored. Exit is available after the helper finishes."); + return 0; + } PostQuitMessage(0); return 0; case WM_DESTROY: KillTimer(hwnd, TIMER_RESUME_REACQUIRE); KillTimer(hwnd, TIMER_HEALTH_CHECK); + KillTimer(hwnd, TIMER_RESET_MONITOR); PostQuitMessage(0); return 0; } @@ -873,7 +952,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) // Elevated path: called when launched with --reset-gpu static int RunElevatedReset() { - LogInfo(L"GPU-Switcher elevated reset started"); + LogInfo(L"GPU-Switcher v" APP_VERSION L" build " GPU_SWITCHER_BUILD_REVISION L" elevated reset started"); ResetResult res = CycleAllDisplayAdapters(); wchar_t buf[128]; swprintf_s(buf, L"GPU-Switcher elevated reset completed with result: %s (%d)", @@ -962,7 +1041,7 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) return 0; } - LogInfo(L"GPU-Switcher v" APP_VERSION L" started"); + LogInfo(L"GPU-Switcher v" APP_VERSION L" build " GPU_SWITCHER_BUILD_REVISION L" started"); g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); @@ -977,6 +1056,14 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, hInst, nullptr); + if (!hwnd) + { + LogError(L"Could not create tray window; reset monitoring is unavailable"); + CloseHandle(g_hMutex); + g_hMutex = nullptr; + return 1; + } + g_window = hwnd; Sleep(200); if (!AcquireDGpu()) @@ -1009,6 +1096,7 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) Shell_NotifyIconW(NIM_DELETE, &g_nid); ReleaseDGpu(); + if (g_resetProcess) CloseHandle(g_resetProcess); LogInfo(L"GPU-Switcher exited cleanly"); if (g_hMutex) { diff --git a/src/reset/reset_lock.cpp b/src/reset/reset_lock.cpp new file mode 100644 index 0000000..b20f270 --- /dev/null +++ b/src/reset/reset_lock.cpp @@ -0,0 +1,39 @@ +#include "reset_lock.h" + +namespace reset +{ +Lock::Lock(const wchar_t* name) +{ + handle = CreateMutexW(nullptr, FALSE, name); + if (!handle) return; + const DWORD result = WaitForSingleObject(handle, 0); + if (result == WAIT_OBJECT_0 || result == WAIT_ABANDONED) + status = LockStatus::Acquired; + else + { + status = result == WAIT_TIMEOUT ? LockStatus::Busy : LockStatus::Unavailable; + CloseHandle(handle); + handle = nullptr; + } +} + +Lock::~Lock() +{ + if (handle) + { + ReleaseMutex(handle); + CloseHandle(handle); + } +} + +bool Active(const wchar_t* name) +{ + HANDLE handle = OpenMutexW(SYNCHRONIZE, FALSE, name); + if (handle) + { + CloseHandle(handle); + return true; + } + return GetLastError() != ERROR_FILE_NOT_FOUND; +} +} diff --git a/src/reset/reset_lock.h b/src/reset/reset_lock.h new file mode 100644 index 0000000..083f6aa --- /dev/null +++ b/src/reset/reset_lock.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace reset +{ +inline constexpr wchar_t kLockName[] = L"Global\\Tiflit.GPUSwitcher.DisplayReset.v1"; +enum class LockStatus { Acquired, Busy, Unavailable }; + +class Lock +{ +public: + explicit Lock(const wchar_t* name = kLockName); + ~Lock(); + Lock(const Lock&) = delete; + Lock& operator=(const Lock&) = delete; + LockStatus Status() const { return status; } +private: + HANDLE handle = nullptr; + LockStatus status = LockStatus::Unavailable; +}; + +// A helper holds the object for its whole operation. Failure to inspect an +// existing object is treated conservatively as busy, including cross-account UAC. +bool Active(const wchar_t* name = kLockName); +} diff --git a/src/reset/reset_policy.h b/src/reset/reset_policy.h new file mode 100644 index 0000000..3d0799c --- /dev/null +++ b/src/reset/reset_policy.h @@ -0,0 +1,83 @@ +#pragma once + +#include + +namespace reset +{ +enum class WaitState { Running, Complete, Failed }; +enum class WatchEvent { None, TimedOut, WaitFailed, Completed }; + +class Watch +{ +public: + void Start(std::uint64_t now) + { + started = now; + active = true; + timedOut = false; + waitFailed = false; + } + bool Active() const { return active; } + bool TimedOut() const { return timedOut; } + WatchEvent Observe(WaitState state, std::uint64_t now) + { + if (!active) return WatchEvent::None; + if (state == WaitState::Complete) + { + active = false; + return WatchEvent::Completed; + } + if (now - started >= 60000 && !timedOut) + { + timedOut = true; + return WatchEvent::TimedOut; + } + if (state == WaitState::Failed && !waitFailed) + { + waitFailed = true; + return WatchEvent::WaitFailed; + } + return WatchEvent::None; + } +private: + std::uint64_t started = 0; + bool active = false; + bool timedOut = false; + bool waitFailed = false; +}; + +struct InitialState +{ + bool known = false; + bool disabled = false; + bool started = false; +}; + +inline bool Eligible(const InitialState& state) +{ + return state.known && !state.disabled && state.started; +} + +// Only a device successfully disabled by this operation may be re-enabled. +// Each enable/status callback resolves the current device node independently. +template +bool Recover(bool disabledByUs, Enable enable, Verify verify, Delay delay) +{ + if (!disabledByUs) return false; + for (int attempt = 0; attempt < 4; ++attempt) + { + if (attempt != 0) delay(1000); + if (enable()) break; + } + if (verify()) return true; + // A final bounded recovery pass also covers an enable request that succeeded + // but never produced a started device during the first verification window. + for (int attempt = 0; attempt < 2; ++attempt) + { + delay(1000); + enable(); + if (verify()) return true; + } + return false; +} +} diff --git a/src/util/logging.cpp b/src/util/logging.cpp index c2a2782..011a2a0 100644 --- a/src/util/logging.cpp +++ b/src/util/logging.cpp @@ -6,7 +6,7 @@ #include #include -static std::wstring GetLogPath() +std::wstring GetLogPath() { static std::wstring s_cachedPath; if (!s_cachedPath.empty()) diff --git a/src/util/logging.h b/src/util/logging.h index 163f780..8ff4ca2 100644 --- a/src/util/logging.h +++ b/src/util/logging.h @@ -3,3 +3,5 @@ void LogError(const std::wstring& msg); void LogInfo(const std::wstring& msg); + +std::wstring GetLogPath(); diff --git a/src/util/reset.cpp b/src/util/reset.cpp index 20da726..5d89042 100644 --- a/src/util/reset.cpp +++ b/src/util/reset.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include "reset/reset_lock.h" +#include "reset/reset_policy.h" #include #include @@ -19,6 +22,13 @@ struct AdapterTarget std::wstring name; std::wstring instanceId; bool disabledSuccessfully = false; + reset::InitialState initial; + CONFIGRET lastLocate = CR_SUCCESS; + CONFIGRET lastStatus = CR_SUCCESS; + CONFIGRET lastEnable = CR_SUCCESS; + ULONG status = 0; + ULONG problem = 0; + unsigned enableAttempts = 0; AdapterTarget(DEVINST i, const std::wstring& n, const std::wstring& id, bool d = false) : inst(i), name(n), instanceId(id), disabledSuccessfully(d) {} @@ -34,6 +44,9 @@ const wchar_t* ResetResultToString(ResetResult res) case ResetResult::PartialDisable: return L"PartialDisable"; case ResetResult::EnableFailed: return L"EnableFailed"; case ResetResult::VerifyFailed: return L"VerifyFailed"; + case ResetResult::AlreadyRunning: return L"AlreadyRunning"; + case ResetResult::LockFailed: return L"LockFailed"; + case ResetResult::NoEligibleAdapters: return L"NoEligibleAdapters"; default: return L"Unknown"; } } @@ -56,8 +69,59 @@ static std::wstring ConfigRetToString(CONFIGRET cr) } } +static bool ReadStatus(AdapterTarget& adapter) +{ + // Devnode handles can change while drivers are cycled. Never reuse a stale one. + adapter.lastLocate = CM_Locate_DevNodeW(&adapter.inst, adapter.instanceId.data(), CM_LOCATE_DEVNODE_NORMAL); + adapter.status = 0; + adapter.problem = 0; + if (adapter.lastLocate != CR_SUCCESS) + { + adapter.lastStatus = adapter.lastLocate; + return false; + } + adapter.lastStatus = CM_Get_DevNode_Status(&adapter.status, &adapter.problem, adapter.inst, 0); + if (adapter.lastStatus == CR_SUCCESS && !(adapter.status & DN_HAS_PROBLEM)) + adapter.problem = 0; + return adapter.lastStatus == CR_SUCCESS; +} + +static bool VerifyStarted(AdapterTarget& adapter) +{ + for (int attempt = 0; attempt < 15; ++attempt) + { + Sleep(200); + if (ReadStatus(adapter) && (adapter.status & DN_STARTED) && adapter.problem == 0) + return true; + } + return false; +} + +static void LogOutcome(const AdapterTarget& adapter, const wchar_t* phase, ULONGLONG started) +{ + LogInfo(std::wstring(phase) + L": " + adapter.instanceId + + L"; initialKnown=" + std::to_wstring(adapter.initial.known) + + L"; initialDisabled=" + std::to_wstring(adapter.initial.disabled) + + L"; initialStarted=" + std::to_wstring(adapter.initial.started) + + L"; disabledByUs=" + std::to_wstring(adapter.disabledSuccessfully) + + L"; lastLocate=" + ConfigRetToString(adapter.lastLocate) + + L"; lastStatus=" + ConfigRetToString(adapter.lastStatus) + + L"; status=" + std::to_wstring(adapter.status) + + L"; problem=" + std::to_wstring(adapter.problem) + + L"; enableAttempts=" + std::to_wstring(adapter.enableAttempts) + + L"; lastEnable=" + ConfigRetToString(adapter.lastEnable) + + L"; elapsedMs=" + std::to_wstring(GetTickCount64() - started)); +} + ResetResult CycleAllDisplayAdapters() { + reset::Lock lock; + if (lock.Status() != reset::LockStatus::Acquired) + { + LogError(L"Reset excluded: another helper owns the lock, or the lock is inaccessible"); + return lock.Status() == reset::LockStatus::Busy ? ResetResult::AlreadyRunning : ResetResult::LockFailed; + } + const ULONGLONG started = GetTickCount64(); std::vector adapters; // 1. Query required buffer size for all present devices @@ -158,119 +222,88 @@ ResetResult CycleAllDisplayAdapters() for (const auto& a : adapters) LogInfo(L" - Target: " + a.name + L" (" + a.instanceId + L")"); - // 4. Disable display adapters and track results + // Snapshot every target before changing any device. Unknown, stopped, or + // pre-disabled devices are not touched and will never be enabled by us. + unsigned eligible = 0; bool hadDisableFailure = false; - for (auto& a : adapters) + for (auto& adapter : adapters) { - LogInfo(L"Disabling: " + a.name + L" (" + a.instanceId + L")"); - CONFIGRET cr = CM_Disable_DevNode(a.inst, 0); - if (cr == CR_SUCCESS) - { - a.disabledSuccessfully = true; - LogInfo(L"Successfully disabled: " + a.name); - } - else - { + adapter.initial.known = ReadStatus(adapter); + adapter.initial.disabled = adapter.initial.known && adapter.problem == CM_PROB_DISABLED; + adapter.initial.started = adapter.initial.known && + (adapter.status & DN_STARTED) && adapter.problem == 0; + if (reset::Eligible(adapter.initial)) + ++eligible; + else if (!adapter.initial.known || !adapter.initial.disabled) hadDisableFailure = true; - LogError(L"Failed to disable " + a.name + L" — " + ConfigRetToString(cr)); - } + LogOutcome(adapter, L"Initial state", started); } + if (!eligible) + return ResetResult::NoEligibleAdapters; - Sleep(2000); // Allow driver unload - - // 5. Re-enable display adapters with retry logic - bool hadEnableFailure = false; - LogInfo(L"Re-enabling display adapters..."); - for (auto& a : adapters) + unsigned changed = 0; + for (auto& adapter : adapters) { - if (!a.disabledSuccessfully) + if (!reset::Eligible(adapter.initial)) { - LogInfo(L"Skipping re-enable for " + a.name + L" (was not disabled)"); + LogOutcome(adapter, L"Skipped initial state", started); continue; } - - LogInfo(L"Re-enabling: " + a.name + L" (" + a.instanceId + L")"); - CONFIGRET cr = CM_Enable_DevNode(a.inst, 0); - - int retries = 3; - while (cr != CR_SUCCESS && retries-- > 0) + // Refresh immediately before the request as well as before recovery. + if (!ReadStatus(adapter) || !(adapter.status & DN_STARTED) || adapter.problem != 0) { - Sleep(1000); - LogInfo(L"Retrying enable for: " + a.name); - cr = CM_Enable_DevNode(a.inst, 0); + hadDisableFailure = true; + LogOutcome(adapter, L"Skipped changed/unreadable state", started); + continue; } - - if (cr == CR_SUCCESS) + const CONFIGRET cr = CM_Disable_DevNode(adapter.inst, 0); + adapter.disabledSuccessfully = cr == CR_SUCCESS; + LogInfo(L"Disable request: " + adapter.instanceId + L"; result=" + ConfigRetToString(cr)); + if (adapter.disabledSuccessfully) { - LogInfo(L"Successfully requested enable for: " + a.name); + ++changed; + ReadStatus(adapter); + LogOutcome(adapter, L"After disable request", started); } else - { - hadEnableFailure = true; - LogError(L"CRITICAL: Failed to re-enable " + a.name + L" — " + ConfigRetToString(cr)); - } + hadDisableFailure = true; } + if (!changed) + return ResetResult::PartialDisable; - // 6. Deterministic verification: poll CM_Get_DevNode_Status until DN_STARTED with problem == 0 - bool hadVerifyFailure = false; - LogInfo(L"Verifying device startup status with CM_Get_DevNode_Status..."); - for (auto& a : adapters) + Sleep(2000); + bool hadRecoveryFailure = false; + bool hadEnableFailure = false; + for (auto& adapter : adapters) { - if (!a.disabledSuccessfully) - continue; - - bool started = false; - ULONG status = 0; - ULONG problem = 0; - - // Poll for up to 3000ms (15 iterations x 200ms) for driver initialization - for (int attempt = 0; attempt < 15; ++attempt) - { - Sleep(200); - CONFIGRET cr = CM_Get_DevNode_Status(&status, &problem, a.inst, 0); - if (cr == CR_SUCCESS) + if (!adapter.disabledSuccessfully) continue; + const bool recovered = reset::Recover(adapter.disabledSuccessfully, + [&] { - if ((status & DN_STARTED) != 0 && problem == 0) - { - started = true; - break; - } - } - } - - if (started) + ++adapter.enableAttempts; + adapter.lastLocate = CM_Locate_DevNodeW(&adapter.inst, + adapter.instanceId.data(), CM_LOCATE_DEVNODE_NORMAL); + adapter.lastEnable = adapter.lastLocate == CR_SUCCESS ? + CM_Enable_DevNode(adapter.inst, 0) : adapter.lastLocate; + LogInfo(L"Enable request: " + adapter.instanceId + + L"; attempt=" + std::to_wstring(adapter.enableAttempts) + + L"; result=" + ConfigRetToString(adapter.lastEnable)); + return adapter.lastEnable == CR_SUCCESS; + }, + [&] { return VerifyStarted(adapter); }, + [](unsigned milliseconds) { Sleep(milliseconds); }); + LogOutcome(adapter, recovered ? L"Verified recovered" : L"UNRESOLVED recovery", started); + if (!recovered) { - wchar_t logBuf[256]; - swprintf_s(logBuf, L"Verified %s started successfully (Status: 0x%08X, Problem: %u)", - a.name.c_str(), status, problem); - LogInfo(logBuf); - } - else - { - hadVerifyFailure = true; - wchar_t errBuf[256]; - swprintf_s(errBuf, L"CRITICAL: Verification failed for %s (Status: 0x%08X, Problem: %u)", - a.name.c_str(), status, problem); - LogError(errBuf); + hadRecoveryFailure = true; + if (adapter.lastEnable != CR_SUCCESS) hadEnableFailure = true; } } - - if (hadEnableFailure) + if (hadRecoveryFailure) { - LogError(L"Adapter cycle completed with errors: one or more adapters failed to re-enable"); - return ResetResult::EnableFailed; + LogError(L"Reset incomplete: one or more devices remain unresolved; see per-device outcomes"); + return hadEnableFailure ? ResetResult::EnableFailed : ResetResult::VerifyFailed; } - if (hadVerifyFailure) - { - LogError(L"Adapter cycle completed with errors: one or more adapters failed verification"); - return ResetResult::VerifyFailed; - } - if (hadDisableFailure) - { - LogError(L"Adapter cycle completed with warning: one or more adapters could not be disabled; only successfully disabled adapters were cycled and verified"); - return ResetResult::PartialDisable; - } - - LogInfo(L"Adapter cycle completed successfully: all display adapters restarted and verified"); - return ResetResult::Success; + LogInfo(L"Reset finished; elapsedMs=" + std::to_wstring(GetTickCount64() - started)); + return hadDisableFailure ? ResetResult::PartialDisable : ResetResult::Success; } diff --git a/src/util/reset.h b/src/util/reset.h index 76f2510..d4ba4d3 100644 --- a/src/util/reset.h +++ b/src/util/reset.h @@ -7,7 +7,10 @@ enum class ResetResult : int NoAdaptersFound = 2, PartialDisable = 3, EnableFailed = 4, - VerifyFailed = 5 + VerifyFailed = 5, + AlreadyRunning = 6, + LockFailed = 7, + NoEligibleAdapters = 8 }; // Returns a human-readable string representation of a ResetResult code. @@ -17,5 +20,7 @@ const wchar_t* ResetResultToString(ResetResult res); // skipping known virtual and software display devices. // Must be called from an elevated process. // Verifies via CM_Get_DevNode_Status that successfully cycled adapters are started with no problem code. -// Blocks until all target adapters are processed and verified. +// Preserves initially disabled/stopped/unreadable adapters and recovers only +// devices successfully disabled by this operation. A global lock excludes +// competing helpers. Blocks until bounded recovery attempts finish. ResetResult CycleAllDisplayAdapters(); diff --git a/tests/reset_lock_tests.cpp b/tests/reset_lock_tests.cpp new file mode 100644 index 0000000..54d4362 --- /dev/null +++ b/tests/reset_lock_tests.cpp @@ -0,0 +1,66 @@ +#include "reset/reset_lock.h" +#include +#include +#include + +static void Require(bool condition, const char* message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +static DWORD ChildResult(const std::wstring& name) +{ + wchar_t executable[32768] = {}; + const DWORD length = GetModuleFileNameW(nullptr, executable, ARRAYSIZE(executable)); + Require(length && length < ARRAYSIZE(executable), "resolve test executable"); + std::wstring command = L"\"" + std::wstring(executable) + L"\" --try-lock \"" + name + L"\""; + STARTUPINFOW startup = {}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process = {}; + Require(CreateProcessW(executable, command.data(), nullptr, nullptr, FALSE, 0, + nullptr, nullptr, &startup, &process) != FALSE, "launch competing process"); + CloseHandle(process.hThread); + const DWORD wait = WaitForSingleObject(process.hProcess, 5000); + if (wait != WAIT_OBJECT_0) + { + TerminateProcess(process.hProcess, 99); // Test child only; never used on a reset helper. + CloseHandle(process.hProcess); + Require(false, "competing test process did not finish"); + } + DWORD result = 99; + Require(GetExitCodeProcess(process.hProcess, &result) != FALSE, "read competing process result"); + CloseHandle(process.hProcess); + return result; +} + +int wmain(int argc, wchar_t** argv) +{ + if (argc == 3 && std::wstring(argv[1]) == L"--try-lock") + { + reset::Lock lock(argv[2]); + return lock.Status() == reset::LockStatus::Acquired ? 0 : + (lock.Status() == reset::LockStatus::Busy ? 42 : 43); + } + const std::wstring name = L"Global\\Tiflit.GPUSwitcher.TestReset." + std::to_wstring(GetCurrentProcessId()); + Require(!reset::Active(name.c_str()), "test lock starts absent"); + { + reset::Lock lock(name.c_str()); + Require(lock.Status() == reset::LockStatus::Acquired, "first process obtains lock"); + Require(reset::Active(name.c_str()), "running helper can be detected"); + Require(ChildResult(name) == 42, "second process cannot enter device reset"); + } + Require(!reset::Active(name.c_str()), "lock disappears on helper completion"); + Require(ChildResult(name) == 0, "subsequent helper can acquire after completion"); + + HANDLE collision = CreateEventW(nullptr, TRUE, FALSE, name.c_str()); + Require(collision != nullptr, "create wrong-type object for failure test"); + Require(reset::Active(name.c_str()), "inspection error conservatively blocks acquisition"); + Require(ChildResult(name) == 43, "lock failure cannot enter device reset"); + CloseHandle(collision); + std::cout << "Cross-process reset exclusion passed\n"; + return 0; +} diff --git a/tests/reset_policy_tests.cpp b/tests/reset_policy_tests.cpp new file mode 100644 index 0000000..66e491d --- /dev/null +++ b/tests/reset_policy_tests.cpp @@ -0,0 +1,68 @@ +#include "reset/reset_policy.h" +#include +#include + +static void Require(bool condition, const char* message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +int main() +{ + using namespace reset; + Watch watch; + Require(!watch.Active(), "initially inactive"); + watch.Start(100); + Require(watch.Observe(WaitState::Running, 60099) == WatchEvent::None, "deadline not reached"); + Require(watch.Observe(WaitState::Running, 60100) == WatchEvent::TimedOut, "timeout notification"); + Require(watch.Active(), "timeout cannot release reset exclusion"); + Require(watch.Observe(WaitState::Running, 70100) == WatchEvent::None, "timeout reported once"); + Require(watch.Observe(WaitState::Complete, 70101) == WatchEvent::Completed, "eventual completion after timeout"); + Require(!watch.Active(), "only completion releases supervision"); + watch.Start(200); + Require(!watch.TimedOut(), "new operation clears previous timeout"); + Require(watch.Observe(WaitState::Failed, 201) == WatchEvent::WaitFailed, "wait failure notification"); + Require(watch.Active(), "wait failure cannot permit acquisition or a new reset"); + Require(watch.Observe(WaitState::Failed, 202) == WatchEvent::None, "wait failure notification is bounded"); + Require(watch.Observe(WaitState::Complete, 60300) == WatchEvent::Completed, + "confirmed completion wins over an overdue deadline"); + + Require(Eligible({ true, false, true }), "known started device eligible"); + Require(!Eligible({ true, true, false }), "pre-disabled adapter excluded"); + Require(!Eligible({ false, false, false }), "unreadable initial state excluded"); + Require(!Eligible({ true, false, false }), "initially stopped device excluded"); + + int enables = 0, verifies = 0, delays = 0; + const auto delay = [&](unsigned ms) { Require(ms == 1000, "bounded retry delay"); ++delays; }; + Require(!Recover(false, [&] { ++enables; return true; }, [&] { ++verifies; return true; }, delay), + "untouched device cannot enter recovery"); + Require(enables == 0 && verifies == 0 && delays == 0, "untouched device produces no enable calls"); + + Require(Recover(true, [&] { ++enables; return true; }, [&] { ++verifies; return true; }, delay), + "ordinary enable and verification succeed"); + Require(enables == 1 && verifies == 1, "ordinary success needs one enable"); + + enables = verifies = delays = 0; + Require(Recover(true, [&] { return ++enables >= 3; }, [&] { ++verifies; return true; }, delay), + "transient enable failure recovers"); + Require(enables == 3 && verifies == 1 && delays == 2, "transient retry sequence"); + + enables = verifies = delays = 0; + Require(Recover(true, [&] { ++enables; return true; }, [&] { return ++verifies >= 2; }, delay), + "failed startup verification triggers a fresh enable recovery pass"); + Require(enables == 2 && verifies == 2, "enable success alone does not establish recovery"); + + enables = verifies = delays = 0; + Require(!Recover(true, [&] { ++enables; return false; }, [&] { ++verifies; return false; }, delay), + "permanent enable/status failure stays unresolved"); + Require(enables == 6 && verifies == 3 && delays == 5, "all retries and recovery are bounded"); + + enables = verifies = delays = 0; + Require(Recover(true, [&] { ++enables; return false; }, [&] { ++verifies; return true; }, delay), + "verified started state can establish recovery after failed request returns"); + std::cout << "Reset supervision and recovery regression scenarios passed\n"; +} From 1893b6083278b1b3857bb4b21eb6bfbee813fe6d Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:43:28 -0400 Subject: [PATCH 09/13] Gate GPU acquisition across suspend/resume and record Legion validation --- CMakeLists.txt | 3 ++ README.md | 6 +++ docs/LEGION-VALIDATION-2026-10-22.md | 66 ++++++++++++++++++++++++++++ docs/VALIDATION.md | 17 ++++++- src/gpu/power_gate.h | 38 ++++++++++++++++ src/main.cpp | 46 +++++++++++++++---- tests/power_gate_tests.cpp | 50 +++++++++++++++++++++ 7 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 docs/LEGION-VALIDATION-2026-10-22.md create mode 100644 src/gpu/power_gate.h create mode 100644 tests/power_gate_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 48db556..1218389 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,9 @@ if(BUILD_TESTING) add_executable(acquisition_tests tests/acquisition_tests.cpp) target_include_directories(acquisition_tests PRIVATE src) add_test(NAME acquisition_regressions COMMAND acquisition_tests) + add_executable(power_gate_tests tests/power_gate_tests.cpp) + target_include_directories(power_gate_tests PRIVATE src) + add_test(NAME power_transition_regressions COMMAND power_gate_tests) add_executable(reset_policy_tests tests/reset_policy_tests.cpp) target_include_directories(reset_policy_tests PRIVATE src) add_test(NAME reset_policy_regressions COMMAND reset_policy_tests) diff --git a/README.md b/README.md index 9bf5114..26500c0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ For NVIDIA Advanced Optimus laptops to trigger dynamic internal display switchin 5. Ensure **Automatic display switching** is enabled in your global display mode settings. 6. Right-click the tray icon and select **Exit**, then relaunch `GPU-Switcher.exe`. +If configuring the executable through **NVIDIA App**, set its program option +**Automatic Display Switching** to **On**, then fully exit and relaunch the +utility. The [Legion validation report](docs/LEGION-VALIDATION-2026-10-22.md) +records successful ordinary launch switching after recreating that program +profile; a display-adapter reset was not required for that first successful launch. + *After this one-time configuration, launching GPU-Switcher will consistently trigger discrete GPU pinning and Advanced Optimus display switching.* --- diff --git a/docs/LEGION-VALIDATION-2026-10-22.md b/docs/LEGION-VALIDATION-2026-10-22.md new file mode 100644 index 0000000..172b2ad --- /dev/null +++ b/docs/LEGION-VALIDATION-2026-10-22.md @@ -0,0 +1,66 @@ +# Legion validation — 2026-10-22 + +## Tested configuration and evidence + +- Lenovo Legion Slim 7 16IRH8, Intel Iris Xe integrated graphics and NVIDIA + GeForce RTX 4060 Laptop GPU. +- Executable identified by the supplied logs as version 1.2.0, build + `e1470674e237` (full commit `e1470674e2375df3bc483c99c8b5d813f4ca1c7e`). +- Windows build, graphics driver versions, power source, and external display + connections were not recorded in the report; do not infer them. +- Evidence: user reports and successive rolling-log snapshots named + `gpu_switcher.log`, `gpu_switcher (2).log`, `gpu_switcher (3).log`, and + `gpu_switcher(1).log` through `gpu_switcher(4).log` supplied in the development + conversation. Raw logs are not included here. Times below are copied from the + logs without timezone conversion. + +The logs establish D3D11 acquisition, lifecycle transitions, and Configuration +Manager outcomes. Panel routing and observed GPU activity are user observations; +the application does not independently measure the panel MUX route. + +## Results + +| Scenario | Evidence and result | +| --- | --- | +| Adapter selection | NVIDIA selected; Intel 128 MB adapter excluded from discrete candidates. | +| Duplicate launch | Existing tray instance notified; user saw its status notification. | +| Ordinary exit | Clean exit logged; user observed NVIDIA become inactive. | +| Sleep/resume | Device released and reacquired; tray survived. | +| Cancel UAC | Acquisition recovered after cancellation; panel routing stayed unchanged. | +| Accepted reset, 12:19 | Both adapters enabled on first requests and verified started with problem 0; helper elapsed 7171 ms, then tray relaunched. | +| Accepted reset, 12:33 | Both adapters enabled on first requests and verified started with problem 0; helper elapsed 6875 ms, then tray relaunched. User observed NVIDIA panel routing after relaunch and Intel routing after Exit. | +| Final launch/resume/exit | 12:38:17 process start; 12:38:25 device active; 12:38:31 suspend release; 12:38:42 device active after resume; 12:38:59 clean exit. User reported the complete sequence working. | + +## NVIDIA profile clarification + +Initially, the new executable activated the NVIDIA GPU without switching the +panel. The user removed and recreated its NVIDIA App program configuration and +set **Automatic Display Switching** to **On**. + +The user subsequently clarified that the first ordinary launch after this change +already switched the panel successfully, before the accepted reset test. The +adapter reset must therefore **not** be documented as a prerequisite for applying +the profile. The evidence points to profile configuration as the explanation for +the initially absent switching; it does not establish the driver's internal cause +or a universal remedy for other systems. + +## Additional issue found in the logs + +In the earlier resume at 12:32:57–12:33:00, a periodic scan acquired the device +before resume notifications were processed. Those notifications released it, +and the resume timer acquired it again. This unnecessary churn did not recur +in the final 12:38 test, but the observed ordering motivates a power-transition +guard: ordinary acquisition is blocked from suspend notification until the +resume delay completes. Duplicate resume notifications share one delay. + +That guard is a **later change**, not part of the hardware-validated build above. +Its automated regressions do not replace another Legion sleep/resume check. + +## Scope and remaining validation + +These results cover successful paths on one laptop and the tested profile. They +do not establish universal compatibility or failure recovery. Timeout UI, +concurrent production reset helpers, cross-account/cross-session elevation, +pre-disabled adapter preservation, permanent enable failure, and real multi-GPU +fallback were not manually exercised. Relevant policy and lock regression tests +run in CI without touching physical devices. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index da686df..c083661 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -3,7 +3,7 @@ ## Automated checks The Windows build workflow compiles the application and runs acquisition, reset -policy, and cross-process reset-lock regressions with CTest. The tests use the same ordering and replacement helper +policy, power-transition, and cross-process reset-lock regressions with CTest. The tests use the same ordering and replacement helper as the application, with simulated resources and creation failures. ```powershell @@ -12,13 +12,15 @@ cmake --build build --config Release ctest --test-dir build -C Release --output-on-failure ``` -The acquisition and reset-policy tests are independent of Windows headers: +The acquisition, reset-policy, and power-transition tests are independent of Windows headers: ```sh g++ -std=c++17 -Wall -Wextra -Werror -pedantic -Isrc tests/acquisition_tests.cpp -o acquisition_tests ./acquisition_tests g++ -std=c++17 -Wall -Wextra -Werror -pedantic -Isrc tests/reset_policy_tests.cpp -o reset_policy_tests ./reset_policy_tests +g++ -std=c++17 -Wall -Wextra -Werror -pedantic -Isrc tests/power_gate_tests.cpp -o power_gate_tests +./power_gate_tests ``` Coverage includes preferred/current/score ordering, equal-score ties, successful @@ -41,6 +43,17 @@ when the mutex name is occupied by a different object type. No test calls the physical device-reset APIs. Cross-account UAC and cross-session behavior still require manual validation. +Power-transition tests cover acquisition exclusion while suspended, the resume +delay, duplicate resume notifications, stale/early timers, another suspend during +a pending wake, and rearming a resume timer cancelled by reset. The gate is used +by the actual acquisition path; the tests do not simulate Windows message delivery. + +## Recorded hardware baseline + +See [Legion results for build e147067](LEGION-VALIDATION-2026-10-22.md). Later +power-transition changes require another sleep/resume check; the baseline must +not be attributed to those later commits. + ## Legion hardware checks Use the Windows CI artifact for the exact commit under test. Record the commit, diff --git a/src/gpu/power_gate.h b/src/gpu/power_gate.h new file mode 100644 index 0000000..87f3915 --- /dev/null +++ b/src/gpu/power_gate.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace gpu +{ +// Power transitions are independent of device/reset state. A health scan must +// not interpret the intentionally released device as a recovery opportunity. +class PowerGate +{ +public: + void Suspend() { phase = Phase::Suspended; } + bool Resume(std::uint64_t now) + { + if (phase == Phase::ResumeDelay) return false; + phase = Phase::ResumeDelay; + resumedAt = now; + return true; + } + bool CanAcquire() const { return phase == Phase::Ready; } + bool AwaitingResume() const { return phase == Phase::ResumeDelay; } + unsigned RemainingDelay(std::uint64_t now) const + { + const auto elapsed = now - resumedAt; + return elapsed >= 1500 ? 1 : static_cast(1500 - elapsed); + } + bool CompleteResume(std::uint64_t now) + { + if (!AwaitingResume() || now - resumedAt < 1500) return false; + phase = Phase::Ready; + return true; + } +private: + enum class Phase { Ready, Suspended, ResumeDelay }; + Phase phase = Phase::Ready; + std::uint64_t resumedAt = 0; +}; +} diff --git a/src/main.cpp b/src/main.cpp index ef11f0c..f9f285f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ #include "resource.h" #include "gpu/acquisition.h" +#include "gpu/power_gate.h" #include "reset/reset_lock.h" #include "reset/reset_policy.h" #include "build_info.h" @@ -81,6 +82,7 @@ static HANDLE g_hMutex = nullptr; static HWND g_window = nullptr; static HANDLE g_resetProcess = nullptr; static reset::Watch g_resetWatch; +static gpu::PowerGate g_powerGate; static ComPtr g_device; static ComPtr g_context; static NOTIFYICONDATAW g_nid = {}; @@ -236,6 +238,15 @@ static bool AcquireDGpu() // All acquisition paths must leave devices alone while the helper resets them. if (g_appState == AppState::Resetting || g_resetWatch.Active()) return false; + if (!g_powerGate.CanAcquire()) + { + // Reset start can cancel a pending resume timer. Rearm the remaining + // delay on its cancellation/failure path instead of acquiring early. + if (g_powerGate.AwaitingResume()) + SetTimer(g_window, TIMER_RESUME_REACQUIRE, + g_powerGate.RemainingDelay(GetTickCount64()), nullptr); + return false; + } if (reset::Active()) { ReleaseDGpu(); @@ -758,19 +769,27 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) case WM_POWERBROADCAST: if (wParam == PBT_APMSUSPEND) { + g_powerGate.Suspend(); + KillTimer(hwnd, TIMER_RESUME_REACQUIRE); LogInfo(L"System entering suspend — releasing dGPU"); ReleaseDGpu(); - UpdateTrayStatus(L"GPU-Switcher: Suspended"); + if (g_appState == AppState::Resetting) + UpdateTrayStatus(); + else + UpdateTrayStatus(L"GPU-Switcher: Suspended"); } else if (wParam == PBT_APMRESUMEAUTOMATIC || wParam == PBT_APMRESUMESUSPEND) { - // The reset completion/cancellation path owns recovery until the helper exits. - if (g_appState == AppState::Resetting) + // Windows can deliver both notifications for the same wake. Keep + // one delay, and update power state even while a reset is active. + if (!g_powerGate.Resume(GetTickCount64())) return TRUE; - LogInfo(L"System resumed — scheduling dGPU re-acquisition"); ReleaseDGpu(); - UpdateTrayStatus(L"GPU-Switcher: Resuming…"); + if (g_appState == AppState::Resetting) + UpdateTrayStatus(); + else + UpdateTrayStatus(L"GPU-Switcher: Resuming…"); SetTimer(hwnd, TIMER_RESUME_REACQUIRE, 1500, nullptr); } return TRUE; @@ -779,7 +798,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) // 0x0007 = DBT_DEVNODES_CHANGED if (wParam == 0x0007) { - if (g_appState != AppState::Resetting) + if (g_appState != AppState::Resetting && g_powerGate.CanAcquire()) { LogInfo(L"Hardware devnodes changed — verifying GPU status"); if (g_appState == AppState::Active && g_device) @@ -827,9 +846,18 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } if (wParam == TIMER_RESUME_REACQUIRE) { + // Ignore stale/early timer messages, including timers from a + // previous wake or timers already queued when suspend began. + if (!g_powerGate.CompleteResume(GetTickCount64())) + { + KillTimer(hwnd, TIMER_RESUME_REACQUIRE); + if (g_powerGate.AwaitingResume()) + SetTimer(hwnd, TIMER_RESUME_REACQUIRE, + g_powerGate.RemainingDelay(GetTickCount64()), nullptr); + return 0; + } KillTimer(hwnd, TIMER_RESUME_REACQUIRE); - // KillTimer does not remove a WM_TIMER message that is already queued. - if (g_appState == AppState::Resetting) + if (g_appState == AppState::Resetting || g_resetWatch.Active()) return 0; LogInfo(L"Power resume timer fired — re-acquiring dGPU"); @@ -846,6 +874,8 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) PollReset(hwnd); // Fallback if the dedicated timer could not be established. return 0; } + if (!g_powerGate.CanAcquire()) + return 0; if (g_appState == AppState::Active && g_device) { HRESULT hr = g_device->GetDeviceRemovedReason(); diff --git a/tests/power_gate_tests.cpp b/tests/power_gate_tests.cpp new file mode 100644 index 0000000..e9a10c7 --- /dev/null +++ b/tests/power_gate_tests.cpp @@ -0,0 +1,50 @@ +#include "gpu/power_gate.h" +#include +#include + +static void Require(bool condition, const char* message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +int main() +{ + gpu::PowerGate gate; + Require(gate.CanAcquire(), "ordinary startup permits acquisition"); + gate.Suspend(); + Require(!gate.CanAcquire(), "health scan after suspend cannot acquire"); + Require(!gate.CompleteResume(100000), "stale resume timer cannot end suspend"); + Require(gate.Resume(100000), "resume starts one delay"); + Require(!gate.CanAcquire(), "health/devnode recovery stays blocked during delay"); + Require(gate.RemainingDelay(100500) == 1000, "rearming retains original deadline"); + Require(!gate.Resume(101000), "second Windows resume notification is coalesced"); + Require(!gate.CompleteResume(101499), "early queued timer cannot acquire"); + Require(gate.CompleteResume(101500), "delay completes at original deadline"); + Require(gate.CanAcquire(), "timer completion permits recovery"); + Require(!gate.CompleteResume(101501), "duplicate timer cannot complete twice"); + + gate.Suspend(); + gate.Resume(200000); + gate.Suspend(); + Require(!gate.CompleteResume(202000) && !gate.CanAcquire(), + "second suspend invalidates a queued resume timer"); + gate.Resume(203000); + Require(gate.CompleteResume(204500), "next real resume can recover"); + + gate.Suspend(); + gate.Resume(300000); + // Reset may cancel the UI timer. A cancellation/failure recovery attempt + // must rearm it, not bypass the power gate or restart the delay. + Require(!gate.CanAcquire() && gate.AwaitingResume(), "cancelled timer still blocks acquisition"); + Require(gate.RemainingDelay(302000) == 1, "overdue timer can be rearmed promptly"); + Require(gate.CompleteResume(302001), "rearmed timer unblocks recovery"); + + gpu::PowerGate missingSuspend; + Require(missingSuspend.Resume(5000) && !missingSuspend.CanAcquire(), + "resume without a preceding suspend notification still delays acquisition"); + std::cout << "Power transition regression scenarios passed\n"; +} From dfd468a1d443e4635ed5c2fb328237f37749d632 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:53:41 -0400 Subject: [PATCH 10/13] Record successful power-gate retest and unresolved LockApp GPU activity --- README.md | 1 + docs/LEGION-VALIDATION-2026-10-22.md | 47 ++++++++++++++++++++++++++-- docs/VALIDATION.md | 6 ++-- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 26500c0..33f8a55 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Run the acquisition regression tests with `ctest --test-dir build -C Release --o - **Diagnostic Logs**: Use **View Log** in the tray menu. Startup records the version and build commit (or `unknown` when built without Git metadata). A rolling log file is written to `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` (capped at 16 KB with automatic rotation). Check this file if a device fails to acquire or to inspect detected adapter scores. - **Display Stuttering**: If your laptop panel stutters after switching display modes, use **Restart Display Adapters** from the tray menu to reload the graphics driver stack without rebooting Windows. +- **NVIDIA Still Active After Exit**: Returning the panel to Intel does not ensure the NVIDIA GPU becomes inactive. Other programs can retain GPU contexts; LockApp was reported in the Legion test. GPU-Switcher releases its own device but does not migrate other programs or automatically reset adapters on exit. See the [validation report](docs/LEGION-VALIDATION-2026-10-22.md). - **Display Not Switching**: Ensure you have configured the program profile in Windows Graphics Settings and NVIDIA Control Panel as described in the [One-Time GPU Preference Setup](#one-time-gpu-preference-setup). --- diff --git a/docs/LEGION-VALIDATION-2026-10-22.md b/docs/LEGION-VALIDATION-2026-10-22.md index 172b2ad..46d7c9c 100644 --- a/docs/LEGION-VALIDATION-2026-10-22.md +++ b/docs/LEGION-VALIDATION-2026-10-22.md @@ -53,8 +53,51 @@ in the final 12:38 test, but the observed ordering motivates a power-transition guard: ordinary acquisition is blocked from suspend notification until the resume delay completes. Duplicate resume notifications share one delay. -That guard is a **later change**, not part of the hardware-validated build above. -Its automated regressions do not replace another Legion sleep/resume check. +That guard is a **later change**, not part of the e147067 baseline above. +The subsequent 1893b60 hardware check is recorded below. + +## Power-gate retest: build 1893b6083278 + +The user tested the new artifact without performing a reset afterward. The +additional snapshot `gpu_switcher(5).log` identifies the executable as +`1893b6083278` (full commit `1893b6083278b1b3857bb4b21eb6bfbee813fe6d`). + +| Time | Log evidence | +| --- | --- | +| 12:49:37 | New build started. | +| 12:49:45 | NVIDIA D3D11 device active. | +| 12:50:21 | Suspend notification; device released. | +| 12:50:31 | One resume scheduling entry. | +| 12:50:32 | One delayed reacquisition; device active again. | +| 12:50:50 | Release transition and clean exit. | + +The user reported successful launch, sleep/resume, and panel return to Intel on +Exit. There is no periodic-scan acquisition before the resume timer in this +cycle. This validates that successful path on this laptop, not every possible +Windows event ordering. + +## Unresolved NVIDIA activity attributed to LockApp + +The user observed `lockapp.exe` using the NVIDIA GPU after sleep/resume and Exit, +even though the panel returned to Intel. This happened both before and after the +power-gate change. The pre-update reset at 12:41 succeeded in 6953 ms, with both +adapters verified after their first enable requests. The user reported that +resetting then exiting cleared NVIDIA activity. No reset was performed after the +1893b60 test, so the outstanding LockApp state was preserved for investigation. + +The application log does not enumerate foreign GPU clients or measure hardware +power state. It therefore cannot establish that LockApp is the sole reason for +residual GPU activity, how its adapter selection arose, or whether GPU-Switcher +or the Windows/driver transition caused that selection. A display reset affects +all clients; its clearing the condition does not prove a leaked GPU-Switcher +context. Full dGPU inactivity after Exit must remain **unverified/unresolved** +when another client is reported active. + +Next controlled comparison: after a normal Windows restart, observe LockApp and +NVIDIA activity through a lock/unlock or sleep/resume cycle without first running +GPU-Switcher. Compare against the same sequence with GPU-Switcher. Keep the +working application profile unchanged. Do not add automatic adapter resets or +terminate LockApp as part of ordinary Exit on the basis of this evidence. ## Scope and remaining validation diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index c083661..b3d01bf 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -50,9 +50,9 @@ by the actual acquisition path; the tests do not simulate Windows message delive ## Recorded hardware baseline -See [Legion results for build e147067](LEGION-VALIDATION-2026-10-22.md). Later -power-transition changes require another sleep/resume check; the baseline must -not be attributed to those later commits. +See [Legion results for build e147067](LEGION-VALIDATION-2026-10-22.md). The report separately records the successful 1893b60 power-gate retest and an +unresolved report of LockApp retaining NVIDIA activity after Exit. Panel return +to Intel and complete dGPU inactivity are distinct results. ## Legion hardware checks From 8fe69f0d61d280e8e2ffe7b9f12f32a496172b38 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:07:25 -0400 Subject: [PATCH 11/13] Restore clean exit without relaunch after verified display reset --- README.md | 4 +-- docs/LEGION-VALIDATION-2026-10-22.md | 48 +++++++++++++++------------- docs/VALIDATION.md | 11 ++++--- src/main.cpp | 27 +++------------- 4 files changed, 38 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 33f8a55..6954443 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ profile; a display-adapter reset was not required for that first successful laun - **Left‑click**: Displays a status balloon with the active GPU name and health state. - **Right‑click Context Menu**: - **Start with Windows**: Toggles a startup entry in `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. - - **Restart Display Adapters**: Safely cycles physical display adapters via elevated helper to fix rendering stutters, then automatically restarts the utility (requires UAC confirmation). + - **Restart Display Adapters**: Cycles physical display adapters via an elevated helper to clear existing GPU clients or recover from rendering stutters. On verified success, GPU-Switcher exits without relaunching so it does not keep the dGPU active (requires UAC confirmation). Incomplete resets keep the tray open with a warning. - **View Log**: Opens the current user's diagnostic log. - **Exit**: Cleanly releases the DirectX context and terminates the program; disabled while a reset is being monitored. @@ -150,7 +150,7 @@ Run the acquisition regression tests with `ctest --test-dir build -C Release --o - **Diagnostic Logs**: Use **View Log** in the tray menu. Startup records the version and build commit (or `unknown` when built without Git metadata). A rolling log file is written to `%LOCALAPPDATA%\GPU-Switcher\gpu_switcher.log` (capped at 16 KB with automatic rotation). Check this file if a device fails to acquire or to inspect detected adapter scores. - **Display Stuttering**: If your laptop panel stutters after switching display modes, use **Restart Display Adapters** from the tray menu to reload the graphics driver stack without rebooting Windows. -- **NVIDIA Still Active After Exit**: Returning the panel to Intel does not ensure the NVIDIA GPU becomes inactive. Other programs can retain GPU contexts; LockApp was reported in the Legion test. GPU-Switcher releases its own device but does not migrate other programs or automatically reset adapters on exit. See the [validation report](docs/LEGION-VALIDATION-2026-10-22.md). +- **NVIDIA Still Active After Exit**: Returning the panel to Intel does not ensure the NVIDIA GPU becomes inactive. Other programs can retain GPU contexts; LockApp was reported in the Legion test. Ordinary Exit releases GPU-Switcher's own device. To clear other GPU clients, explicitly choose **Restart Display Adapters**; on verified success the utility exits without relaunching. Other programs may subsequently acquire the GPU again. See the [validation report](docs/LEGION-VALIDATION-2026-10-22.md). - **Display Not Switching**: Ensure you have configured the program profile in Windows Graphics Settings and NVIDIA Control Panel as described in the [One-Time GPU Preference Setup](#one-time-gpu-preference-setup). --- diff --git a/docs/LEGION-VALIDATION-2026-10-22.md b/docs/LEGION-VALIDATION-2026-10-22.md index 46d7c9c..002a6f1 100644 --- a/docs/LEGION-VALIDATION-2026-10-22.md +++ b/docs/LEGION-VALIDATION-2026-10-22.md @@ -76,28 +76,32 @@ Exit. There is no periodic-scan acquisition before the resume timer in this cycle. This validates that successful path on this laptop, not every possible Windows event ordering. -## Unresolved NVIDIA activity attributed to LockApp - -The user observed `lockapp.exe` using the NVIDIA GPU after sleep/resume and Exit, -even though the panel returned to Intel. This happened both before and after the -power-gate change. The pre-update reset at 12:41 succeeded in 6953 ms, with both -adapters verified after their first enable requests. The user reported that -resetting then exiting cleared NVIDIA activity. No reset was performed after the -1893b60 test, so the outstanding LockApp state was preserved for investigation. - -The application log does not enumerate foreign GPU clients or measure hardware -power state. It therefore cannot establish that LockApp is the sole reason for -residual GPU activity, how its adapter selection arose, or whether GPU-Switcher -or the Windows/driver transition caused that selection. A display reset affects -all clients; its clearing the condition does not prove a leaked GPU-Switcher -context. Full dGPU inactivity after Exit must remain **unverified/unresolved** -when another client is reported active. - -Next controlled comparison: after a normal Windows restart, observe LockApp and -NVIDIA activity through a lock/unlock or sleep/resume cycle without first running -GPU-Switcher. Compare against the same sequence with GPU-Switcher. Keep the -working application profile unchanged. Do not add automatic adapter resets or -terminate LockApp as part of ordinary Exit on the basis of this evidence. +## LockApp and intended reset semantics — user clarification + +The user observed `lockapp.exe` using NVIDIA after sleep/resume and ordinary +Exit, while the panel returned to Intel. This happened on both tested builds. +The pre-update reset at 12:41 succeeded in 6953 ms, with both adapters verified +after their first enable requests. Resetting then exiting cleared NVIDIA activity +in the user's test. + +The user clarified that this is an expected Windows client behavior when NVIDIA +has driven the display, unrelated to a GPU-Switcher defect. Clearing such clients +was the original purpose of the explicit **Restart Display Adapters** command. +Ordinary Exit releases only GPU-Switcher's own context. No further independent +LockApp investigation is required for this task. + +The intended reset sequence is: release GPU-Switcher's device, run the elevated +adapter cycle, verify recovery, then exit GPU-Switcher **without relaunching**. +The earlier tested builds instead relaunched automatically after successful +reset; those historical observations above remain accurate, but they do not +match the original intended behavior. Automatic relaunch entered the repository +in commit `65f60ab` (before the current development session), and was carried into +this draft. The subsequent correction removes it on verified success. + +Partial or failed resets retain supervision/recovery and a visible warning; +UAC cancellation retains the previous reacquisition behavior. The corrected +successful reset-to-exit path requires a fresh manual test. Clearing existing +contexts cannot prevent another application from acquiring the GPU later. ## Scope and remaining validation diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index b3d01bf..71b1a45 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -51,7 +51,7 @@ by the actual acquisition path; the tests do not simulate Windows message delive ## Recorded hardware baseline See [Legion results for build e147067](LEGION-VALIDATION-2026-10-22.md). The report separately records the successful 1893b60 power-gate retest and an -unresolved report of LockApp retaining NVIDIA activity after Exit. Panel return +expected report of LockApp retaining NVIDIA activity after ordinary Exit. Panel return to Intel and complete dGPU inactivity are distinct results. ## Legion hardware checks @@ -73,10 +73,11 @@ Use **View Log** and save the log after each independent test from 4. **Cancel reset:** select Restart Display Adapters and cancel UAC. Confirm the utility reacquires its device and remains usable. 5. **Display reset:** with work saved and graphics workloads closed, request a - reset. Success should verify every successfully cycled target and relaunch - the utility. Partial/failure status should keep the tray instance alive and - report the incomplete reset. Save the log; do not infer success from the icon - alone. + reset. Success should verify every successfully cycled target and exit the + utility without launching a replacement. Confirm the icon and process disappear, + then check whether the NVIDIA GPU becomes inactive. Partial/failure status + should keep the tray instance alive and report the incomplete reset. Save the + log; do not infer GPU inactivity from panel routing alone. 6. **Resume followed by reset:** verify from the log that a resume timer does not create a D3D11 device while the helper is resetting adapters. A timer already queued when reset starts must also be ignored. diff --git a/src/main.cpp b/src/main.cpp index f9f285f..bc0ce04 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -708,29 +708,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) if (res == ResetResult::Success) { - LogInfo(L"Elevated reset completed successfully — re-launching GPU-Switcher and exiting"); - Shell_NotifyIconW(NIM_DELETE, &g_nid); - - if (g_hMutex) - { - CloseHandle(g_hMutex); - g_hMutex = nullptr; - } - - wchar_t exePath[MAX_PATH] = {}; - if (GetModuleFileNameW(nullptr, exePath, MAX_PATH) > 0) - { - HINSTANCE hInstApp = ShellExecuteW(nullptr, L"open", exePath, nullptr, nullptr, SW_SHOWNORMAL); - if (reinterpret_cast(hInstApp) <= 32) - { - LogError(L"Failed to re-launch GPU-Switcher after reset"); - } - else - { - LogInfo(L"Successfully re-launched GPU-Switcher instance"); - } - } - + // A reset is an explicit request to clear GPU clients and leave the + // dGPU available to sleep. Normal shutdown below releases our device + // and mutex; launching a replacement process would pin it again. + LogInfo(L"Elevated reset completed successfully — exiting GPU-Switcher without relaunch"); PostQuitMessage(0); } else From 8f855d34999ef98da85aa0d3885cf9f5779f45d1 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:16:00 -0400 Subject: [PATCH 12/13] docs: record successful Legion reset-to-exit validation --- docs/LEGION-VALIDATION-2026-10-22.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/LEGION-VALIDATION-2026-10-22.md b/docs/LEGION-VALIDATION-2026-10-22.md index 002a6f1..b70a80b 100644 --- a/docs/LEGION-VALIDATION-2026-10-22.md +++ b/docs/LEGION-VALIDATION-2026-10-22.md @@ -10,7 +10,7 @@ connections were not recorded in the report; do not infer them. - Evidence: user reports and successive rolling-log snapshots named `gpu_switcher.log`, `gpu_switcher (2).log`, `gpu_switcher (3).log`, and - `gpu_switcher(1).log` through `gpu_switcher(4).log` supplied in the development + `gpu_switcher(1).log` through `gpu_switcher(6).log` supplied in the development conversation. Raw logs are not included here. Times below are copied from the logs without timezone conversion. @@ -99,10 +99,29 @@ in commit `65f60ab` (before the current development session), and was carried in this draft. The subsequent correction removes it on verified success. Partial or failed resets retain supervision/recovery and a visible warning; -UAC cancellation retains the previous reacquisition behavior. The corrected -successful reset-to-exit path requires a fresh manual test. Clearing existing +UAC cancellation retains the previous reacquisition behavior. Clearing existing contexts cannot prevent another application from acquiring the GPU later. +## Reset-to-exit retest: build 8fe69f0d61d2 + +The user tested the corrected artifact and reported it working as intended. +The supplied `gpu_switcher(6).log` identifies build `8fe69f0d61d2` (full commit +`8fe69f0d61d280e8e2ffe7b9f12f32a496172b38`). + +| Time | Log evidence | +| --- | --- | +| 13:11:18–13:11:26 | Process started and acquired the NVIDIA D3D11 device. | +| 13:11:35 | Entered Resetting and requested Restart Display Adapters. | +| 13:11:37 | Elevated helper started; both adapters were initially started and enabled. | +| 13:11:41 | Intel recovered after its first enable request, with problem 0. | +| 13:11:44 | NVIDIA recovered after its first enable request, with problem 0; helper reported Success after 6547 ms. | +| 13:11:46 | Parent explicitly logged exit without relaunch, followed by clean exit. | + +No subsequent startup appears in this snapshot. Together with the user's +confirmation, this passes the corrected successful reset-to-exit scenario on +this laptop. The log establishes reset recovery and process exit; final display +routing and GPU inactivity remain hardware observations. + ## Scope and remaining validation These results cover successful paths on one laptop and the tested profile. They From 772d591597ef12d1f985c1f1a454772bdf7a6278 Mon Sep 17 00:00:00 2001 From: Tiflit <59609757+Tiflit@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:23:29 -0400 Subject: [PATCH 13/13] Handle missing resume timers and tray registration failures --- docs/VALIDATION.md | 17 ++++++++ src/main.cpp | 81 +++++++++++++++++++++++++++++--------- tests/power_gate_tests.cpp | 12 ++++++ 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 71b1a45..30677c0 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -48,6 +48,23 @@ delay, duplicate resume notifications, stale/early timers, another suspend durin a pending wake, and rearming a resume timer cancelled by reset. The gate is used by the actual acquisition path; the tests do not simulate Windows message delivery. +## Timer and tray failure handling + +The health check can complete an elapsed resume delay if the dedicated resume +timer was not delivered or could not be created. Power-transition regressions +cover this delayed fallback, reject early completion, and preserve a subsequent +suspend. Normal resume still uses the 1500 ms timer; fallback may take until the +next 30-second health check. + +Startup checks tray registration, callback-version negotiation, and creation of +the health timer before acquiring a GPU. Initialization failure logs an error, +shows an error dialog, and exits. After Explorer restarts, failed tray restoration +releases the held device and blocks acquisition until registration succeeds on a +health check. Reset supervision continues while the icon is unavailable. +These Win32 failure paths were reviewed in source; automated tests do not inject +Shell_NotifyIcon or SetTimer failures. Explorer restart and the new startup order +require a Windows smoke test on the final artifact. + ## Recorded hardware baseline See [Legion results for build e147067](LEGION-VALIDATION-2026-10-22.md). The report separately records the successful 1893b60 power-gate retest and an diff --git a/src/main.cpp b/src/main.cpp index bc0ce04..538d835 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -87,6 +87,7 @@ static ComPtr g_device; static ComPtr g_context; static NOTIFYICONDATAW g_nid = {}; static UINT g_taskbarCreatedMsg = 0; +static bool g_trayAvailable = false; static AppState g_appState = AppState::NoDiscreteGpu; static UINT g_activeVendorId = 0; @@ -233,8 +234,15 @@ struct PendingGpu ComPtr context; }; +static void ScheduleResumeTimer(HWND hwnd, unsigned delay) +{ + if (!SetTimer(hwnd, TIMER_RESUME_REACQUIRE, delay, nullptr)) + LogError(L"Could not schedule resume timer; periodic health check will complete the resume delay"); +} + static bool AcquireDGpu() { + if (!g_trayAvailable) return false; // All acquisition paths must leave devices alone while the helper resets them. if (g_appState == AppState::Resetting || g_resetWatch.Active()) return false; @@ -243,8 +251,7 @@ static bool AcquireDGpu() // Reset start can cancel a pending resume timer. Rearm the remaining // delay on its cancellation/failure path instead of acquiring early. if (g_powerGate.AwaitingResume()) - SetTimer(g_window, TIMER_RESUME_REACQUIRE, - g_powerGate.RemainingDelay(GetTickCount64()), nullptr); + ScheduleResumeTimer(g_window, g_powerGate.RemainingDelay(GetTickCount64())); return false; } if (reset::Active()) @@ -522,13 +529,34 @@ static void PollReset(HWND hwnd) } } +static bool RegisterTrayIcon() +{ + g_trayAvailable = false; + if (!Shell_NotifyIconW(NIM_ADD, &g_nid)) + { + LogError(L"Could not register tray icon"); + return false; + } + g_nid.uVersion = NOTIFYICON_VERSION_4; + if (!Shell_NotifyIconW(NIM_SETVERSION, &g_nid)) + { + LogError(L"Could not set tray callback version"); + Shell_NotifyIconW(NIM_DELETE, &g_nid); + return false; + } + g_trayAvailable = true; + return true; +} + LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg == g_taskbarCreatedMsg) { - Shell_NotifyIconW(NIM_ADD, &g_nid); - g_nid.uVersion = NOTIFYICON_VERSION_4; - Shell_NotifyIconW(NIM_SETVERSION, &g_nid); + if (!RegisterTrayIcon()) + { + ReleaseDGpu(); + LogError(L"Tray restoration failed; GPU released, retrying at the next health check"); + } UpdateTrayStatus(); return 0; } @@ -771,7 +799,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayStatus(); else UpdateTrayStatus(L"GPU-Switcher: Resuming…"); - SetTimer(hwnd, TIMER_RESUME_REACQUIRE, 1500, nullptr); + ScheduleResumeTimer(hwnd, 1500); } return TRUE; @@ -833,8 +861,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { KillTimer(hwnd, TIMER_RESUME_REACQUIRE); if (g_powerGate.AwaitingResume()) - SetTimer(hwnd, TIMER_RESUME_REACQUIRE, - g_powerGate.RemainingDelay(GetTickCount64()), nullptr); + ScheduleResumeTimer(hwnd, g_powerGate.RemainingDelay(GetTickCount64())); return 0; } KillTimer(hwnd, TIMER_RESUME_REACQUIRE); @@ -850,12 +877,22 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (wParam == TIMER_HEALTH_CHECK) { + // Explorer may have been unavailable when TaskbarCreated arrived. + // Keep reset supervision alive, but never hold a GPU invisibly. + if (!g_trayAvailable && RegisterTrayIcon()) + UpdateTrayStatus(); if (g_resetWatch.Active()) { PollReset(hwnd); // Fallback if the dedicated timer could not be established. return 0; } - if (!g_powerGate.CanAcquire()) + // This also completes the delay when SetTimer for resume failed. + if (g_powerGate.CompleteResume(GetTickCount64())) + { + KillTimer(hwnd, TIMER_RESUME_REACQUIRE); + LogInfo(L"Periodic health check completed resume delay — recovering dGPU"); + } + if (!g_powerGate.CanAcquire() || !g_trayAvailable) return 0; if (g_appState == AppState::Active && g_device) { @@ -1077,9 +1114,6 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) g_window = hwnd; Sleep(200); - if (!AcquireDGpu()) - LogError(L"Failed to acquire discrete GPU on startup"); - g_nid.cbSize = sizeof(g_nid); g_nid.hWnd = hwnd; g_nid.uID = TRAY_ID; @@ -1091,12 +1125,23 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) UpdateTrayStatus(); - Shell_NotifyIconW(NIM_ADD, &g_nid); - g_nid.uVersion = NOTIFYICON_VERSION_4; - Shell_NotifyIconW(NIM_SETVERSION, &g_nid); - - // Periodic health check every 30 seconds - SetTimer(hwnd, TIMER_HEALTH_CHECK, 30000, nullptr); + // Establish the visible control and fallback timer before holding a GPU. + const bool trayReady = RegisterTrayIcon(); + const bool healthReady = trayReady && SetTimer(hwnd, TIMER_HEALTH_CHECK, 30000, nullptr) != 0; + if (!healthReady) + { + LogError(L"Tray or health timer initialization failed; exiting without acquiring a GPU"); + Shell_NotifyIconW(NIM_DELETE, &g_nid); + DestroyWindow(hwnd); + CloseHandle(g_hMutex); + g_hMutex = nullptr; + MessageBoxW(nullptr, L"Could not initialize the tray icon or health timer. GPU-Switcher has exited. See the log for details.", + L"GPU-Switcher", MB_OK | MB_ICONERROR); + return 1; + } + if (!AcquireDGpu()) + LogError(L"Failed to acquire discrete GPU on startup"); + UpdateTrayStatus(); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0)) diff --git a/tests/power_gate_tests.cpp b/tests/power_gate_tests.cpp index e9a10c7..0f81f72 100644 --- a/tests/power_gate_tests.cpp +++ b/tests/power_gate_tests.cpp @@ -43,6 +43,18 @@ int main() Require(gate.RemainingDelay(302000) == 1, "overdue timer can be rearmed promptly"); Require(gate.CompleteResume(302001), "rearmed timer unblocks recovery"); + gpu::PowerGate timerFailure; + timerFailure.Suspend(); + timerFailure.Resume(400000); + // No resume timer is delivered. The health check uses the same deadline. + Require(!timerFailure.CompleteResume(401000) && !timerFailure.CanAcquire(), + "health fallback cannot acquire before the resume deadline"); + Require(timerFailure.CompleteResume(430000) && timerFailure.CanAcquire(), + "health fallback recovers when the resume timer was never delivered"); + Require(!timerFailure.CompleteResume(430001), "late resume timer cannot repeat recovery"); + timerFailure.Suspend(); + Require(!timerFailure.CompleteResume(460000), "health fallback cannot end a new suspend"); + gpu::PowerGate missingSuspend; Require(missingSuspend.Resume(5000) && !missingSuspend.CanAcquire(), "resume without a preceding suspend notification still delays acquisition");