原标题:Handling Unhandled Exceptions in a Large C# WPF Application: Seeking Advice
I’m currently working on a large Windows desktop application built with C# and WPF. We’ve been facing an ongoing issue where, in some releases, we miss adding exception handlers in certain places, leading to unhandled exceptions and, consequently, application crashes. This has sparked some debate within our team. Some colleagues blame the QA team for not catching these cases, while others see it as a developer oversight. Personally, I think both perspectives have merit, but we need a more robust software solution to prevent minor exceptions from crashing the app.
To address this, I’m advocating for implementing a global exception handler coupled with a telemetry system (like Sentry). The goal is to catch unhandled exceptions, log them for monitoring, and prevent the application from crashing. This would help us track issues and improve stability over time.
However, there’s opposition from some team members. Their concerns are:
Catching exceptions globally might lead to the application functioning incorrectly (e.g., dialogs not showing or buttons not working). The application state could become inconsistent if we catch an exception globally and allow the app to continue running. I’m curious to hear about your experiences and insights on this matter. What approaches have you found effective in dealing with unhandled exceptions? How do you balance error handling and application stability in large desktop applications?
Thanks for your input!
问题回答
Much of this advice is not unqiue to WPF, but I employ a fairly simple Result pattern combined with an ICommand-based exception handling scheme.
Result Pattern
First, I have a Result and Result class that must always be returned whenever there is a possibility of a method failing. A simplified version:
public class Result
{
public Result(
string? error = null,
string? warning = null,
Exception? ex = null)
{
if (!string.IsNullOrEmpty(error) || ex != null)
{
this.Error = error;
this.InnerException = ex;
LogError?.Invoke(this, this);
IsSuccess = false;
}
else
{
this.IsSuccess = true;
}
}
public void Throw()
{
if (this.InnerException != null)
throw this.InnerException;
else if (!string.IsNullOrEmpty(this.Error))
throw new Exception(this.Error);
}
public bool IsSuccess { get; }
public string? Error { get; }
public string? Warning { get; }
public Exception? InnerException { get; }
// For global logging
public static event EventHandler? LogError;
public static event EventHandler? LogWarning;
// For the (hopefully) 99% case
public static readonly Result Success = new Result();
// Allows you to just return the Exception directly
public static implicit operator Result(Exception ex)
{
return new Result(ex: ex);
}
}
public class Result : Result
{
public Result(T value, string? warning = null) : base(null, warning, null)
{
this.Value = value;
}
public Result(string? error = null, Exception? ex = null)
:base(error, null, ex)
{
}
T? _Value;
public T? Value
{
get
{
if (!this.IsSuccess)
// This prevents us from accidentally using
// the value if we neglected to check for
// failure.
this.Throw();
return _Value;
}
private set
{
_Value = value;
}
}
// These simplify usage (returning values, etc.)
public static implicit operator T? (Result result)
{
return result.Value;
}
public static implicit operator Result(T value)
{
return new Result(value);
}
public static implicit operator Result(Exception ex)
{
return new Result(ex: ex);
}
}
Here s what it looks like in action:
public static Result Divide(double a, double b)
{
if (b == 0)
return new Result("Division by zero");
try
{
// can just return value thanks to implicit cast
return a / b;
}
catch (Exception ex)
{
// ditto
return ex;
}
}
public static void CheckResult()
{
var r = Divide(1, 2);
if (!r.IsSuccess)
r.Throw();
var r2 = Divide(2, 0);
if (r2.Value > 1)
{
// Oops, forgot to check for failure! Plain old
// exception will then be thrown.
}
}
Or a more practical example:
public static Result SafeFileOpen(string path)
{
try
{
return File.OpenRead(path);
}
catch (Exception ex)
{
return ex;
}
}
In my opinion, .NET is way too Exception crazed. Both corelib and third party libraries will throw exceptions for all kinds of unpredictable and inconsistent reasons, very often for things that are irrelevant or easily recovered from. That s why I employ the Result pattern in every single solitary bit of my code that could possibly fail, and wrap every call to code that I didn t write in an Exception handler that returns the Result-wrapped exception. Then my code can decide what to do.
ICommand-based Error Handling
The second prong of this approach is mostly relevant if you re using the MVVM pattern, so feel free to stop here if not. I implement my own ICommand class that wraps every Action in an exception handler while also making use of the Result pattern:
public class Command : ICommand, INotifyPropertyChanged
{
private Func _action;
public event EventHandler? CanExecuteChanged;
public event PropertyChangedEventHandler? PropertyChanged;
public Command(Func action)
{
this._action = action;
}
// You can bind this to a UI element that will display
// the error. A subclassed Button with a textblock beneath
// it would work great for this.
string? _Error;
public string? Error
{
get
{
return _Error;
}
set
{
if (_Error == value)
return;
_Error = value;
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(nameof(Error)));
}
}
public void Execute(object? parameter)
{
Result r;
try
{
this.Error = null;
r = this._action(parameter is T tparam ? tparam : default(T));
}
catch (Exception ex)
{
r = new Result(ex: ex);
}
if (!r.IsSuccess)
{
this.Error = r.Error ?? r.InnerException?.Message;
}
}
public bool CanExecute(object? parameter)
{
return true;
}
}
public class Command : Command
In my "large" WPF app I "personalized" the error / exception handling where it was needed. For the rest, I also implemented a global handler that addresses at least some of your concerns; including a "retry" option based on knowing "no great harm" will come at this point in this particular "mission critical" app. Note the "filtering" of the stack trace. (BTW, when a user is "in the field", an expressive message is the biggest help when communicating over a phone).
Application x:Class="SRM.App"
DispatcherUnhandledException="Application_DispatcherUnhandledException">
///
/// Unhandled Application errors.
///
private void Application_DispatcherUnhandledException( object sender, DispatcherUnhandledExceptionEventArgs e ) {
Exception ex = e.Exception;
//-------------------------------
// Format message.
//-------------------------------
string caption = ex.GetType().ToString();
StringBuilder sb = new StringBuilder();
sb.AppendLine( $"Exception: {ex.GetType().ToString()}" );
sb.AppendLine( $"Message: {ex.Message}" );
sb.AppendLine( $"Source: {ex.Source}" );
sb.AppendLine();
//-----------------------------------
// Dump "HT." trace lines.
// Skip "System." trace lines.
//-----------------------------------
sb.AppendLine( $"StackTrace:" );
string text = ex.StackTrace.Trim();
using ( StringReader sr = new StringReader( text ) ) {
while ( true ) {
string traceLine = sr.ReadLine();
if ( traceLine == null ) { break; }
traceLine = traceLine.Trim();
// Skip MS...; System...
if ( traceLine.StartsWith( "at MS." ) ) {
continue;
}
if ( traceLine.StartsWith( "at System." ) ) {
continue;
}
// Format.
int index1 = traceLine.IndexOf( " in " );
int index2 = traceLine.LastIndexOf( @"" );
string display;
if ( index1 > 0 && index2 > 0 ) {
display = traceLine.Substring( 0, index1 ) + " in " + traceLine.Substring( index2 + 1 );
} else {
display = traceLine;
}
display = display.Replace( "SRM.", "" );
sb.AppendLine( " " + display );
} // end while.
} // end using.
sb.AppendLine();
//-----------------------------------
//
//-----------------------------------
if ( ex.InnerException != null ) {
sb.AppendLine( "Inner Exception: " + ex.GetType().ToString() );
sb.AppendLine( "Inner Ex message: " + ex.InnerException.Message );
sb.AppendLine();
}
sb.AppendLine( "".PadRight( 80, - ) );
sb.AppendLine();
sb.AppendLine( "The program encountered a problem." );
sb.AppendLine();
sb.AppendLine( "Try to Continue (Yes), or exit (No)?" );
string msg = sb.ToString();
// Cannot use a "window" here.
MessageBoxResult result = MessageBox.Show( msg, caption,
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.Yes,
MessageBoxOptions.ServiceNotification );
if ( result == MessageBoxResult.Yes ) {
// Try to continue.
e.Handled = true;
//StartWorkers();
}
}
The goal is to catch unhandled exceptions, log them for monitoring
Yes, Do this! Also consider showing some helpful information etc, to make it easier to report the issue with all the necessary debugging details.
and prevent the application from crashing
No! Your colleges are correct in this regard. If you do not know what caused the fault you risk further damage if you continue. It is better to restart from a clean slate and report the error. You may however consider some type of recovery mechanism to reduce the risk of data loss for the user, like saving the most important part of the program state regularly.
Some colleagues blame the QA team for not catching these cases, while others see it as a developer oversight
In my opinion QA and dev should be part of the same team, and jointly responsible.
but we need a more robust software solution to prevent minor exceptions from crashing the app
There is no single way to prevent all crashes, but good coding practices and testing process will help. I would start by analyzing the type of exceptions you have problem with. Try to figure out what changes you could have done to prevent them. Code reviews? Training? Hire more QA? Refactoring of code? Better documentation?
In my experience exceptions can be broadly categorized as:
Programmer errors - Typical example would be null reference exceptions. These are errors that should never have occurred in the first place. The typical approach here are things like unit testing and code reviews. Tools, like "Nullable reference types" might also help.
Environmental errors - Typical examples include IO exceptions, or user input validation errors. These are to some extent unavoidable. The solution here is things like input validation and reading the documentation to verify that you are catching the exceptions you need to handle. These errors often need "friendly" error messages to help the user understand and correct the problem.
If you are sure an action do not have any affect on the overall application state you may consider just catching all exceptions and showing some generic error. But there is no silver bullet.
I m the only developer in my company, and am getting along well as an autodidact, but I know I m missing out on the education one gets from working with and having code reviewed by more senior devs.
...
I m pretty new to the Objective-C world and I have a long history with .net/C# so naturally I m inclined to use my C# wits.
Now here s the question: I feel really inclined to create some type of ...
I cannot figure out how to marshal a C++ CBitmap to a C# Bitmap or Image class.
My import looks like this:
[DllImport(@"test.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr ...
I have two EF entities. One has a property called HouseNumber. The other has two properties, one called StartHouseNumber and one called EndHouseNumber.
I want to create a many to many association ...
How to user GhostScript DLL to convert PDF to PDF/A. I know I kind of have to call the exported function of gsdll32.dll whose name is gsapi_init_with_args, but how do i pass the right arguments? BTW, ...
Maybe it s something I m doing wrong. I m just learning Linq because I m bored. And so far so good. I made a little program and it basically just outputs all matches (foreach) into a label control.
...