fix(runner): unload modules before CRT teardown #51433

Open
LegendaryBlair opened 11:03am on October 20, 2026 wants to merge 100 Δ into microsoft/powertoys main from
fix/runner-shutdown-before-crt-20261020

Pull Request Overview

  • Opened on October 20, 2026
  • Status Open
  • Commit count 3 with first commit October 20, 2026

Total Delta

100 Total Diff Delta

Open Days

Open 16 weekdays

Test Delta

0 Diff Delta in Test Files
Breakdown by Phase

How long has this pull request spent in each phase of its lifecycle?

Fraction of total time Business days Phase
 
0.3 days Authoring 1 commit before pull request opened for review
 
0.0 days Awaiting first review
 
16.0 days Revising work with 2 commits in response to 1 review that left 2 comments

Total time for pull request still awaiting merge (longer than repo's target): 16.3 business days

Author avatar

fix(runner): unload modules before CRT teardown

Summary of the Pull Request

Fixes Runner remaining alive after tray Quit following #50899's Hybrid CRT change. Module destruction currently happens during CRT static teardown on ordinary exit: the exiting thread can hold the shared UCRT exit lock while waiting for a module worker that needs the same lock.

Complete module shutdown before CRT teardown and before releasing Runner's lifetime guards. Keep Hybrid CRT enabled.

Draft: targeted builds, 22 Runner unit tests, actual-process shutdown checks, the real tray Close menu, single-instance cleanup ordering, and Restart Manager close-message checks passed. Settings-driven language restart remains blocked by a noninteractive desktop. Official private build 161076867 is running from a branch in microsoft/PowerToys, not the fork.

PR Checklist


  • [ ] Closes: N/A; regression introduced by #50899
    <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) -->

  • [ ] Communication: I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected

  • [ ] Tests: Added/updated and all pass — shutdown checks passed; remaining restart verification is environment-blocked

  • [x] Localization: All end-user-facing strings can be localized — no new end-user strings

  • [x] Dev docs: Added/updated

  • [ ] New binaries: Added on the required places — N/A, no new shipping binaries



  • [ ] Documentation updated: If checked, please file a pull request on our docs repo and link it here: N/A; repository developer documentation only

Detailed Description of the Pull Request / Additional comments



  • src/runner/main.cpp: a local cleanup guard cancels deferred Settings startup, stops the centralized hook, revokes module hotkey callbacks, and clears the module map before the settings-CLI and single-instance guards are released. It covers ordinary exit and exception unwinding as well as restart; the old restart-only clear is removed.


  • src/runner/centralized_hotkeys.cpp: revoke every matching callback and avoid trying to unregister window-owned hotkeys after their window has been destroyed.

  • Advanced Paste and PowerDisplay process-manager headers: declare the executor last so its worker joins before the pipe/state accessed by its tasks is destroyed.


  • tools/build/Test-RunnerShutdown.ps1: repeatable actual-process graceful-close check. A timeout/crash fails; any force-stop is recorded as failed-test cleanup, never a pass.


  • doc/devdocs/core/runner.md: document the shutdown contract and regression command.

  • The Runner verification profile records the validated entry paths, test limitations, and autorun-task restoration required after sideloading.

Existing Windows session-ending skips for Settings and Quick Access are unchanged. No settings schema, IPC contract, elevation policy, or pipeline changes.

The initial diagnosis used matching PDBs on four lingering installed 0.102.2883.0 processes, plus an isolated static/Hybrid CRT linkage experiment. These support the root cause, but are not a substitute for live testing of this patch.

Validation Steps Performed

