Compare commits

..

4 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
f5b90e89da Address review: walk inner exceptions in IsExceptionFromUserPlugin
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 15:53:02 +08:00
copilot-swe-agent[bot]
5e62ab4955 Improve catch block comment in IsExceptionFromUserPlugin
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/cd2e901b-d98e-4f74-8fe3-714604091a9d

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-04-29 10:41:24 +00:00
copilot-swe-agent[bot]
f6890a0a5e Fix: suppress crash report popup for unhandled exceptions from user-installed plugins
When a third-party PowerToys Run plugin throws an unhandled exception that
propagates to the WPF dispatcher, PowerToys was showing a confusing crash
report dialog even though the exception was not caused by PowerToys itself.

Add IsExceptionFromUserPlugin() helper to ErrorReporting that inspects the
exception's stack trace frames. If any frame's assembly is loaded from
Constant.PluginsDirectory (the user-installed plugins folder), the exception
is treated as a plugin exception: it is logged at Error level but the crash
report window is not shown.

This fix was motivated by a third-party plugin
(Community.PowerToys.Run.Plugin.UniversalSearchSuggestions) throwing
DirectoryNotFoundException from UpdatePlugin() when the PowerToys Run Plugin
Updater directory didn't exist, causing a confusing crash popup on every launch.

