fix(shortcut-guide): prevent orphaned processes during shutdown (#50091)

## Summary of the Pull Request

Prevents Shortcut Guide processes from surviving Runner shutdown and
blocking PowerToys upgrades.

- Shuts down WinUI through its dispatcher instead of forcing CLR
termination from a worker thread.
- Opens and retains the Runner process handle before WinUI
initialization so early Runner exits cannot be missed.
- Gives the native module deterministic ownership of the Shortcut Guide
process handle, with graceful shutdown and a bounded forced-termination
fallback.
- Keeps telemetry subprocess handles separate from the persistent UI
process.
- Adds `PowerToys.ShortcutGuide.exe` to the installer termination
fallback so affected existing installations can recover during upgrade.

## PR Checklist

- [x] **Communication:** Discussed and requested by a core contributor
after investigating the release regression.
- [x] **Tests:** Existing tests pass; process lifecycle and installer
file replacement were also validated.

## Detailed Description of the Pull Request / Additional comments

The Shortcut Guide lifecycle introduced by #48683 could call
`Environment.Exit` from a Runner-watcher worker thread while WinUI was
still tearing down. The native module also overwrote its persistent
child-process handle when launching telemetry and did not close
completed handles. Repeated Runner lifetimes could therefore leave
`PowerToys.ShortcutGuide.exe` processes retaining shared WinUI files.

The installer did not recover from that state: Restart Manager is
disabled, the bundle and WiX close-application steps target only
`PowerToys.exe`, and `TerminateProcessesCA` did not include
`PowerToys.ShortcutGuide.exe`. Locked files could consequently remain at
the previous version while installation continued, producing a mixed
payload.

`src/modules/ShortcutGuide/ShortcutGuide.Ui/Program.cs` now
synchronously captures the Runner process handle and publishes its exit
through a wait handle.
`src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/App.xaml.cs`
registers that wait with the UI dispatcher, centralizes idempotent
shutdown, and disposes activation listeners and hooks deterministically.

`src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp` now
uses RAII for the tracked UI process, avoids replacing it with telemetry
handles, signals the existing native exit event, waits for graceful
shutdown, and terminates only as a bounded fallback. The event is
projected to managed code through `src/common/interop/Constants.idl`.

`installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp` now
includes `PowerToys.ShortcutGuide.exe` in the MSI process-termination
fallback, allowing upgrades from already-affected builds.

## Validation Steps Performed

- Built `PowerToys.Interop.vcxproj`,
`ShortcutGuideModuleInterface.vcxproj`, `ShortcutGuide.Ui.csproj`, and
`PowerToysSetupCustomActionsVNext.vcxproj` for x64 Release.
- Built and ran `ShortcutGuide.UnitTests`: 48/48 passed.
- Repeated Runner/Shortcut Guide startup and parent-exit teardown 10
times; every child exited with code 0.
- Verified the race where the Runner exits before Shortcut Guide
initializes.
- Verified `PowerToys.ShortcutGuide.exe`, `PowerToys.Interop.dll`, and
`Microsoft.UI.Xaml.dll` were immediately replaceable after shutdown.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7f6c4822-53e0-42ae-a51a-a302a7c6d3ab
This commit is contained in:
Boliang Zhang
2026-08-25 13:14:32 +08:00
committed by GitHub
parent a3944b99eb
commit 01271fcb52
7 changed files with 233 additions and 78 deletions

View File

@@ -1579,7 +1579,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
} }
processes.resize(bytes / sizeof(processes[0])); processes.resize(bytes / sizeof(processes[0]));
std::array<std::wstring_view, 45> processesToTerminate = { std::array<std::wstring_view, 46> processesToTerminate = {
L"PowerToys.PowerLauncher.exe", L"PowerToys.PowerLauncher.exe",
L"PowerToys.Settings.exe", L"PowerToys.Settings.exe",
L"PowerToys.AdvancedPaste.exe", L"PowerToys.AdvancedPaste.exe",
@@ -1623,6 +1623,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.WorkspacesWindowArranger.exe", L"PowerToys.WorkspacesWindowArranger.exe",
L"Microsoft.CmdPal.UI.exe", L"Microsoft.CmdPal.UI.exe",
L"Microsoft.CmdPal.Ext.PowerToys.exe", L"Microsoft.CmdPal.Ext.PowerToys.exe",
L"PowerToys.ShortcutGuide.exe",
L"PowerToys.ZoomIt.exe", L"PowerToys.ZoomIt.exe",
L"PowerToys.exe", L"PowerToys.exe",
}; };

