Compare commits

...

6 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
99a7fb15e7 CmdPal: Fix AppLifeMonitor spelling alerts from check-spelling CI
- Remove 'MOAPPLICATION_HANG / HANG_QUIESCE' from XML doc (not in allowlist)
- Rename DispatchMessageW parameter from 'lpmsg' to 'msg' (not in allowlist)

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/6305e31f-fb6c-4d13-90c7-31fdf4319a83

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-05-14 08:34:43 +00:00
Muyuan Li (from Dev Box)
21ba5d20e4 Address review: harden AppLifeMonitor shutdown loop
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 15:40:24 +08:00
copilot-swe-agent[bot]
bf381b42c9 CmdPal: Fix AppLifeMonitor _windowCreated disposal race condition
Avoid disposing _windowCreated from Dispose() to prevent race with
background thread's WaitOne/Set. Instead, always Set() in finally
to unblock constructor. Add comment explaining the deliberate choice.

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/96bd57fd-4d90-4c2e-85e9-3dfd7f712546

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-04-29 09:47:02 +00:00
copilot-swe-agent[bot]
dddccef173 CmdPal: Fix AppLifeMonitor disposal and failure handling
- Dispose _windowCreated from background thread finally block to avoid
  race condition with Dispose()
- Always signal _windowCreated.Set() in finally to prevent constructor hang
- Add explanatory comments for silent failure paths

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/96bd57fd-4d90-4c2e-85e9-3dfd7f712546

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-04-29 09:44:38 +00:00
copilot-swe-agent[bot]
4c9e007fd9 CmdPal: Add AppLifeMonitor to fix extension hang on OS shutdown
Add AppLifeMonitor class to Microsoft.CommandPalette.Extensions.Toolkit
that creates a hidden STA window on a background thread to handle
WM_QUERYENDSESSION and WM_ENDSESSION. Update extension template and
sample extensions to use it, preventing MOAPPLICATION_HANG reports.

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/96bd57fd-4d90-4c2e-85e9-3dfd7f712546

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-04-29 09:40:01 +00:00
copilot-swe-agent[bot]
11564d6fb0 Initial plan 2026-04-29 08:50:37 +00:00
5 changed files with 323 additions and 2 deletions

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Shmuelie.WinRTServer;
using Shmuelie.WinRTServer.CsWinRT;
using System;
@@ -21,14 +22,19 @@ public class Program
global::Shmuelie.WinRTServer.ComServer server = new();
ManualResetEvent extensionDisposedEvent = new(false);
// AppLifeMonitor creates a hidden window on a background STA thread to handle
// WM_QUERYENDSESSION and WM_ENDSESSION. Without it, extensions hang on OS
// shutdown because the MTA main thread has no message loop to receive those messages.
using AppLifeMonitor monitor = new(extensionDisposedEvent);
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
TemplateCmdPalExtension extensionInstance = new(extensionDisposedEvent);
server.RegisterClass<TemplateCmdPalExtension, IExtension>(() => extensionInstance);
server.Start();
// This will make the main thread wait until the event is signalled by the extension class.
// Since we have single instance of the extension object, we exit as soon as it is disposed.
extensionDisposedEvent.WaitOne();

View File