Fixes #38033

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/cd2e901b-d98e-4f74-8fe3-714604091a9d

Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-04-29 10:40:28 +00:00
copilot-swe-agent[bot]
f5eb3bdb09 Initial plan 2026-04-29 08:49:24 +00:00
4 changed files with 72 additions and 133 deletions

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Threading;
@@ -61,17 +62,84 @@ namespace PowerLauncher.Helper
// Many bug reports because users see the "Report problem UI" after "the" crash with System.Runtime.InteropServices.COMException 0xD0000701 or 0x80263001.
// However, displaying this "Report problem UI" during WPF crashes, especially when DWM composition is changing, is not ideal; some users reported it hangs for up to a minute before the "Report problem UI" appears.
// This change modifies the behavior to log the exception instead of showing the "Report problem UI".
if (ExceptionHelper.IsRecoverableDwmCompositionException(e))
if (ExceptionHelper.IsRecoverableDwmCompositionException(e as System.Runtime.InteropServices.COMException))
{
var logger = LogManager.GetLogger(LoggerName);
logger.Error($"From {(isNotUIThread ? "non" : string.Empty)} UI thread's exception: {ExceptionFormatter.FormatException(e)}");
}
else if (IsExceptionFromUserPlugin(e))
{
// Exceptions thrown by user-installed third-party plugins should be logged
// but must not show the crash report window, since the exception is not
// caused by PowerToys itself and the popup is confusing and alarming to the user.
var logger = LogManager.GetLogger(LoggerName);
logger.Error($"From {(isNotUIThread ? "non" : string.Empty)} UI thread - Exception from user plugin: {ExceptionFormatter.FormatException(e)}");
}
else
{
Report(e, isNotUIThread);
}
}
/// <summary>
/// Determines whether an exception originated from a user-installed third-party plugin
/// (i.e., an assembly loaded from <see cref="Constant.PluginsDirectory"/>).
/// </summary>
private static bool IsExceptionFromUserPlugin(Exception e)
{
var current = e;
while (current != null)
{
if (HasPluginStackFrames(current))
{
return true;
}
if (current is AggregateException aggregateException)
{
foreach (var innerException in aggregateException.InnerExceptions)
{
if (IsExceptionFromUserPlugin(innerException))
{
return true;
}
}
}
current = current.InnerException;
}
return false;
}
private static bool HasPluginStackFrames(Exception e)
{
try
{
var pluginsDir = Constant.PluginsDirectory;
var stackTrace = new StackTrace(e, fNeedFileInfo: false);
foreach (var frame in stackTrace.GetFrames() ?? [])
{
var assemblyLocation = frame.GetMethod()?.DeclaringType?.Assembly?.Location;
if (!string.IsNullOrEmpty(assemblyLocation) &&
assemblyLocation.StartsWith(pluginsDir, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch (System.Exception)
{
// Reflection-based stack inspection can throw a variety of exceptions
// (e.g., SecurityException, TypeLoadException, BadImageFormatException).
// In every failure case the safe fallback is to treat the exception as
// NOT originating from a plugin so that it still surfaces as a crash report.
}
return false;
}
private static void Report(Exception e, bool waitForClose)
{
if (e != null)

View File

@@ -3,7 +3,6 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Reflection;
using System.Runtime.InteropServices;
namespace PowerLauncher.Helper
@@ -23,13 +22,6 @@ namespace PowerLauncher.Helper
/// </summary>
internal static bool IsRecoverableDwmCompositionException(Exception exception)
{
// Unwrap TargetInvocationException to get the underlying exception, since WPF's internal
// theme-change mechanism invokes handlers via reflection which wraps exceptions this way.
if (exception is TargetInvocationException && exception.InnerException != null)
{
exception = exception.InnerException;
}
if (exception is not COMException comException)
{
return false;

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
@@ -159,16 +160,12 @@ namespace PowerLauncher.Helper
return;
}
catch (Exception ex) when (ExceptionHelper.IsRecoverableDwmCompositionException(ex))
catch (COMException ex) when (ExceptionHelper.IsRecoverableDwmCompositionException(ex))
{
var logHResult = (ex is System.Reflection.TargetInvocationException tie && tie.InnerException != null)
? tie.InnerException.HResult
: ex.HResult;
switch (attempt)
{
case 1:
Log.Warn($"Desktop composition is disabled (HRESULT: 0x{logHResult:X}). Scheduling retries for theme update.", typeof(ThemeManager));
Log.Warn($"Desktop composition is disabled (HRESULT: 0x{ex.HResult:X}). Scheduling retries for theme update.", typeof(ThemeManager));
delayMs = InitialDelayMs;
break;
case < maxAttempts:

View File

@@ -1,118 +0,0 @@
// 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.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerLauncher.Helper;
namespace Wox.Test;
[TestClass]
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 naming conventions")]
public class ExceptionHelperTest
{
private const int DWM_E_COMPOSITIONDISABLED = unchecked((int)0x80263001);
private const int STATUS_MESSAGE_LOST_HR = unchecked((int)0xD0000701);
private const string PresentationFrameworkSource = "PresentationFramework";
[TestMethod]
public void IsRecoverableDwmCompositionException_NullException_ReturnsFalse()
{
Assert.IsFalse(ExceptionHelper.IsRecoverableDwmCompositionException(null));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_NonCOMException_ReturnsFalse()
{
var ex = new InvalidOperationException("Test");
Assert.IsFalse(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_COMException_CompositionDisabled_ReturnsTrue()
{
var ex = new COMException("Desktop composition is disabled", DWM_E_COMPOSITIONDISABLED);
Assert.IsTrue(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_COMException_OtherHResult_ReturnsFalse()
{
var ex = new COMException("Some other COM error", unchecked((int)0x80004005));
Assert.IsFalse(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_TargetInvocationException_WrappingCompositionDisabled_ReturnsTrue()
{
var inner = new COMException("Desktop composition is disabled", DWM_E_COMPOSITIONDISABLED);
var ex = new TargetInvocationException("Invocation failed", inner);
Assert.IsTrue(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_TargetInvocationException_WrappingMessageLostFromPresentationFramework_ReturnsTrue()
{
var inner = new COMException("Message lost", STATUS_MESSAGE_LOST_HR)
{
Source = PresentationFrameworkSource,
};
var ex = new TargetInvocationException("Invocation failed", inner);
Assert.IsTrue(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_TargetInvocationException_WrappingDwmCompositionChangedStackTrace_ReturnsTrue()
{
var inner = CreateDwmCompositionChangedComException();
var ex = new TargetInvocationException("Invocation failed", inner);
Assert.IsTrue(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_TargetInvocationException_WrappingUnrelatedCOMException_ReturnsFalse()
{
var inner = new COMException("Unrelated", unchecked((int)0x80004005));
var ex = new TargetInvocationException("Invocation failed", inner);
Assert.IsFalse(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_TargetInvocationException_WrappingNonCOMException_ReturnsFalse()
{
var inner = new InvalidOperationException("Not a COM exception");
var ex = new TargetInvocationException("Invocation failed", inner);
Assert.IsFalse(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
[TestMethod]
public void IsRecoverableDwmCompositionException_TargetInvocationException_NullInner_ReturnsFalse()
{
var ex = new TargetInvocationException("No inner", null);
Assert.IsFalse(ExceptionHelper.IsRecoverableDwmCompositionException(ex));
}
private static COMException CreateDwmCompositionChangedComException()
{
try
{
ThrowDwmCompositionChangedComException();
throw new AssertFailedException("Expected COMException to be thrown.");
}
catch (COMException ex)
{
return ex;
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ThrowDwmCompositionChangedComException()
{
throw new COMException("DWM composition changed", unchecked((int)0x80004005));
}
}