diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 024c474..7291165 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,9 @@ jobs: - name: Build run: cmake --build build --config Release + - name: Test acquisition and reset recovery + run: ctest --test-dir build -C Release --output-on-failure + - name: Upload artifact uses: actions/upload-artifact@v4.6.2 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index b278435..1218389 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 @@ -47,3 +57,21 @@ target_link_libraries(GPU-Switcher PRIVATE dxgi cfgmgr32 ) + +include(CTest) +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) + 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 434bbbf..6954443 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,160 @@

- + GPU-Switcher Logo

GPU‑Switcher

-

A lightweight GPU switching utility for Windows

+

+ A lightweight, near-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 **near-zero CPU usage** and no persistent background rendering loops. -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. +- ⚡ **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 + - 🔵 **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 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 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`. --- -## 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 GPU Preference Setup -1. Run `GPU-Switcher.exe` -2. Open **NVIDIA Control Panel** -3. Go to **Manage 3D settings → Program Settings** +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: -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` +### 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**. -After this, GPU‑Switcher will consistently trigger a dGPU display switch on launch. Right-click on the tray icon to access extra options. +### 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`. +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`. ---- +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. -## Tray icon +*After this one-time configuration, launching GPU-Switcher will consistently trigger discrete GPU pinning and Advanced Optimus display switching.* -- **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**: 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. --- -## 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. **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, 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. --- ## 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. +# Build Release binary +cmake --build build --config Release +``` -**"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. +The compiled binary will be located at `build/Release/GPU-Switcher.exe` (or `build/GPU-Switcher.exe` if using Ninja). -**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. +Run the acquisition regression tests with `ctest --test-dir build -C Release --output-on-failure`. See [Validation](docs/VALIDATION.md) for coverage limits and the Legion hardware checklist. -**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**: 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. 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). --- ## 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/docs/LEGION-VALIDATION-2026-10-22.md b/docs/LEGION-VALIDATION-2026-10-22.md new file mode 100644 index 0000000..b70a80b --- /dev/null +++ b/docs/LEGION-VALIDATION-2026-10-22.md @@ -0,0 +1,132 @@ +# 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(6).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 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. + +## 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. 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 +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 new file mode 100644 index 0000000..30677c0 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,130 @@ +# 1.2 validation + +## Automated checks + +The Windows build workflow compiles the application and runs acquisition, reset +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 +cmake -B build -G "Visual Studio 17 2022" -A x64 +cmake --build build --config Release +ctest --test-dir build -C Release --output-on-failure +``` + +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 +replacement, failed preferred creation retaining the current device, subsequent +candidate attempts when no healthy device exists, all candidates failing, a +healthy device omitted from enumeration, and loss of the current device during +a preferred acquisition attempt. These tests do not exercise DXGI, driver calls, +MUX routing, suspend/resume delivery, or physical adapter resets. + +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. + +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. + +## 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 +expected report of LockApp retaining NVIDIA activity after ordinary Exit. Panel return +to Intel and complete dGPU inactivity are distinct results. + +## 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. +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 + utility. On the Legion with Intel integrated graphics and an RTX 4060, expect + the NVIDIA icon and model. Check actual panel routing separately in NVIDIA's + controls; the tray icon reports the held device. Verify readable UTF-8 log + output, including non-ASCII characters when present. +2. **Duplicate launch and exit:** a second launch should notify the existing + instance. Exit should remove the icon and release the held device. +3. **Sleep/resume:** test independently of reset. After resume, confirm device + reacquisition, icon state, and actual routing. Save the log. +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 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. + +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 + +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/app.manifest b/src/app.manifest index f0ba885..b86cf2b 100644 --- a/src/app.manifest +++ b/src/app.manifest @@ -1,7 +1,7 @@ + +namespace gpu +{ +struct CandidatePriority +{ + bool preferred; + bool current; + int score; +}; + +inline bool HigherPriority(const CandidatePriority& left, const CandidatePriority& right) +{ + const int leftTier = left.preferred ? 0 : (left.current ? 1 : 2); + const int rightTier = right.preferred ? 0 : (right.current ? 1 : 2); + return leftTier != rightTier ? leftTier < rightTier : left.score > right.score; +} + +enum class AcquisitionResult { Replaced, Retained, Failed }; + +// Creation returns an optional pending resource. Only a successful creation may +// commit it; a healthy current device remains usable throughout failed attempts. +template +AcquisitionResult AcquireInOrder(const Candidates& candidates, IsHealthy currentHealthy, + IsCurrent isCurrent, Create create, Commit commit) +{ + for (const auto& candidate : candidates) + { + if (currentHealthy() && isCurrent(candidate)) + return AcquisitionResult::Retained; + + auto pending = create(candidate); + if (pending) + { + commit(candidate, std::move(*pending)); + return AcquisitionResult::Replaced; + } + } + return currentHealthy() ? AcquisitionResult::Retained : AcquisitionResult::Failed; +} +} 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 c42022d..538d835 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,8 +4,17 @@ #include #include #include +#include +#include +#include +#include #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" #include "util/logging.h" #include "util/startup.h" #include "util/reset.h" @@ -13,7 +22,7 @@ #pragma comment(lib, "dxgi.lib") #pragma comment(lib, "d3d11.lib") -#define APP_VERSION L"1.1.0" +#define APP_VERSION L"1.2.0" extern "C" { __declspec(dllexport) DWORD NvOptimusEnablement = 1; @@ -27,23 +36,174 @@ 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; +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 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 = {}; static UINT g_taskbarCreatedMsg = 0; +static bool g_trayAvailable = false; +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 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 IsAdapterPresent(const LUID& targetLuid) +{ + ComPtr factory; + if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1), reinterpret_cast(factory.GetAddressOf())))) + return false; + + ComPtr adapter; + for (UINT i = 0; ; ++i) + { + HRESULT hr = factory->EnumAdapters1(i, adapter.ReleaseAndGetAddressOf()); + if (hr == DXGI_ERROR_NOT_FOUND) + break; + if (FAILED(hr)) + { + LogError(L"EnumAdapters1 failed during adapter presence check"); + return false; + } + 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))) + 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,103 +214,181 @@ 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) + { + SetAppState(AppState::WaitingForDiscreteGpu); + } +} + +static bool SameLuid(const LUID& left, const LUID& right) +{ + return left.LowPart == right.LowPart && left.HighPart == right.HighPart; +} + +struct PendingGpu +{ + ComPtr device; + 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() { - ReleaseDGpu(); + 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; + 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()) + ScheduleResumeTimer(g_window, g_powerGate.RemainingDelay(GetTickCount64())); + 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) + ReleaseDGpu(); ComPtr factory; - if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1), reinterpret_cast(factory.GetAddressOf())))) + HRESULT hr = CreateDXGIFactory1(__uuidof(IDXGIFactory1), + reinterpret_cast(factory.GetAddressOf())); + if (FAILED(hr)) { LogError(L"CreateDXGIFactory1 failed"); - return false; + SetAppState(currentHealthy ? AppState::Active : AppState::DeviceLost); + return currentHealthy; } - ComPtr bestAdapter; - DXGI_ADAPTER_DESC1 bestDesc = {}; - int bestScore = -1; - - ComPtr adapter; - for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) + std::vector discreteCandidates; + for (UINT i = 0; ; ++i) { - DXGI_ADAPTER_DESC1 desc; - if (SUCCEEDED(adapter->GetDesc1(&desc))) + ComPtr adapter; + hr = factory->EnumAdapters1(i, adapter.GetAddressOf()); + if (hr == DXGI_ERROR_NOT_FOUND) + break; + if (FAILED(hr)) { - 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)); + LogError(L"EnumAdapters1 failed during GPU acquisition"); + SetAppState(currentHealthy ? AppState::Active : AppState::DeviceLost); + return currentHealthy; + } + GpuCandidate info; + if (EvaluateAdapter(adapter.Get(), info)) + { 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) - { - bestScore = score; - bestDesc = desc; - bestAdapter = adapter; - } + if (info.isDiscrete) + discreteCandidates.push_back(info); } - adapter.Reset(); } - if (!bestAdapter) + if (discreteCandidates.empty()) { - LogError(L"No suitable graphics adapter found"); + if (currentHealthy) + { + LogInfo(L"No discrete candidates enumerated; retaining healthy active device"); + SetAppState(AppState::Active); + return true; + } + SetAppState(HasPreferredLuid() ? AppState::WaitingForDiscreteGpu : AppState::NoDiscreteGpu); + LogInfo(L"No discrete GPU detected; waiting for an eligible adapter"); return false; } - LogInfo(std::wstring(L"Selected target adapter: ") + bestDesc.Description); - - D3D_FEATURE_LEVEL level = {}; - D3D_FEATURE_LEVEL featureLevels[] = { - D3D_FEATURE_LEVEL_11_1, - D3D_FEATURE_LEVEL_11_0, - D3D_FEATURE_LEVEL_10_1, - D3D_FEATURE_LEVEL_10_0 + // Preserve the existing vendor/VRAM score within each tier. Equal scores + // retain enumeration order; selection-policy changes are a separate task. + auto priority = [currentHealthy](const GpuCandidate& candidate) + { + return gpu::CandidatePriority{ + HasPreferredLuid() && SameLuid(candidate.luid, g_preferredLuid), + currentHealthy && SameLuid(candidate.luid, g_activeLuid), + candidate.score }; }; + std::stable_sort(discreteCandidates.begin(), discreteCandidates.end(), + [&](const GpuCandidate& left, const GpuCandidate& right) + { + return gpu::HigherPriority(priority(left), priority(right)); + }); - HRESULT hr = D3D11CreateDevice( - bestAdapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, - nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, - featureLevels, static_cast(ARRAYSIZE(featureLevels)), - D3D11_SDK_VERSION, - g_device.GetAddressOf(), &level, g_context.GetAddressOf()); + if (!currentHealthy) + SetAppState(AppState::Acquiring); - if (FAILED(hr)) + const auto result = gpu::AcquireInOrder(discreteCandidates, + [] { return g_device && g_device->GetDeviceRemovedReason() == S_OK; }, + [](const GpuCandidate& candidate) { return SameLuid(candidate.luid, g_activeLuid); }, + [](const GpuCandidate& candidate) -> std::optional + { + PendingGpu pending; + D3D_FEATURE_LEVEL level = {}; + const D3D_FEATURE_LEVEL levels[] = { + D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; + HRESULT createHr = D3D11CreateDevice(candidate.adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, + nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, levels, static_cast(ARRAYSIZE(levels)), + D3D11_SDK_VERSION, pending.device.GetAddressOf(), &level, pending.context.GetAddressOf()); + if (FAILED(createHr)) + { + wchar_t buf[256]; + swprintf_s(buf, L"D3D11CreateDevice failed for %s: 0x%08X", + candidate.desc.Description, createHr); + LogError(buf); + return std::nullopt; + } + if (level < D3D_FEATURE_LEVEL_11_0) + LogError(L"Warning: acquired adapter feature level is below D3D_FEATURE_LEVEL_11_0"); + return std::optional{ std::move(pending) }; + }, + [](const GpuCandidate& candidate, PendingGpu&& pending) + { + // Commit only after a replacement device and context exist. + ReleaseDGpu(); + g_device = std::move(pending.device); + g_context = std::move(pending.context); + g_activeVendorId = candidate.desc.VendorId; + g_activeGpuName = candidate.desc.Description; + g_activeLuid = candidate.luid; + if (!HasPreferredLuid()) + { + g_preferredLuid = candidate.luid; + g_preferredGpuName = candidate.desc.Description; + LogInfo(std::wstring(L"Established preferred discrete GPU: ") + g_preferredGpuName); + } + LogInfo(std::wstring(L"Successfully initialized D3D11 device on: ") + g_activeGpuName); + }); + + if (result == gpu::AcquisitionResult::Failed) { - wchar_t buf[64]; - swprintf_s(buf, L"D3D11CreateDevice failed: 0x%08X", hr); - LogError(buf); + ReleaseDGpu(); + 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 = bestDesc.VendorId; - g_activeGpuName = bestDesc.Description; - g_dGpuActive = true; - - LogInfo(std::wstring(L"Successfully initialized D3D11 device on: ") + bestDesc.Description); + SetAppState(AppState::Active); + if (result == gpu::AcquisitionResult::Retained) + LogInfo(L"Retaining healthy active GPU device"); return true; } @@ -192,49 +430,133 @@ 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: + 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, 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()) + 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 -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); - CloseHandle(ctx->hProcess); - PostMessageW(ctx->hwnd, WM_RESET_DONE, 0, 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."); + } +} + +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; } @@ -249,7 +571,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 +579,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); + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); } @@ -264,20 +587,44 @@ 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) - { - swprintf_s(balloon.szInfo, L"Active discrete GPU:\n%s", g_activeGpuName.c_str()); - } - else + + switch (g_appState) { - wcsncpy_s(balloon.szInfo, L"dGPU acquisition failed — check log", _TRUNCATE); + case AppState::Active: + 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, 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()) + 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; } + Shell_NotifyIconW(NIM_MODIFY, &balloon); return 0; } @@ -291,10 +638,11 @@ 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, + 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; @@ -315,10 +663,24 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) case ID_RESET_DISPLAYS: { - if (g_resetInProgress) + 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; + } - g_resetInProgress = true; + SetAppState(AppState::Resetting); + KillTimer(hwnd, TIMER_RESUME_REACQUIRE); UpdateTrayStatus(L"GPU-Switcher: Restarting display adapters…"); LogInfo(L"Restart Display Adapters requested"); @@ -338,8 +700,9 @@ 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"); - g_resetInProgress = false; + SetAppState(AppState::DeviceLost); AcquireDGpu(); UpdateTrayStatus(); break; @@ -348,26 +711,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; } } @@ -377,53 +731,143 @@ 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) { - CloseHandle(g_hMutex); - g_hMutex = nullptr; + // 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); } - - wchar_t exePath[MAX_PATH] = {}; - if (GetModuleFileNameW(nullptr, exePath, MAX_PATH) > 0) + else { - HINSTANCE hInstApp = ShellExecuteW(nullptr, L"open", exePath, nullptr, nullptr, SW_SHOWNORMAL); - if (reinterpret_cast(hInstApp) <= 32) + wchar_t errBuf[256]; + swprintf_s(errBuf, L"Display adapter reset finished with non-success status: %s (code %u)", + ResetResultToString(res), resetExitCode); + LogError(errBuf); + + 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 Incomplete", _TRUNCATE); + + if (res == ResetResult::PartialDisable) { - LogError(L"Failed to re-launch GPU-Switcher after reset"); + wcsncpy_s(balloon.szInfo, + L"One or more display adapters could not be disabled. The reset was only partially applied.", + _TRUNCATE); } else { - LogInfo(L"Successfully re-launched GPU-Switcher instance"); + 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); } - - PostQuitMessage(0); return 0; } case WM_POWERBROADCAST: if (wParam == PBT_APMSUSPEND) { + g_powerGate.Suspend(); + KillTimer(hwnd, TIMER_RESUME_REACQUIRE); LogInfo(L"System entering suspend — releasing dGPU"); ReleaseDGpu(); - UpdateTrayStatus(); + if (g_appState == AppState::Resetting) + UpdateTrayStatus(); + else + UpdateTrayStatus(L"GPU-Switcher: Suspended"); } else if (wParam == PBT_APMRESUMEAUTOMATIC || wParam == PBT_APMRESUMESUSPEND) { + // 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…"); - SetTimer(hwnd, TIMER_RESUME_REACQUIRE, 1500, nullptr); + if (g_appState == AppState::Resetting) + UpdateTrayStatus(); + else + UpdateTrayStatus(L"GPU-Switcher: Resuming…"); + ScheduleResumeTimer(hwnd, 1500); + } + return TRUE; + + case WM_DEVICECHANGE: + // 0x0007 = DBT_DEVNODES_CHANGED + if (wParam == 0x0007) + { + if (g_appState != AppState::Resetting && g_powerGate.CanAcquire()) + { + 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); + SetAppState(AppState::DeviceLost); + AcquireDGpu(); + UpdateTrayStatus(); + } + else if (HasPreferredLuid() && (g_activeLuid.LowPart != g_preferredLuid.LowPart || g_activeLuid.HighPart != g_preferredLuid.HighPart)) + { + // Only re-acquire if the preferred discrete GPU has actually reappeared in the topology + if (IsAdapterPresent(g_preferredLuid)) + { + LogInfo(L"Preferred discrete GPU re-detected in topology — restoring preference"); + if (AcquireDGpu()) + { + UpdateTrayStatus(); + } + } + } + } + else + { + if (AcquireDGpu()) + { + LogInfo(L"Discrete GPU detected and acquired following devnodes notification"); + UpdateTrayStatus(); + } + } + } } return TRUE; case WM_TIMER: + if (wParam == TIMER_RESET_MONITOR) + { + PollReset(hwnd); + return 0; + } 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()) + ScheduleResumeTimer(hwnd, g_powerGate.RemainingDelay(GetTickCount64())); + return 0; + } KillTimer(hwnd, TIMER_RESUME_REACQUIRE); + if (g_appState == AppState::Resetting || g_resetWatch.Active()) + return 0; + LogInfo(L"Power resume timer fired — re-acquiring dGPU"); if (!AcquireDGpu()) { @@ -433,7 +877,24 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (wParam == TIMER_HEALTH_CHECK) { - if (g_device) + // 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; + } + // 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) { HRESULT hr = g_device->GetDeviceRemovedReason(); if (hr != S_OK) @@ -441,14 +902,30 @@ 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); + 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 in the topology + if (IsAdapterPresent(g_preferredLuid)) + { + LogInfo(L"Preferred discrete GPU detected in topology during periodic scan — restoring preference"); + if (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,31 +937,59 @@ 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) + + switch (g_appState) { + case AppState::Active: + 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); + 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 if (g_dGpuActive) - { - swprintf_s(balloon.szInfo, L"Already active in system tray.\nGPU: %s", g_activeGpuName.c_str()); - } - else - { - wcsncpy_s(balloon.szInfo, L"Already running (dGPU inactive)", _TRUNCATE); - } + Shell_NotifyIconW(NIM_MODIFY, &balloon); return 0; } 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; } @@ -495,10 +1000,13 @@ 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"); - CycleAllDisplayAdapters(); - LogInfo(L"GPU-Switcher elevated reset complete"); - return 0; + 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)", + ResetResultToString(res), static_cast(res)); + LogInfo(buf); + return static_cast(res); } // lpCmdLine is unused because we parse via GetCommandLineW(). @@ -581,7 +1089,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"); @@ -596,31 +1104,44 @@ 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()) - LogError(L"Failed to acquire dGPU 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); - - Shell_NotifyIconW(NIM_ADD, &g_nid); - g_nid.uVersion = NOTIFYICON_VERSION_4; - Shell_NotifyIconW(NIM_SETVERSION, &g_nid); + UpdateTrayStatus(); - // 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)) @@ -631,6 +1152,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/resource.rc b/src/resource.rc index 59c88ba..a9b9e42 100644 --- a/src/resource.rc +++ b/src/resource.rc @@ -10,8 +10,8 @@ IDI_ICON_UNKNOWN ICON "icons/ico/unknown-grey.ico" IDI_ICON_WARNING ICON "icons/ico/warning-yellow.ico" VS_VERSION_INFO VERSIONINFO -FILEVERSION 1,1,0,0 -PRODUCTVERSION 1,1,0,0 +FILEVERSION 1,2,0,0 +PRODUCTVERSION 1,2,0,0 FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -28,12 +28,12 @@ BEGIN BEGIN VALUE "CompanyName", "Tiflit" VALUE "FileDescription", "GPU-Switcher - Discrete GPU Activator" - VALUE "FileVersion", "1.1.0.0" + VALUE "FileVersion", "1.2.0.0" VALUE "InternalName", "GPU-Switcher" VALUE "LegalCopyright", "Copyright (C) 2026" VALUE "OriginalFilename", "GPU-Switcher.exe" VALUE "ProductName", "GPU-Switcher" - VALUE "ProductVersion", "1.1.0.0" + VALUE "ProductVersion", "1.2.0.0" END END BLOCK "VarFileInfo" diff --git a/src/util/logging.cpp b/src/util/logging.cpp index 4151e34..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()) @@ -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); 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 2cef844..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 @@ -9,17 +12,45 @@ #pragma comment(lib, "cfgmgr32.lib") +#ifndef DN_STARTED +#define DN_STARTED 0x00000008 +#endif + struct AdapterTarget { DEVINST inst = 0; 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) {} }; +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"; + case ResetResult::AlreadyRunning: return L"AlreadyRunning"; + case ResetResult::LockFailed: return L"LockFailed"; + case ResetResult::NoEligibleAdapters: return L"NoEligibleAdapters"; + default: return L"Unknown"; + } +} + static std::wstring ConfigRetToString(CONFIGRET cr) { switch (cr) @@ -38,8 +69,59 @@ static std::wstring ConfigRetToString(CONFIGRET cr) } } -void CycleAllDisplayAdapters() +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 @@ -51,7 +133,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 +146,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} @@ -102,7 +184,7 @@ void 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); @@ -112,7 +194,7 @@ void 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) { @@ -133,62 +215,95 @@ 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 - for (auto& a : adapters) + // 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& 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 - { - LogError(L"Failed to disable " + a.name + L" — " + ConfigRetToString(cr)); - } + 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; + LogOutcome(adapter, L"Initial state", started); } + if (!eligible) + return ResetResult::NoEligibleAdapters; - Sleep(2000); // Allow driver unload - - // 5. Re-enable display adapters with retry logic - 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 re-enabled: " + a.name); + ++changed; + ReadStatus(adapter); + LogOutcome(adapter, L"After disable request", started); } else + hadDisableFailure = true; + } + if (!changed) + return ResetResult::PartialDisable; + + Sleep(2000); + bool hadRecoveryFailure = false; + bool hadEnableFailure = false; + for (auto& adapter : adapters) + { + if (!adapter.disabledSuccessfully) continue; + const bool recovered = reset::Recover(adapter.disabledSuccessfully, + [&] + { + ++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) { - LogError(L"CRITICAL: Failed to re-enable " + a.name + L" — " + ConfigRetToString(cr)); + hadRecoveryFailure = true; + if (adapter.lastEnable != CR_SUCCESS) hadEnableFailure = true; } } - - Sleep(1500); // Allow driver re-initialization - LogInfo(L"Adapter cycle completed"); + if (hadRecoveryFailure) + { + LogError(L"Reset incomplete: one or more devices remain unresolved; see per-device outcomes"); + return hadEnableFailure ? ResetResult::EnableFailed : ResetResult::VerifyFailed; + } + 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 cc7b129..d4ba4d3 100644 --- a/src/util/reset.h +++ b/src/util/reset.h @@ -1,6 +1,26 @@ #pragma once -// Performs a full disable/enable cycle on all display adapters. +enum class ResetResult : int +{ + Success = 0, + EnumerationFailed = 1, + NoAdaptersFound = 2, + PartialDisable = 3, + EnableFailed = 4, + VerifyFailed = 5, + AlreadyRunning = 6, + LockFailed = 7, + NoEligibleAdapters = 8 +}; + +// Returns a human-readable string representation of a ResetResult code. +const wchar_t* ResetResultToString(ResetResult res); + +// 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. -// Blocks until all adapters are re-enabled. -void CycleAllDisplayAdapters(); +// Verifies via CM_Get_DevNode_Status that successfully cycled adapters are started with no problem code. +// 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/acquisition_tests.cpp b/tests/acquisition_tests.cpp new file mode 100644 index 0000000..3c079f1 --- /dev/null +++ b/tests/acquisition_tests.cpp @@ -0,0 +1,139 @@ +#include "gpu/acquisition.h" + +#include +#include +#include +#include +#include +#include + +static void Require(bool condition, const char* message) +{ + if (!condition) + { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +struct Candidate +{ + int id; + gpu::CandidatePriority priority; +}; + +static void TestOrdering() +{ + std::vector candidates{ + { 1, { false, false, 900 } }, { 2, { false, true, 10 } }, + { 3, { true, false, 1 } }, { 4, { false, false, 900 } }, + { 5, { false, false, 100 } } }; + std::stable_sort(candidates.begin(), candidates.end(), + [](const Candidate& left, const Candidate& right) + { return gpu::HigherPriority(left.priority, right.priority); }); + std::vector order; + for (const auto& candidate : candidates) + order.push_back(candidate.id); + Require(order == std::vector({ 3, 2, 1, 4, 5 }), + "preferred, current, score, then stable tie order"); +} + +struct Scenario +{ + std::unique_ptr active; + bool healthy = false; + bool loseCurrentDuringAttempt = false; + std::vector attempted; + std::vector succeeding; + int commits = 0; + + gpu::AcquisitionResult Run(const std::vector& candidates) + { + const int* original = active.get(); + return gpu::AcquireInOrder(candidates, + [&] { return active && healthy; }, + [&](int candidate) { return active && candidate == *active; }, + [&](int candidate) -> std::optional> + { + // Every creation attempt must see the original resource intact. + Require(active.get() == original, "active resource released before creation succeeded"); + attempted.push_back(candidate); + if (loseCurrentDuringAttempt) + healthy = false; + if (std::find(succeeding.begin(), succeeding.end(), candidate) == succeeding.end()) + return std::nullopt; + return std::optional>{ std::make_unique(candidate) }; + }, + [&](int candidate, std::unique_ptr&& pending) + { + Require(pending && *pending == candidate, "commit must receive created resource"); + Require(active.get() == original, "old resource must survive until commit"); + active = std::move(pending); + healthy = true; + ++commits; + }); + } +}; + +int main() +{ + using Result = gpu::AcquisitionResult; + TestOrdering(); + + Scenario replacement; + replacement.active = std::make_unique(2); + replacement.healthy = true; + replacement.succeeding = { 1 }; + Require(replacement.Run({ 1, 2, 3 }) == Result::Replaced, "preferred replacement succeeds"); + Require(*replacement.active == 1 && replacement.commits == 1, + "successful replacement commits once"); + + Scenario retained; + retained.active = std::make_unique(2); + retained.healthy = true; + retained.succeeding = { 3 }; + Require(retained.Run({ 1, 2, 3 }) == Result::Retained, "failed preferred preserves fallback"); + Require(*retained.active == 2 && retained.commits == 0 && retained.attempted == std::vector({ 1 }), + "healthy fallback is neither recreated nor displaced by a lower candidate"); + + Scenario next; + next.succeeding = { 2 }; + Require(next.Run({ 1, 2, 3 }) == Result::Replaced, "next candidate succeeds without a current device"); + Require(*next.active == 2 && next.attempted == std::vector({ 1, 2 }), "candidate retry order"); + + Scenario allFailed; + Require(allFailed.Run({ 1, 2 }) == Result::Failed, "all candidates fail without current device"); + Require(!allFailed.active && allFailed.commits == 0, "failures never commit"); + + Scenario absent; + absent.active = std::make_unique(7); + absent.healthy = true; + Require(absent.Run({ 1, 2 }) == Result::Retained, "healthy device survives enumeration omission"); + Require(*absent.active == 7 && absent.commits == 0, "all failed attempts preserve old device"); + + Scenario lost; + lost.active = std::make_unique(2); + lost.succeeding = { 3 }; + Require(lost.Run({ 1, 2, 3 }) == Result::Replaced, "unhealthy current device does not stop retries"); + Require(*lost.active == 3 && lost.attempted == std::vector({ 1, 2, 3 }), "lost-device retry sequence"); + + Scenario becameLost; + becameLost.active = std::make_unique(2); + becameLost.healthy = true; + becameLost.loseCurrentDuringAttempt = true; + becameLost.succeeding = { 3 }; + Require(becameLost.Run({ 1, 2, 3 }) == Result::Replaced, + "health is rechecked after failed preferred creation"); + Require(*becameLost.active == 3, "device lost during creation is not retained"); + + Scenario empty; + Require(empty.Run({}) == Result::Failed, "empty candidate list without device fails"); + empty.active = std::make_unique(2); + empty.healthy = true; + Require(empty.Run({}) == Result::Retained, "empty list retains healthy device"); + Require(empty.Run({ 2 }) == Result::Retained && empty.attempted.empty(), + "already active preferred device needs no creation"); + + std::cout << "Acquisition regression scenarios passed\n"; + return 0; +} diff --git a/tests/power_gate_tests.cpp b/tests/power_gate_tests.cpp new file mode 100644 index 0000000..0f81f72 --- /dev/null +++ b/tests/power_gate_tests.cpp @@ -0,0 +1,62 @@ +#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 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"); + std::cout << "Power transition regression scenarios passed\n"; +} 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"; +}