Local x64 Release builds, using the repository wrappers with process-scoped tooling setup:



  • D:\PT-RunnerExit\tools\build\build.ps1 -Platform x64 -Configuration Release -Path D:\PT-RunnerExit\src\runner -ExtraArgs '/p:SolutionDir=D:\PT-RunnerExit\' — passed, exit 0.


  • D:\PT-RunnerExit\tools\build\build.ps1 -Platform x64 -Configuration Release -Path D:\PT-RunnerExit\src\modules\AdvancedPaste\AdvancedPasteModuleInterface -ExtraArgs @('/p:SolutionDir=D:\PT-RunnerExit\', '/restore', '/p:RestorePackagesConfig=true') — passed, exit 0.


  • D:\PT-RunnerExit\tools\build\build.ps1 -Platform x64 -Configuration Release -Path D:\PT-RunnerExit\src\modules\powerdisplay\PowerDisplayModuleInterface -ExtraArgs @('/p:SolutionDir=D:\PT-RunnerExit\', '/restore', '/p:RestorePackagesConfig=true') — passed, exit 0.


  • D:\PT-RunnerExit\tools\build\build.ps1 -Platform x64 -Configuration Release -Path D:\PT-RunnerExit\src\runner\UnitTests -ExtraArgs '/p:SolutionDir=D:\PT-RunnerExit\' — passed, exit 0.


  • & 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\Extensions\TestPlatform\vstest.console.exe' D:\PT-RunnerExit\x64\Release\tests\Runner\Runner.UnitTests.dll /Platform:x64 '/Logger:trx;LogFileName=runner-shutdown.trx' /ResultsDirectory:D:\PT-RunnerExit\artifacts\tests\runner-shutdown — 22/22 passed.

Local setup caveat: D:\PT-RunnerExit\tools\build\build-essentials.ps1 -Platform x64 -Configuration Release failed during solution restore on existing PlatformToolsetVersion evaluation errors in unrelated native projects. The targeted dependency builds above completed independently. The host's missing SDK-resolver/test-SDK paths were handled using task-private/process-local tooling, without modifying shared installations or repository build configuration.

Actual-process and winapp verification

The test layouts are outside the installation, under D:\PT-RunnerExit\artifacts\verification\Runner-PR51433. Both use a complete copied 0.102.2883.0 dependency payload. The baseline uses the unmodified main Runner; the fixed layout replaces only the locally built Runner and the two changed native module interfaces. Runner hashes/provenance were recorded. The Settings dependency still displays 0.102.2883; this is scoped Runner/native-interface verification, not a full latest-main installation test.

With $test = 'D:\PT-RunnerExit\tools\build\Test-RunnerShutdown.ps1', $payload = 'D:\PT-RunnerExit\artifacts\verification\Runner-PR51433', and $evidence = Join-Path $env:TEMP 'verify-Runner-PR51433-20261020\artifacts':



  • pwsh -NoProfile -File $test -RunnerPath "$payload\baseline\PowerToys.exe" -Iterations 1 -ExitTimeoutSeconds 20 -SettleSeconds 3 -ResultsDirectory "$evidence\baseline-process" — expected baseline failure: PID 42568 remained alive after graceful close. Its forced cleanup was recorded as a failure, not a pass.


  • pwsh -NoProfile -File $test -RunnerPath "$payload\fixed\PowerToys.exe" -Iterations 5 -ExitTimeoutSeconds 20 -SettleSeconds 3 -ResultsDirectory "$evidence\fixed-process" — 5/5 passed, natural exit code 0, no forced Runner cleanup.


  • pwsh -NoProfile -File $test -RunnerPath "$payload\fixed\PowerToys.exe" -Iterations 3 -SettleSeconds 0 -ExitTimeoutSeconds 30 -ResultsDirectory "$evidence\fixed-cold-controlled" — 3/3 passed, natural exit code 0. PowerDisplay, Light Switch, Mouse Highlighter and Advanced Paste remained enabled; ZoomIt was temporarily disabled and restored.

The unrestricted cold-start run completed two natural exits, then an independent ZoomIt --open-settings=ZoomIt activation acquired the singleton before the third iteration. The same secondary activation occurred with the unpatched baseline. That interrupted run is retained as interference evidence, not counted as a complete pass.

For the real tray menu, winapp ui inspect --depth 5 -w 8914686 --json identified Close (AutomationId 40001), and winapp ui invoke 40001 -w 8914686 --json passed. Patched Runner PID 43920 exited naturally with code 0; no test Runner remained. A post-exit taskbar screenshot and command/PID logs were captured. The menu was opened through the Runner's tray-context notification after an Explorer UIA focus failure; no direct process termination was used to satisfy this check.

Settings language selection and the visible Restart button were reached through winapp. Its InvokePattern path was unavailable; before the click fallback, the desktop became noninteractive (GetForegroundWindow() == 0, cursor APIs rejected access). A 60-minute wait did not restore desktop access. Restart is BLOCKED, not passed.

Additional process-level lifecycle controls passed: a second launch during the first Runner's observed module cleanup exited normally rather than becoming another Runner; relaunch after cleanup worked; a targeted Restart Manager ENDSESSION_CLOSEAPP message path exited naturally. These are not a claim that the Settings Restart button or real OS shutdown/logoff was verified.

All 71 snapshotted JSON configuration files are restored and no test-payload process remains. The installation's three relevant binary hashes are unchanged. Interactive runs restored pointer state; subsequent headless attempts did not move the pointer or override the locked desktop. The eight already-exited original remnants were identified by PID/exit logs and cleaned before testing. Runner startup had rewritten the user's autorun task to the test path; its installed executable action was restored with the principal and security descriptor preserved.

Official private validation build



  • Build 161076867, definition 76541.

  • Upstream branch: microsoft/PowerToys:LegendaryBlair/fix-runner-shutdown-before-crt-20261020.

  • Queued commit: fd27942a85ac01090d679f0b2107d921fbdf23f3; later commit e4abceaed4f61f00134ccc4ab090f47f904729c8 only updates documentation and script help.

  • Parameters: releaseIntent=auto (private-validation/private for this branch), versionNumber=auto, publishSymbolsToPublic=false, useVSPreview=false, Release x64 + ARM64.

  • Queued after the executable local regression gates passed. No fork branch or PR ref was used.

Not claimed: Settings-language button restart, elevation transitions, actual OS shutdown/logoff, full latest-main installed-product A/B, or private-branch ADO completion. The PR remains Draft for those remaining review/validation items.

Comments Threads Pending Resolution

ljharb reviewed on October 20, 2026
ljharb left a comment

### 🔵 Needs a closer look

Cross-module shutdown ordering needs human review, with Settings-driven restart, elevation transitions, and actual OS session-ending paths still unverified.

**No open issues** 🎉

What changed in this PR

Addresses the Runner shutdown hang introduced by #50899 by unloading modules before CRT teardown while retaining Hybrid CRT.

**Changes:**
- Shuts down modules and revokes hotkey callbacks before releasing Runner lifetime guards.
- Reorders Advanced Paste and PowerDisplay members so workers stop before their shared state is destroyed.
- Adds a process-level shutdown check and documents lifecycle requirements and validation limits.

| File | Description |
| ---- | ----------- |
| tools/​build/​Test-RunnerShutdown.ps1 | Checks graceful process exit and records failures. |
| src/​runner/​main.cpp | Adds pre-CRT module cleanup. |
| src/​runner/​centralized_hotkeys.cpp | Removes all matching callbacks and guards window-dependent unregistration. |
| src/​modules/​powerdisplay/​PowerDisplayModuleInterface/​PowerDisplayProcessManager.h | Destroys the executor before task-accessed state. |
| src/​modules/​AdvancedPaste/​AdvancedPasteModuleInterface/​AdvancedPasteProcessManager.h | Destroys the executor before task-accessed state. |
| doc/​devdocs/​core/​runner.md | Documents shutdown ordering and regression checks. |
| .github/​skills/​powertoys-verification/​references/​modules/​runner.md | Records Runner verification procedures and limitations. |

Review effort: Balanced

Resolved Comment Threads

No resolved comments have been left on this PR.