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]));
std::array<std::wstring_view, 45> processesToTerminate = {
std::array<std::wstring_view, 46> processesToTerminate = {
L"PowerToys.PowerLauncher.exe",
L"PowerToys.Settings.exe",
L"PowerToys.AdvancedPaste.exe",
@@ -1623,6 +1623,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.WorkspacesWindowArranger.exe",
L"Microsoft.CmdPal.UI.exe",
L"Microsoft.CmdPal.Ext.PowerToys.exe",
L"PowerToys.ShortcutGuide.exe",
L"PowerToys.ZoomIt.exe",
L"PowerToys.exe",
};

View File

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

View File

@@ -45,6 +45,7 @@ namespace winrt::PowerToys::Interop::implementation
static hstring ShowPeekEvent();
static hstring TerminatePeekEvent();
static hstring PowerAccentExitEvent();
static hstring ShortcutGuideExitEvent();
static hstring ShortcutGuideTriggerEvent();
static hstring ShortcutGuideWinKeyHoldEvent();
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 TerminatePeekEvent();
static String PowerAccentExitEvent();
static String ShortcutGuideExitEvent();
static String ShortcutGuideTriggerEvent();
static String ShortcutGuideWinKeyHoldEvent();
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.
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Threading;
@@ -19,10 +20,14 @@ namespace ShortcutGuide
{
public sealed class Program
{
private static readonly ManualResetEvent _runnerExitEvent = new(false);
public static Thread CopyAndIndexGenerationThread { get; private set; } = null!;
public static nint ForegroundWindowHandle { get; set; } = nint.Zero;
internal static WaitHandle RunnerExitEvent => _runnerExitEvent;
[STAThread]
public static void Main(string[] args)
{
@@ -38,21 +43,17 @@ namespace ShortcutGuide
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)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
return;
}
if (args.Length >= 1 && int.TryParse(args[0], out int runnerPID))
{
MonitorPowerToysRunner(runnerPID);
}
Directory.CreateDirectory(ManifestInterpreter.PathOfManifestFiles);
// 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");
}
}
// The WinRT/WinUI dispatcher thread doesn't terminate cleanly; force exit.
Environment.Exit(0);
private static void MonitorPowerToysRunner(int runnerPID)
{
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()

View File

@@ -38,16 +38,21 @@ namespace ShortcutGuide
/// </summary>
internal static OverlayWindow OverlayWindow { get; private set; } = null!;
private HotkeySettingsControlHook _winKeyUpKeyboardHook = null!;
private HotkeySettingsControlHook? _winKeyUpKeyboardHook;
internal static string CurrentAppName { get; set; } = string.Empty;
private readonly SemaphoreSlim _activationGate = new(1, 1);
private readonly ManualResetEvent _listenerShutdownEvent = new(false);
private EventWaitHandle? _regularHotkeyEvent;
private EventWaitHandle? _winKeyHoldEvent;
private EventWaitHandle? _exitEvent;
private RegisteredWaitHandle? _runnerExitRegistration;
private Thread? _listenForActivationEventsThread;
private int _activeSource = (int)ShortcutGuideActivationSource.None;
private int _activeSurface = (int)ShortcutGuideOverlaySurface.Hidden;
private int _disposed;
private int _shutdownStarted;
private static readonly UIntPtr _ignoreKeyEventFlag = 0x5557;
@@ -68,6 +73,20 @@ namespace ShortcutGuide
{
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();
OverlayWindow = new OverlayWindow();
OverlayWindow.ClosingStarted += (_, _) => ResetActivationState();
@@ -79,16 +98,12 @@ namespace ShortcutGuide
OverlayWindow.SessionDurationMs,
OverlayWindow.CloseType));
// WinUI3's dispatcher loop does not terminate when the last
// 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();
Shutdown();
};
_regularHotkeyEvent = TryOpenActivationEvent(Constants.ShortcutGuideTriggerEvent());
_winKeyHoldEvent = TryOpenActivationEvent(Constants.ShortcutGuideWinKeyHoldEvent());
_exitEvent = TryOpenActivationEvent(Constants.ShortcutGuideExitEvent());
_listenForActivationEventsThread = new Thread(ListenForActivationEvents)
{
@@ -133,7 +148,8 @@ namespace ShortcutGuide
// Any failure in launch is fatal for this short-lived overlay; log and exit
// cleanly rather than letting WinUI surface a generic crash dialog.
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));
}
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;
}
WaitHandle[] handles = activationEvents.ConvertAll(item => item.Handle).ToArray();
try
{
int listenerShutdownEventIndex = handles.Count;
handles.Add(_listenerShutdownEvent);
WaitHandle[] waitHandles = handles.ToArray();
Logger.LogInfo("Shortcut Guide activation-event listener started.");
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;
Logger.LogInfo($"Shortcut Guide trigger event signaled by {activationSource}.");
OverlayWindow.DispatcherQueue.TryEnqueue(() => _ = HandleActivationAsync(activationSource));
}
}
catch (ObjectDisposedException)
{
}
catch (ThreadInterruptedException)
{
}
}
private static EventWaitHandle? TryOpenActivationEvent(string eventName)
{
@@ -431,32 +460,53 @@ namespace ShortcutGuide
e.SetObserved();
}
public void Dispose()
private void Shutdown()
{
_regularHotkeyEvent?.Dispose();
_winKeyHoldEvent?.Dispose();
if (_listenForActivationEventsThread == null)
if (Interlocked.Exchange(ref _shutdownStarted, 1) != 0)
{
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
{
if (!_listenForActivationEventsThread.Join(TimeSpan.FromMilliseconds(250)))
if (!_listenForActivationEventsThread.Join(TimeSpan.FromSeconds(1)))
{
_listenForActivationEventsThread.Interrupt();
_listenForActivationEventsThread.Join(TimeSpan.FromMilliseconds(250));
Logger.LogWarning("Shortcut Guide activation-event listener did not stop within the timeout.");
}
}
catch (ThreadInterruptedException)
{
}
catch (ThreadStateException)
catch (ThreadStateException ex)
{
Logger.LogWarning($"Failed to join Shortcut Guide activation-event listener: {ex.Message}");
}
_listenForActivationEventsThread = null;
}
_regularHotkeyEvent?.Dispose();
_winKeyHoldEvent?.Dispose();
_exitEvent?.Dispose();
_listenerShutdownEvent.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -111,10 +111,7 @@ public:
if (_enabled)
{
_enabled = false;
if (IsProcessActive())
{
TerminateProcess(m_hProcess, 0);
}
StopProcess();
}
else
{
@@ -190,7 +187,7 @@ private:
//contains the non localized key of the powertoy
std::wstring app_key;
bool _enabled = false;
HANDLE m_hProcess = nullptr;
winrt::handle m_process;
// Hotkey to invoke the module
HotkeyEx m_hotkey;
@@ -226,6 +223,14 @@ private:
}
bool StartProcess(std::wstring args = L"")
{
const bool trackProcess = args.empty();
if (trackProcess && IsProcessActive())
{
return true;
}
if (trackProcess)
{
if (exitEvent)
{
@@ -236,10 +241,12 @@ private:
{
ResetEvent(triggerEvent);
}
if (winKeyHoldEvent)
{
ResetEvent(winKeyHoldEvent);
}
}
unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = L"";
@@ -267,25 +274,80 @@ private:
return false;
}
Logger::trace(L"Started SG process with pid={}", GetProcessId(sei.hProcess));
m_hProcess = sei.hProcess;
winrt::handle launchedProcess{ sei.hProcess };
Logger::trace(L"Started SG process with pid={}", GetProcessId(launchedProcess.get()));
if (trackProcess)
{
m_process = std::move(launchedProcess);
}
return true;
}
bool IsProcessActive()
{
if (!m_hProcess)
if (!m_process)
{
return false;
}
auto result = WaitForSingleObject(m_hProcess, 0);
auto result = WaitForSingleObject(m_process.get(), 0);
if (result == WAIT_FAILED)
{
Logger::error("Failed to wait for SG process.");
}
if (result == WAIT_OBJECT_0)
{
m_process = {};
}
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()
{
try
@@ -409,10 +471,7 @@ private:
void WindowsKeyPressBehavior()
{
if (IsProcessActive())
{
TerminateProcess(m_hProcess, 0);
}
StopProcess();
}
};