View File

@@ -163,6 +163,12 @@ namespace winrt::PowerToys::Interop::implementation
{ {
return CommonSharedConstants::POWERACCENT_EXIT_EVENT; return CommonSharedConstants::POWERACCENT_EXIT_EVENT;
} }
hstring Constants::ShortcutGuideExitEvent()
{
return CommonSharedConstants::SHORTCUT_GUIDE_EXIT_EVENT;
}
hstring Constants::ShortcutGuideTriggerEvent() hstring Constants::ShortcutGuideTriggerEvent()
{ {
return CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT; return CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT;
@@ -317,4 +323,3 @@ namespace winrt::PowerToys::Interop::implementation
return CommonSharedConstants::KEYBOARD_MANAGER_ENGINE_INSTANCE_MUTEX; return CommonSharedConstants::KEYBOARD_MANAGER_ENGINE_INSTANCE_MUTEX;
} }
} }

View File

@@ -45,6 +45,7 @@ namespace winrt::PowerToys::Interop::implementation
static hstring ShowPeekEvent(); static hstring ShowPeekEvent();
static hstring TerminatePeekEvent(); static hstring TerminatePeekEvent();
static hstring PowerAccentExitEvent(); static hstring PowerAccentExitEvent();
static hstring ShortcutGuideExitEvent();
static hstring ShortcutGuideTriggerEvent(); static hstring ShortcutGuideTriggerEvent();
static hstring ShortcutGuideWinKeyHoldEvent(); static hstring ShortcutGuideWinKeyHoldEvent();
static hstring RegistryPreviewTriggerEvent(); static hstring RegistryPreviewTriggerEvent();
@@ -91,4 +92,3 @@ namespace winrt::PowerToys::Interop::factory_implementation
{ {
}; };
} }

View File

@@ -41,6 +41,7 @@ namespace PowerToys
static String ShowPeekEvent(); static String ShowPeekEvent();
static String TerminatePeekEvent(); static String TerminatePeekEvent();
static String PowerAccentExitEvent(); static String PowerAccentExitEvent();
static String ShortcutGuideExitEvent();
static String ShortcutGuideTriggerEvent(); static String ShortcutGuideTriggerEvent();
static String ShortcutGuideWinKeyHoldEvent(); static String ShortcutGuideWinKeyHoldEvent();
static String RegistryPreviewTriggerEvent(); static String RegistryPreviewTriggerEvent();
@@ -82,4 +83,3 @@ namespace PowerToys
} }
} }
} }

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
@@ -19,10 +20,14 @@ namespace ShortcutGuide
{ {
public sealed class Program public sealed class Program
{ {
private static readonly ManualResetEvent _runnerExitEvent = new(false);
public static Thread CopyAndIndexGenerationThread { get; private set; } = null!; public static Thread CopyAndIndexGenerationThread { get; private set; } = null!;
public static nint ForegroundWindowHandle { get; set; } = nint.Zero; public static nint ForegroundWindowHandle { get; set; } = nint.Zero;
internal static WaitHandle RunnerExitEvent => _runnerExitEvent;
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
@@ -38,21 +43,17 @@ namespace ShortcutGuide
return; return;
} }
if (args.Length >= 1 && int.TryParse(args[0], out int runnerPID))
{
RunnerHelper.WaitForPowerToysRunner(runnerPID, () =>
{
Logger.LogInfo($"PowerToys runner process (PID={runnerPID}) exited. Exiting ShortcutGuide.");
Environment.Exit(0);
});
}
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredShortcutGuideEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredShortcutGuideEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{ {
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator."); Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
return; return;
} }
if (args.Length >= 1 && int.TryParse(args[0], out int runnerPID))
{
MonitorPowerToysRunner(runnerPID);
}
Directory.CreateDirectory(ManifestInterpreter.PathOfManifestFiles); Directory.CreateDirectory(ManifestInterpreter.PathOfManifestFiles);
// Copy every shipped manifest from the install directory to the per-user manifest folder. // Copy every shipped manifest from the install directory to the per-user manifest folder.
@@ -136,9 +137,48 @@ namespace ShortcutGuide
{ {
Logger.LogWarning("Another instance of ShortcutGuide is running. Exiting ShortcutGuide"); Logger.LogWarning("Another instance of ShortcutGuide is running. Exiting ShortcutGuide");
} }
}
// The WinRT/WinUI dispatcher thread doesn't terminate cleanly; force exit. private static void MonitorPowerToysRunner(int runnerPID)
Environment.Exit(0); {
Process runnerProcess;
try
{
runnerProcess = Process.GetProcessById(runnerPID);
// Force the process handle to open synchronously so a Runner exit
// during WinUI initialization cannot be missed or confused with PID reuse.
_ = runnerProcess.Handle;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception)
{
Logger.LogWarning($"PowerToys runner process (PID={runnerPID}) is no longer available. Exiting ShortcutGuide.");
_runnerExitEvent.Set();
return;
}
var runnerWatcher = new Thread(() =>
{
try
{
runnerProcess.WaitForExit();
Logger.LogInfo($"PowerToys runner process (PID={runnerPID}) exited. Exiting ShortcutGuide.");
}
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception)
{
Logger.LogWarning($"Failed while waiting for PowerToys runner process (PID={runnerPID}): {ex.Message}");
}
finally
{
runnerProcess.Dispose();
_runnerExitEvent.Set();
}
})
{
IsBackground = true,
Name = "ShortcutGuide-RunnerWatcher",
};
runnerWatcher.Start();
} }
private static void SendSettingsTelemetry() private static void SendSettingsTelemetry()

