GlobalErrorHandler.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using ManagedCommon; 6 using Microsoft.CmdPal.Core.Common.Services.Reports; 7 using Windows.Win32; 8 using Windows.Win32.Foundation; 9 using Windows.Win32.UI.WindowsAndMessaging; 10 using SystemUnhandledExceptionEventArgs = System.UnhandledExceptionEventArgs; 11 using XamlUnhandledExceptionEventArgs = Microsoft.UI.Xaml.UnhandledExceptionEventArgs; 12 13 namespace Microsoft.CmdPal.UI.Helpers; 14 15 /// <summary> 16 /// Global error handler for Command Palette. 17 /// </summary> 18 internal sealed partial class GlobalErrorHandler : IDisposable 19 { 20 private readonly ErrorReportBuilder _errorReportBuilder = new(); 21 private Options? _options; 22 private App? _app; 23 24 // GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available. 25 internal void Register(App app, Options options) 26 { 27 ArgumentNullException.ThrowIfNull(app); 28 ArgumentNullException.ThrowIfNull(options); 29 30 _options = options; 31 32 _app = app; 33 _app.UnhandledException += App_UnhandledException; 34 TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; 35 AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; 36 } 37 38 private void App_UnhandledException(object sender, XamlUnhandledExceptionEventArgs e) 39 { 40 // Exceptions thrown on the main UI thread are handled here. 41 if (e.Exception != null) 42 { 43 HandleException(e.Exception, Context.MainThreadException); 44 } 45 } 46 47 private void CurrentDomain_UnhandledException(object sender, SystemUnhandledExceptionEventArgs e) 48 { 49 // Exceptions thrown on background threads are handled here. 50 if (e.ExceptionObject is Exception ex) 51 { 52 HandleException(ex, Context.AppDomainUnhandledException); 53 } 54 } 55 56 private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) 57 { 58 // This event is raised only when a faulted Task is garbage-collected 59 // without its exception being observed. It is NOT raised immediately 60 // when the Task faults; timing depends on GC finalization. 61 e.SetObserved(); 62 HandleException(e.Exception, Context.UnobservedTaskException); 63 } 64 65 private void HandleException(Exception ex, Context context) 66 { 67 Logger.LogError($"Unhandled exception detected ({context})", ex); 68 69 if (context == Context.MainThreadException) 70 { 71 var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true); 72 73 StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true); 74 75 string message; 76 string caption; 77 try 78 { 79 message = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Message"); 80 caption = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Caption"); 81 } 82 catch 83 { 84 // The resource loader may not be available if the exception occurred during startup. 85 // Fall back to hardcoded strings in that case. 86 message = "Command Palette has encountered a fatal error and must close."; 87 caption = "Command Palette - Fatal error"; 88 } 89 90 PInvoke.MessageBox( 91 HWND.Null, 92 message, 93 caption, 94 MESSAGEBOX_STYLE.MB_ICONERROR); 95 } 96 } 97 98 private static string? StoreReport(string report, bool storeOnDesktop) 99 { 100 // Generate a unique name for the report file; include timestamp and a random zero-padded number to avoid collisions 101 // in case of crash storm. 102 var name = FormattableString.Invariant($"CmdPal_ErrorReport_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{Random.Shared.Next(100000):D5}.log"); 103 104 // Always store a copy in log directory, this way it is available for Bug Report Tool 105 string? reportPath = null; 106 if (Logger.CurrentVersionLogDirectoryPath != null) 107 { 108 reportPath = Save(report, name, static () => Logger.CurrentVersionLogDirectoryPath); 109 } 110 111 // Optionally store a copy on the desktop for user (in)convenience 112 if (storeOnDesktop) 113 { 114 var path = Save(report, name, static () => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory)); 115 116 // show the desktop copy if both succeeded 117 if (path != null) 118 { 119 reportPath = path; 120 } 121 } 122 123 return reportPath; 124 125 static string? Save(string reportContent, string reportFileName, Func<string> directory) 126 { 127 try 128 { 129 var logDirectory = directory(); 130 Directory.CreateDirectory(logDirectory); 131 var reportFilePath = Path.Combine(logDirectory, reportFileName); 132 File.WriteAllText(reportFilePath, reportContent); 133 return reportFilePath; 134 } 135 catch (Exception ex) 136 { 137 Logger.LogError("Failed to store exception report", ex); 138 return null; 139 } 140 } 141 } 142 143 public void Dispose() 144 { 145 _app?.UnhandledException -= App_UnhandledException; 146 TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException; 147 AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException; 148 } 149 150 private enum Context 151 { 152 Unknown = 0, 153 MainThreadException, 154 BackgroundThreadException, 155 UnobservedTaskException, 156 AppDomainUnhandledException, 157 } 158 159 /// <summary> 160 /// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions 161 /// (what to log, what to show to the user, and where to store reports). 162 /// </summary> 163 internal sealed record Options 164 { 165 /// <summary> 166 /// Gets the default configuration. 167 /// </summary> 168 public static Options Default { get; } = new(); 169 170 /// <summary> 171 /// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports. 172 /// </summary> 173 public bool RedactPii { get; init; } = true; 174 175 /// <summary> 176 /// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory. 177 /// </summary> 178 public bool StoreReportOnUserDesktop { get; init; } 179 } 180 }