Skip to content

Commit d4bf82d

Browse files
committed
Add recoverable UI-thread exception dialog
Register a Dispatcher.UnhandledException handler and add a recoverable exception dialog flow so UI-thread exceptions can offer a "continue" option instead of forcing an exit. Program.ShowExceptionDialog now accepts an isRecoverable flag, sets an appropriate Sentry mechanism, only flushes Sentry for non-recoverable crashes, and returns whether the user chose to continue. App marks exceptions handled when the user continues.
1 parent 0589160 commit d4bf82d

2 files changed

Lines changed: 56 additions & 12 deletions

File tree

StabilityMatrix.Avalonia/App.axaml.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ public override void OnFrameworkInitializationCompleted()
151151
{
152152
base.OnFrameworkInitializationCompleted();
153153

154+
if (!Debugger.IsAttached || Program.Args.DebugExceptionDialog)
155+
{
156+
Dispatcher.UIThread.UnhandledException += Dispatcher_UnhandledException;
157+
}
158+
154159
if (Design.IsDesignMode)
155160
{
156161
DesignData.DesignData.Initialize();
@@ -1015,6 +1020,14 @@ private static void OnServiceProviderDisposing(ServiceProvider serviceProvider)
10151020
Logger.Trace("Disposing {Count} Disposables", disposables.Count);
10161021
}
10171022

1023+
private static void Dispatcher_UnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
1024+
{
1025+
if (Program.ShowExceptionDialog(e.Exception, true))
1026+
{
1027+
e.Handled = true;
1028+
}
1029+
}
1030+
10181031
private static void TaskScheduler_UnobservedTaskException(
10191032
object? sender,
10201033
UnobservedTaskExceptionEventArgs e

StabilityMatrix.Avalonia/Program.cs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ public static void Main(string[] args)
6969
});
7070

7171
if (
72-
parseResult.Errors.Any(
73-
x => x.Tag is ErrorType.HelpRequestedError or ErrorType.VersionRequestedError
72+
parseResult.Errors.Any(x =>
73+
x.Tag is ErrorType.HelpRequestedError or ErrorType.VersionRequestedError
7474
)
7575
)
7676
{
@@ -172,7 +172,7 @@ internal static void SetupAvaloniaApp()
172172
{
173173
BaseCachePath = Path.Combine(Path.GetTempPath(), "StabilityMatrix", "Cache"),
174174
CacheDuration = TimeSpan.FromDays(1),
175-
MaxMemoryCacheCount = 100
175+
MaxMemoryCacheCount = 100,
176176
}
177177
);
178178
}
@@ -198,7 +198,7 @@ public static AppBuilder BuildAvaloniaApp()
198198
app = app.With(
199199
new Win32PlatformOptions
200200
{
201-
RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software]
201+
RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software],
202202
}
203203
);
204204
}
@@ -216,7 +216,7 @@ public static AppBuilder BuildAvaloniaApp()
216216
.With(
217217
new AvaloniaNativePlatformOptions
218218
{
219-
RenderingMode = new[] { AvaloniaNativeRenderingMode.Software }
219+
RenderingMode = new[] { AvaloniaNativeRenderingMode.Software },
220220
}
221221
);
222222
}
@@ -402,14 +402,28 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc
402402
if (e.ExceptionObject is not Exception ex)
403403
return;
404404

405+
ShowExceptionDialog(ex, false);
406+
}
407+
408+
internal static bool ShowExceptionDialog(Exception ex, bool isRecoverable)
409+
{
405410
SentryId? sentryId = null;
406411

407412
// Exception automatically logged by Sentry if enabled
408413
if (SentrySdk.IsEnabled)
409414
{
410-
ex.SetSentryMechanism("AppDomain.UnhandledException", handled: false);
415+
ex.SetSentryMechanism(
416+
isRecoverable ? "Dispatcher.UnhandledException" : "AppDomain.UnhandledException",
417+
handled: isRecoverable
418+
);
419+
411420
sentryId = SentrySdk.CaptureException(ex);
412-
SentrySdk.FlushAsync().SafeFireAndForget();
421+
422+
if (!isRecoverable)
423+
{
424+
SentrySdk.FlushAsync().SafeFireAndForget();
425+
}
426+
413427
Logger.Warn(ex, "Unhandled {Type}: {Message}", ex.GetType().Name, ex.Message);
414428
}
415429
else
@@ -419,11 +433,15 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc
419433

420434
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
421435
{
422-
var dialog = new ExceptionDialog
436+
var viewModel = new ExceptionViewModel
423437
{
424-
DataContext = new ExceptionViewModel { Exception = ex, SentryId = sentryId }
438+
Exception = ex,
439+
SentryId = sentryId,
440+
IsRecoverable = isRecoverable,
425441
};
426442

443+
var dialog = new ExceptionDialog { DataContext = viewModel };
444+
427445
// We can only show dialog if main window exists, and is visible
428446
if (lifetime.MainWindow is { PlatformImpl: not null, IsVisible: true } mainWindow)
429447
{
@@ -441,25 +459,38 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc
441459
_ =>
442460
{
443461
cts.Cancel();
444-
ExitWithException(ex);
462+
if (!isRecoverable || !viewModel.IsContinueResult)
463+
{
464+
ExitWithException(ex);
465+
}
445466
},
446467
TaskScheduler.FromCurrentSynchronizationContext()
447468
);
448469

449470
Dispatcher.UIThread.MainLoop(cts.Token);
471+
return viewModel.IsContinueResult;
450472
}
451473
else
452474
{
453475
// No parent window available
454476
var cts = new CancellationTokenSource();
455-
// Exit on token cancellation
456-
cts.Token.Register(() => ExitWithException(ex));
477+
// Exit on token cancellation only if not recoverable or user didn't choose continue
478+
cts.Token.Register(() =>
479+
{
480+
if (!isRecoverable || !viewModel.IsContinueResult)
481+
{
482+
ExitWithException(ex);
483+
}
484+
});
457485

458486
dialog.ShowWithCts(cts);
459487

460488
Dispatcher.UIThread.MainLoop(cts.Token);
489+
return viewModel.IsContinueResult;
461490
}
462491
}
492+
493+
return false;
463494
}
464495

465496
[DoesNotReturn]

0 commit comments

Comments
 (0)