View File

@@ -38,16 +38,21 @@ namespace ShortcutGuide
/// </summary> /// </summary>
internal static OverlayWindow OverlayWindow { get; private set; } = null!; internal static OverlayWindow OverlayWindow { get; private set; } = null!;
private HotkeySettingsControlHook _winKeyUpKeyboardHook = null!; private HotkeySettingsControlHook? _winKeyUpKeyboardHook;
internal static string CurrentAppName { get; set; } = string.Empty; internal static string CurrentAppName { get; set; } = string.Empty;
private readonly SemaphoreSlim _activationGate = new(1, 1); private readonly SemaphoreSlim _activationGate = new(1, 1);
private readonly ManualResetEvent _listenerShutdownEvent = new(false);
private EventWaitHandle? _regularHotkeyEvent; private EventWaitHandle? _regularHotkeyEvent;
private EventWaitHandle? _winKeyHoldEvent; private EventWaitHandle? _winKeyHoldEvent;
private EventWaitHandle? _exitEvent;
private RegisteredWaitHandle? _runnerExitRegistration;
private Thread? _listenForActivationEventsThread; private Thread? _listenForActivationEventsThread;
private int _activeSource = (int)ShortcutGuideActivationSource.None; private int _activeSource = (int)ShortcutGuideActivationSource.None;
private int _activeSurface = (int)ShortcutGuideOverlaySurface.Hidden; private int _activeSurface = (int)ShortcutGuideOverlaySurface.Hidden;
private int _disposed;
private int _shutdownStarted;
private static readonly UIntPtr _ignoreKeyEventFlag = 0x5557; private static readonly UIntPtr _ignoreKeyEventFlag = 0x5557;
@@ -68,6 +73,20 @@ namespace ShortcutGuide
{ {
try try
{ {
var dispatcher = DispatcherQueue.GetForCurrentThread();
_runnerExitRegistration = ThreadPool.RegisterWaitForSingleObject(
Program.RunnerExitEvent,
(_, _) =>
{
if (!dispatcher.TryEnqueue(Shutdown))
{
Logger.LogWarning("Failed to enqueue Shortcut Guide shutdown after the PowerToys runner exited.");
}
},
null,
Timeout.Infinite,
true);
this.LoadData(); this.LoadData();
OverlayWindow = new OverlayWindow(); OverlayWindow = new OverlayWindow();
OverlayWindow.ClosingStarted += (_, _) => ResetActivationState(); OverlayWindow.ClosingStarted += (_, _) => ResetActivationState();
@@ -79,16 +98,12 @@ namespace ShortcutGuide
OverlayWindow.SessionDurationMs, OverlayWindow.SessionDurationMs,
OverlayWindow.CloseType)); OverlayWindow.CloseType));
// WinUI3's dispatcher loop does not terminate when the last Shutdown();
// window closes; without Exit() the SG.exe process stays
// alive, holds the AppInstance single-instance lock, and
// blocks the next launch (the well-known "every other
// long-press works" bug).
Current.Exit();
}; };
_regularHotkeyEvent = TryOpenActivationEvent(Constants.ShortcutGuideTriggerEvent()); _regularHotkeyEvent = TryOpenActivationEvent(Constants.ShortcutGuideTriggerEvent());
_winKeyHoldEvent = TryOpenActivationEvent(Constants.ShortcutGuideWinKeyHoldEvent()); _winKeyHoldEvent = TryOpenActivationEvent(Constants.ShortcutGuideWinKeyHoldEvent());
_exitEvent = TryOpenActivationEvent(Constants.ShortcutGuideExitEvent());
_listenForActivationEventsThread = new Thread(ListenForActivationEvents) _listenForActivationEventsThread = new Thread(ListenForActivationEvents)
{ {
@@ -133,7 +148,8 @@ namespace ShortcutGuide
// Any failure in launch is fatal for this short-lived overlay; log and exit // Any failure in launch is fatal for this short-lived overlay; log and exit
// cleanly rather than letting WinUI surface a generic crash dialog. // cleanly rather than letting WinUI surface a generic crash dialog.
Logger.LogError("Failed to launch Shortcut Guide.", ex); Logger.LogError("Failed to launch Shortcut Guide.", ex);
Environment.Exit(1); Environment.ExitCode = 1;
Shutdown();
} }
} }
@@ -195,31 +211,44 @@ namespace ShortcutGuide
activationEvents.Add((_winKeyHoldEvent, ShortcutGuideActivationSource.WindowsKeyHold)); activationEvents.Add((_winKeyHoldEvent, ShortcutGuideActivationSource.WindowsKeyHold));
} }
if (activationEvents.Count == 0) List<WaitHandle> handles = activationEvents.ConvertAll(item => item.Handle);
int exitEventIndex = -1;
if (_exitEvent != null)
{ {
Logger.LogError("Failed to open any Shortcut Guide activation trigger events."); exitEventIndex = handles.Count;
handles.Add(_exitEvent);
}
if (handles.Count == 0)
{
Logger.LogError("Failed to open any Shortcut Guide events.");
return; return;
} }
WaitHandle[] handles = activationEvents.ConvertAll(item => item.Handle).ToArray(); int listenerShutdownEventIndex = handles.Count;
try handles.Add(_listenerShutdownEvent);
{ WaitHandle[] waitHandles = handles.ToArray();
Logger.LogInfo("Shortcut Guide activation-event listener started."); Logger.LogInfo("Shortcut Guide activation-event listener started.");
while (true) while (true)
{ {
int eventIndex = WaitHandle.WaitAny(handles); int eventIndex = WaitHandle.WaitAny(waitHandles);
if (eventIndex == listenerShutdownEventIndex)
{
return;
}
if (eventIndex == exitEventIndex)
{
Logger.LogInfo("Shortcut Guide exit event signaled.");
OverlayWindow.DispatcherQueue.TryEnqueue(Shutdown);
return;
}
var activationSource = activationEvents[eventIndex].Source; var activationSource = activationEvents[eventIndex].Source;
Logger.LogInfo($"Shortcut Guide trigger event signaled by {activationSource}."); Logger.LogInfo($"Shortcut Guide trigger event signaled by {activationSource}.");
OverlayWindow.DispatcherQueue.TryEnqueue(() => _ = HandleActivationAsync(activationSource)); OverlayWindow.DispatcherQueue.TryEnqueue(() => _ = HandleActivationAsync(activationSource));
} }
} }
catch (ObjectDisposedException)
{
}
catch (ThreadInterruptedException)
{
}
}
private static EventWaitHandle? TryOpenActivationEvent(string eventName) private static EventWaitHandle? TryOpenActivationEvent(string eventName)
{ {
@@ -431,32 +460,53 @@ namespace ShortcutGuide
e.SetObserved(); e.SetObserved();
} }
public void Dispose() private void Shutdown()
{ {
_regularHotkeyEvent?.Dispose(); if (Interlocked.Exchange(ref _shutdownStarted, 1) != 0)
_winKeyHoldEvent?.Dispose();
if (_listenForActivationEventsThread == null)
{ {
return; return;
} }
Dispose();
Current.Exit();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_winKeyUpKeyboardHook?.Dispose();
_runnerExitRegistration?.Unregister(null);
this.UnhandledException -= App_UnhandledException;
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
_listenerShutdownEvent.Set();
if (_listenForActivationEventsThread != null)
{
try try
{ {
if (!_listenForActivationEventsThread.Join(TimeSpan.FromMilliseconds(250))) if (!_listenForActivationEventsThread.Join(TimeSpan.FromSeconds(1)))
{ {
_listenForActivationEventsThread.Interrupt(); Logger.LogWarning("Shortcut Guide activation-event listener did not stop within the timeout.");
_listenForActivationEventsThread.Join(TimeSpan.FromMilliseconds(250));
} }
} }
catch (ThreadInterruptedException) catch (ThreadStateException ex)
{
}
catch (ThreadStateException)
{ {
Logger.LogWarning($"Failed to join Shortcut Guide activation-event listener: {ex.Message}");
} }
_listenForActivationEventsThread = null; _listenForActivationEventsThread = null;
}
_regularHotkeyEvent?.Dispose();
_winKeyHoldEvent?.Dispose();
_exitEvent?.Dispose();
_listenerShutdownEvent.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }

View File

@@ -111,10 +111,7 @@ public:
if (_enabled) if (_enabled)
{ {
_enabled = false; _enabled = false;
if (IsProcessActive()) StopProcess();
{
TerminateProcess(m_hProcess, 0);
}
} }
else else
{ {
@@ -190,7 +187,7 @@ private:
//contains the non localized key of the powertoy //contains the non localized key of the powertoy
std::wstring app_key; std::wstring app_key;
bool _enabled = false; bool _enabled = false;
HANDLE m_hProcess = nullptr; winrt::handle m_process;
// Hotkey to invoke the module // Hotkey to invoke the module
HotkeyEx m_hotkey; HotkeyEx m_hotkey;
@@ -226,6 +223,14 @@ private:
} }
bool StartProcess(std::wstring args = L"") bool StartProcess(std::wstring args = L"")
{
const bool trackProcess = args.empty();
if (trackProcess && IsProcessActive())
{
return true;
}
if (trackProcess)
{ {
if (exitEvent) if (exitEvent)
{ {
@@ -236,10 +241,12 @@ private:
{ {
ResetEvent(triggerEvent); ResetEvent(triggerEvent);
} }
if (winKeyHoldEvent) if (winKeyHoldEvent)
{ {
ResetEvent(winKeyHoldEvent); ResetEvent(winKeyHoldEvent);
} }
}
unsigned long powertoys_pid = GetCurrentProcessId(); unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = L""; std::wstring executable_args = L"";
@@ -267,25 +274,80 @@ private:
return false; return false;
} }
Logger::trace(L"Started SG process with pid={}", GetProcessId(sei.hProcess)); winrt::handle launchedProcess{ sei.hProcess };
m_hProcess = sei.hProcess; Logger::trace(L"Started SG process with pid={}", GetProcessId(launchedProcess.get()));
if (trackProcess)
{
m_process = std::move(launchedProcess);
}
return true; return true;
} }
bool IsProcessActive() bool IsProcessActive()
{ {
if (!m_hProcess) if (!m_process)
{ {
return false; return false;
} }
auto result = WaitForSingleObject(m_hProcess, 0);
auto result = WaitForSingleObject(m_process.get(), 0);
if (result == WAIT_FAILED) if (result == WAIT_FAILED)
{ {
Logger::error("Failed to wait for SG process."); Logger::error("Failed to wait for SG process.");
} }
if (result == WAIT_OBJECT_0)
{
m_process = {};
}
return result == WAIT_TIMEOUT; return result == WAIT_TIMEOUT;
} }
void StopProcess()
{
if (exitEvent)
{
if (!SetEvent(exitEvent))
{
Logger::error(L"Failed to signal {}. {}", CommonSharedConstants::SHORTCUT_GUIDE_EXIT_EVENT, get_last_error_or_default(GetLastError()));
}
}
if (!m_process)
{
return;
}
if (!IsProcessActive())
{
return;
}
constexpr DWORD gracefulShutdownTimeoutMs = 2000;
constexpr DWORD forcedShutdownTimeoutMs = 5000;
auto waitResult = WaitForSingleObject(m_process.get(), gracefulShutdownTimeoutMs);
if (waitResult == WAIT_TIMEOUT)
{
Logger::warn("Shortcut Guide did not exit gracefully; terminating it.");
if (!TerminateProcess(m_process.get(), 0))
{
Logger::error(L"Failed to terminate Shortcut Guide. {}", get_last_error_or_default(GetLastError()));
}
else if (WaitForSingleObject(m_process.get(), forcedShutdownTimeoutMs) != WAIT_OBJECT_0)
{
Logger::error("Shortcut Guide did not terminate within the timeout.");
}
}
else if (waitResult == WAIT_FAILED)
{
Logger::error(L"Failed to wait for Shortcut Guide shutdown. {}", get_last_error_or_default(GetLastError()));
}
m_process = {};
}
void InitSettings() void InitSettings()
{ {
try try
@@ -409,10 +471,7 @@ private:
void WindowsKeyPressBehavior() void WindowsKeyPressBehavior()
{ {
if (IsProcessActive()) StopProcess();
{
TerminateProcess(m_hProcess, 0);
}
} }
}; };