Skip to content

PowerToys Run: suppress crash report popup for unhandled exceptions thrown by user-installed plugins#47369

Closed
Copilot wants to merge 4 commits into
mainfrom
copilot/fix-directory-not-found-error
Closed

PowerToys Run: suppress crash report popup for unhandled exceptions thrown by user-installed plugins#47369
Copilot wants to merge 4 commits into
mainfrom
copilot/fix-directory-not-found-error

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 29, 2026

Summary of the Pull Request

Third-party plugins can throw unhandled exceptions that propagate via Task.ThrowAsync to the WPF dispatcher, causing PowerToys to display the crash report window—even though PowerToys itself is healthy. This change detects such plugin-originated exceptions and logs them instead of surfacing the crash dialog.

PR Checklist

  • 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
  • Localization: All end-user-facing strings can be localized
  • Dev docs: Added/updated
  • New binaries: Added on the required places
  • Documentation updated: If checked, please file a pull request on our docs repo and link it here: #xxx

Detailed Description of the Pull Request / Additional comments

Root cause: A plugin (Community.PowerToys.Run.Plugin.UniversalSearchSuggestions) calls an UpdatePlugin() async method that opens a FileStream to a path whose directory doesn't exist (…\Plugins\PowerToys Run Plugin Updater\PowerToys Run Plugin Updater.exe). The resulting DirectoryNotFoundException is captured by the .NET task machinery and re-thrown on the WPF dispatcher via Task.ThrowAsync, reaching DispatcherUnhandledException—which unconditionally showed the crash report window.

Change in ErrorReporting.cs:

  • Added IsExceptionFromUserPlugin(Exception e): walks the full exception chain — iterating via InnerException and recursing into AggregateException.InnerExceptions — to check if any stack frame's assembly is loaded from Constant.PluginsDirectory (%LOCALAPPDATA%\Microsoft\PowerToys\PowerToys Run\Plugins). This ensures plugin exceptions that are wrapped by the .NET task machinery (e.g. inside an AggregateException or TargetInvocationException) are correctly identified.
  • Extracted HasPluginStackFrames(Exception e) as a dedicated helper that inspects the stack frames of a single exception instance.
  • In HandleException, added an else if branch: plugin-originated exceptions are logged at Error level; the ReportWindow is not shown.
  • Reflection failures during stack inspection fall back to false, so the crash report still surfaces for any exception that cannot be attributed to a plugin.
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;
}

Preinstalled PowerToys plugins (in Constant.PreinstalledDirectory) are unaffected and will still trigger the crash report if they throw unhandled exceptions.

Validation Steps Performed

  • Code review of the exception propagation path through Task.ThrowAsyncDispatcherUnhandledExceptionHandleException.
  • Confirmed Constant.PluginsDirectory correctly resolves to the user plugin folder; preinstalled plugins in Constant.PreinstalledDirectory are excluded from the suppression logic.
  • Verified that wrapped exceptions (e.g. AggregateException containing a plugin exception) are correctly identified by the recursive exception chain walk.
  • Verified the catch block fallback ensures no regression for genuine PowerToys crashes.

Copilot AI and others added 2 commits April 29, 2026 10:40
…nstalled 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>
Copilot AI changed the title [WIP] Fix System.IO.DirectoryNotFoundException in PowerToys Run PowerToys Run: suppress crash report popup for unhandled exceptions thrown by user-installed plugins Apr 29, 2026
Copilot AI requested a review from MuyuanMS April 29, 2026 10:42
@niels9001 niels9001 added the Product-PowerToys Run Improved app launch PT Run (Win+R) Window label Apr 29, 2026
@MuyuanMS MuyuanMS requested a review from Copilot May 14, 2026 03:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Suppresses the PowerToys Run crash report popup for unhandled exceptions whose stack trace contains frames from assemblies loaded out of the user-installed plugin directory (Constant.PluginsDirectory). Such exceptions are now logged at Error level instead of surfacing the ReportWindow. Preinstalled plugins remain unaffected.

Changes:

  • Added a new IsExceptionFromUserPlugin(Exception) helper that walks the exception's stack frames and matches assembly locations against the user plugins directory.
  • Inserted an else if branch in HandleException that logs plugin-originated exceptions instead of opening the crash report window.
  • Added a try/catch fallback so reflection failures during stack inspection default to "not a plugin" (preserving the existing crash-report behavior).
Comments suppressed due to low confidence (2)

src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs:108

  • Assembly.Location returns an empty string for assemblies loaded from a byte array or via single-file/collectible contexts. While the empty-string case is guarded against here, this also means a plugin loaded via Assembly.Load(byte[]) (some plugin frameworks do this) will not be detected. More importantly, comparing path prefixes with StartsWith is fragile if Constant.PluginsDirectory is not normalized (no trailing separator, mixed separators, 8.3 short-name form, or symlinks); a plugin whose path differs only in case/normalization may be missed, and a sibling directory whose name shares the prefix (e.g. …\Plugins-backup\…) could match falsely. Consider normalizing both paths via Path.GetFullPath and ensuring a directory separator at the end of pluginsDir before comparing.
                foreach (var frame in stackTrace.GetFrames() ?? [])
                {
                    var assemblyLocation = frame.GetMethod()?.DeclaringType?.Assembly?.Location;
                    if (!string.IsNullOrEmpty(assemblyLocation) &&
                        assemblyLocation.StartsWith(pluginsDir, StringComparison.OrdinalIgnoreCase))
                    {
                        return true;
                    }
                }

src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs:116

  • Catching the bare base System.Exception here is broader than necessary and will swallow genuinely unexpected errors (including OutOfMemoryException/StackOverflowException-adjacent issues that, while not catchable themselves, may be accompanied by other fatal conditions). The comment lists the expected reflection failures (SecurityException, TypeLoadException, BadImageFormatException, plus FileNotFoundException/FileLoadException); catching those explicitly — or at minimum logging the swallowed exception at Debug/Warn — would make this code easier to diagnose if it ever silently misclassifies.
            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.
            }

Comment on lines +88 to +119
private static bool IsExceptionFromUserPlugin(Exception e)
{
if (e == null)
{
return false;
}

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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: IsExceptionFromUserPlugin now walks the full exception chain including InnerException and AggregateException.InnerExceptions, so wrapped plugin exceptions are correctly identified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MuyuanMS
Copy link
Copy Markdown
Contributor

@copilot please re-review the latest changes.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 14, 2026

@copilot please re-review the latest changes.

The latest changes look correct. IsExceptionFromUserPlugin now walks the full exception chain: it iterates via InnerException and handles AggregateException.InnerExceptions recursively, so plugin exceptions wrapped by the task infrastructure are properly detected. The stack frame inspection is in a dedicated HasPluginStackFrames helper to keep the logic clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Product-PowerToys Run Improved app launch PT Run (Win+R) Window

Projects

None yet

4 participants