@@ -7,6 +7,7 @@ using System.Runtime.InteropServices;
using System.Threading;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Shmuelie.WinRTServer;
using Shmuelie.WinRTServer.CsWinRT;
@@ -37,6 +38,11 @@ public class Program
ManualResetEvent extensionDisposedEvent = new(false);
try
{
// AppLifeMonitor creates a hidden window on a background STA thread to handle
// WM_QUERYENDSESSION and WM_ENDSESSION. Without it, extensions hang on OS
// shutdown because the MTA main thread has no message loop to receive those messages.
using AppLifeMonitor monitor = new(extensionDisposedEvent);
PowerToysExtension extensionInstance = new(extensionDisposedEvent);
Logger.LogInfo("Registering extension via Shmuelie.WinRTServer.");
server.RegisterClass<PowerToysExtension, IExtension>(() => extensionInstance);

View File

@@ -5,6 +5,7 @@
using System;
using System.Threading;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace ProcessMonitorExtension;
@@ -17,6 +18,12 @@ public class Program
{
using ExtensionServer server = new();
var extensionDisposedEvent = new ManualResetEvent(false);
// AppLifeMonitor creates a hidden window on a background STA thread to handle
// WM_QUERYENDSESSION and WM_ENDSESSION. Without it, extensions hang on OS
// shutdown because the MTA main thread has no message loop to receive those messages.
using AppLifeMonitor monitor = new(extensionDisposedEvent);
var extensionInstance = new SampleExtension(extensionDisposedEvent);
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.

View File

@@ -5,6 +5,7 @@
using System;
using System.Threading;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Shmuelie.WinRTServer;
using Shmuelie.WinRTServer.CsWinRT;
@@ -21,6 +22,11 @@ public class Program
ManualResetEvent extensionDisposedEvent = new(false);
// AppLifeMonitor creates a hidden window on a background STA thread to handle
// WM_QUERYENDSESSION and WM_ENDSESSION. Without it, extensions hang on OS
// shutdown because the MTA main thread has no message loop to receive those messages.
using AppLifeMonitor monitor = new(extensionDisposedEvent);
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.

View File

@@ -0,0 +1,296 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
/// <summary>
/// Monitors OS-initiated application lifecycle events (such as system shutdown or session end)
/// and signals an event so the extension process can exit gracefully.
/// </summary>
/// <remarks>
/// Extensions run as COM out-of-process servers with <c>[MTAThread]</c>, which means the main
/// thread has no Win32 message loop. Without a message loop, the process cannot receive
/// <c>WM_QUERYENDSESSION</c> or <c>WM_ENDSESSION</c> messages when the OS shuts down, causing
/// hang reports and delayed Store updates.
///
/// This class creates a dedicated hidden window on a background STA thread whose message loop
/// handles those messages and signals <paramref name="extensionDisposedEvent"/> so the
/// extension exits promptly.
///
/// <b>Important:</b> The window must <em>not</em> use the <c>HWND_MESSAGE</c> parent because
/// message-only windows are excluded from the OS broadcast of <c>WM_QUERYENDSESSION</c>.
/// </remarks>
public sealed class AppLifeMonitor : IDisposable
{
// Win32 window message constants
private const uint WM_CLOSE = 0x0010;
private const uint WM_DESTROY = 0x0002;
private const uint WM_QUERYENDSESSION = 0x0011;
private const uint WM_ENDSESSION = 0x0016;
// Invisible zero-size pop-up window style. Must NOT use HWND_MESSAGE parent:
// message-only windows are excluded from OS shutdown broadcasts.
private const uint WS_POPUP = 0x80000000;
private delegate nint WndProcDelegate(nint hWnd, uint msg, nint wParam, nint lParam);
[StructLayout(LayoutKind.Sequential)]
private struct WNDCLASSW
{
public uint style;
public nint lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public nint hInstance;
public nint hIcon;
public nint hCursor;
public nint hbrBackground;
public nint lpszMenuName;
public nint lpszClassName;
}
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public nint hwnd;
public uint message;
public nint wParam;
public nint lParam;
public uint time;
public int ptX;
public int ptY;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass);
[DllImport("user32.dll", SetLastError = true)]
private static extern nint CreateWindowExW(
uint dwExStyle,
nint lpClassName,
nint lpWindowName,
uint dwStyle,
int x,
int y,
int nWidth,
int nHeight,
nint hWndParent,
nint hMenu,
nint hInstance,
nint lpParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern int GetMessageW(out MSG lpMsg, nint hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern nint DispatchMessageW(ref MSG msg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern nint DefWindowProcW(nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(nint hWnd);
[DllImport("user32.dll")]
private static extern void PostQuitMessage(int nExitCode);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnregisterClassW(nint lpClassName, nint hInstance);
[DllImport("kernel32.dll")]
private static extern nint GetModuleHandleW(nint lpModuleName);
private readonly ManualResetEvent _extensionDisposedEvent;
private readonly ManualResetEvent _windowCreated = new(false);
// Keep the delegate alive for the lifetime of the window to prevent GC collection.
// The GC may collect the delegate if it is only stored as a function pointer.
private WndProcDelegate? _wndProcDelegate;
private nint _hwnd;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="AppLifeMonitor"/> class and starts
/// the background message loop immediately.
/// </summary>
/// <param name="extensionDisposedEvent">
/// The event to signal when the OS requests the session to end (e.g. system shutdown,
/// user logoff, or Store-initiated update). Signalling this event causes the
/// <c>extensionDisposedEvent.WaitOne()</c> call in <c>Program.Main</c> to unblock so
/// the COM server can stop gracefully before the OS deadline expires.
/// </param>
public AppLifeMonitor(ManualResetEvent extensionDisposedEvent)
{
_extensionDisposedEvent = extensionDisposedEvent ?? throw new ArgumentNullException(nameof(extensionDisposedEvent));
var thread = new Thread(RunMessageLoop)
{
IsBackground = true,
Name = "AppLifeMonitor",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
// Block until the window is ready (or has failed to initialize) before returning.
_windowCreated.WaitOne();
}
private void RunMessageLoop()
{
// Use a process-specific class name to avoid collisions if the same extension
// is loaded multiple times in the same session.
var className = $"AppLifeMonitor_{Environment.ProcessId}";
var classNamePtr = Marshal.StringToHGlobalUni(className);
var hInstance = GetModuleHandleW(0);
var classRegistered = false;
try
{
// Store the delegate in a field so it is not collected by the GC.
_wndProcDelegate = HandleMessage;
var wndProcPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
var wndClass = new WNDCLASSW
{
lpfnWndProc = wndProcPtr,
hInstance = hInstance,
lpszClassName = classNamePtr,
};
var atom = RegisterClassW(ref wndClass);
if (atom == 0)
{
Trace.TraceWarning("AppLifeMonitor: RegisterClassW failed with error {0}", Marshal.GetLastWin32Error());
return;
}
classRegistered = true;
// Create a 0×0 invisible pop-up window.
// This must NOT be a message-only window (HWND_MESSAGE parent) because
// message-only windows are excluded from OS shutdown broadcasts.
_hwnd = CreateWindowExW(
dwExStyle: 0,
lpClassName: classNamePtr,
lpWindowName: 0,
dwStyle: WS_POPUP,
x: 0,
y: 0,
nWidth: 0,
nHeight: 0,
hWndParent: 0,
hMenu: 0,
hInstance: hInstance,
lpParam: 0);
if (_hwnd == 0)
{
Trace.TraceWarning("AppLifeMonitor: CreateWindowExW failed with error {0}", Marshal.GetLastWin32Error());
return;
}
// Signal that initialization succeeded before entering the message loop.
// The constructor's WaitOne() unblocks here.
_windowCreated.Set();
// Run the message loop until PostQuitMessage is called (WM_QUIT) or an error occurs.
int ret;
while ((ret = GetMessageW(out var msg, nint.Zero, 0, 0)) > 0)
{
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
if (ret == -1)
{
Trace.TraceWarning("AppLifeMonitor: GetMessageW failed with error {0}", Marshal.GetLastWin32Error());
}
}
finally
{
if (classRegistered)
{
UnregisterClassW(classNamePtr, hInstance);
}
// Always signal so the constructor's WaitOne() unblocks even on failure paths.
// Set() is idempotent, so calling it again after an early Set() in the try block is safe.
// Note: _windowCreated is not disposed here because there is a theoretical window
// between Set() and WaitOne() returning in the constructor where Dispose() could race.
// ManualResetEvent wraps a kernel object that is released when the process exits
// or the GC finalizes it; for a process-lifetime object this is acceptable.
_windowCreated.Set();
Marshal.FreeHGlobal(classNamePtr);
_wndProcDelegate = null;
}
}
private nint HandleMessage(nint hWnd, uint msg, nint wParam, nint lParam)
{
switch (msg)
{
case WM_QUERYENDSESSION:
// Return non-zero to permit the session to end.
return 1;
case WM_ENDSESSION:
// wParam is non-zero when the session is actually ending.
if (wParam != 0)
{
_extensionDisposedEvent.Set();
}
return 0;
case WM_CLOSE:
DestroyWindow(hWnd);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProcW(hWnd, msg, wParam, lParam);
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
var hwnd = Interlocked.Exchange(ref _hwnd, 0);
if (hwnd != 0)
{
// Post WM_CLOSE to our window on the message thread.
// The WndProc will call DestroyWindow → WM_DESTROY → PostQuitMessage,
// which unblocks GetMessageW and lets the background thread exit cleanly.
// The background thread's finally block will then signal and dispose _windowCreated.
PostMessageW(hwnd, WM_CLOSE, 0, 0);
}
}
}