diff --git a/ILSpy/AppEnv/AssertionFailedDialog.cs b/ILSpy/AppEnv/AssertionFailedDialog.cs
new file mode 100644
index 000000000..aa8345a4e
--- /dev/null
+++ b/ILSpy/AppEnv/AssertionFailedDialog.cs
@@ -0,0 +1,163 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace ILSpy.AppEnv
+{
+ ///
+ /// The choice a developer makes when a Debug.Assert fires (see ).
+ ///
+ enum AssertionAction
+ {
+ /// Raise an so the failure propagates up the call stack.
+ Throw,
+ /// Break into an attached debugger via .
+ Debug,
+ /// Continue past this one assert.
+ Ignore,
+ /// Continue and suppress every further assert raised from the same call site this session.
+ IgnoreAll,
+ }
+
+ ///
+ /// Modal dialog shown when a Debug.Assert fails, offering Throw / Debug / Ignore / Ignore All.
+ /// Asserts fire on whichever thread is decompiling (usually a background thread), so
+ /// marshals to the UI thread and blocks the caller via a nested dispatcher frame until the developer
+ /// picks an action, then returns that choice to the asserting thread.
+ ///
+ static class AssertionFailedDialog
+ {
+ public static AssertionAction Show(string message, string? detailMessage, string stackTrace)
+ {
+ // Dispatcher.Invoke runs inline when already on the UI thread and otherwise blocks the
+ // calling (decompiler) thread until the dialog closes.
+ return Dispatcher.UIThread.Invoke(() => ShowOnUIThread(message, detailMessage, stackTrace));
+ }
+
+ static AssertionAction ShowOnUIThread(string message, string? detailMessage, string stackTrace)
+ {
+ Window? owner = null;
+ if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ owner = desktop.MainWindow;
+ if (owner is not { IsVisible: true })
+ {
+ // No window to host the dialog (headless, startup, or shutdown). Surface the assert
+ // as an exception rather than blocking on a frame that nothing will ever exit; in a
+ // test run this fails the test instead of silently passing over the assertion.
+ return AssertionAction.Throw;
+ }
+
+ // Esc / closing the window with no button maps to Ignore.
+ var result = AssertionAction.Ignore;
+ var window = new Window {
+ Title = "Assertion Failed",
+ Width = 720,
+ Height = 480,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ ShowInTaskbar = true, // the assert interrupts decompilation; make the dialog easy to find
+ };
+
+ void Pick(AssertionAction action)
+ {
+ result = action;
+ window.Close();
+ }
+
+ window.Content = BuildContent(message, detailMessage, stackTrace, Pick);
+ window.KeyBindings.Add(new KeyBinding {
+ Gesture = new KeyGesture(Key.Escape),
+ Command = new CommunityToolkit.Mvvm.Input.RelayCommand(window.Close),
+ });
+
+ // A nested dispatcher frame keeps the UI responsive while we wait, without returning to
+ // the caller (which may be a background decompiler thread blocked inside Dispatcher.Invoke).
+ var frame = new DispatcherFrame();
+ window.Closed += (_, _) => frame.Continue = false;
+ window.Show(owner);
+ Dispatcher.UIThread.PushFrame(frame);
+ return result;
+ }
+
+ static Control BuildContent(string message, string? detailMessage, string stackTrace, Action pick)
+ {
+ var summary = new TextBlock {
+ Margin = new Thickness(0, 0, 0, 8),
+ FontWeight = FontWeight.Bold,
+ TextWrapping = TextWrapping.Wrap,
+ Text = message,
+ };
+
+ var detailText = string.IsNullOrEmpty(detailMessage)
+ ? stackTrace
+ : detailMessage + Environment.NewLine + Environment.NewLine + stackTrace;
+ var details = new TextBox {
+ IsReadOnly = true,
+ AcceptsReturn = true,
+ TextWrapping = TextWrapping.NoWrap,
+ FontFamily = new FontFamily("Consolas, Menlo, Monospace"),
+ FontSize = 12,
+ Text = detailText,
+ };
+
+ var buttons = new StackPanel {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 8, 0, 0),
+ Spacing = 8,
+ Children = {
+ MakeButton("Throw", () => pick(AssertionAction.Throw), isDefault: false),
+ MakeButton("Debug", () => pick(AssertionAction.Debug), isDefault: false),
+ MakeButton("Ignore", () => pick(AssertionAction.Ignore), isDefault: true),
+ MakeButton("Ignore All", () => pick(AssertionAction.IgnoreAll), isDefault: false),
+ },
+ };
+
+ var grid = new Grid {
+ Margin = new Thickness(12),
+ RowDefinitions = new RowDefinitions("Auto,*,Auto"),
+ };
+ Grid.SetRow(summary, 0);
+ Grid.SetRow(details, 1);
+ Grid.SetRow(buttons, 2);
+ grid.Children.Add(summary);
+ grid.Children.Add(details);
+ grid.Children.Add(buttons);
+ return grid;
+ }
+
+ static Button MakeButton(string text, Action onClick, bool isDefault)
+ {
+ var button = new Button {
+ Content = text,
+ IsDefault = isDefault,
+ MinWidth = 80,
+ };
+ button.Click += (_, _) => onClick();
+ return button;
+ }
+ }
+}
diff --git a/ILSpy/AppEnv/GlobalExceptionHandler.cs b/ILSpy/AppEnv/GlobalExceptionHandler.cs
index 37539d240..b686c5edf 100644
--- a/ILSpy/AppEnv/GlobalExceptionHandler.cs
+++ b/ILSpy/AppEnv/GlobalExceptionHandler.cs
@@ -52,8 +52,8 @@ namespace ILSpy.AppEnv
///
/// Surfaces through the same UI as an unhandled exception.
- /// Used by to route Debug.Assert failures to the
- /// regular exception dialog instead of letting them fail-fast the process.
+ /// Lets call sites that swallow exceptions in a fire-and-forget continuation still report
+ /// them through the standard dialog.
///
public static void Show(Exception exception) => Report(exception);
diff --git a/ILSpy/AppEnv/ILSpyTraceListener.cs b/ILSpy/AppEnv/ILSpyTraceListener.cs
index 3706b35e4..501235673 100644
--- a/ILSpy/AppEnv/ILSpyTraceListener.cs
+++ b/ILSpy/AppEnv/ILSpyTraceListener.cs
@@ -24,13 +24,10 @@ using System.Linq;
namespace ILSpy.AppEnv
{
///
- /// Trace listener that downgrades Debug.Assert failures from process-killing
- /// fail-fasts to a regular surfaced exception. The decompiler ships a number of asserts
- /// that fire on real-world (but unusual) IL — without this, hovering or decompiling such
- /// a method would terminate the process.
- ///
- /// We route through so asserts land in the same
- /// dialog as any other unhandled exception.
+ /// Trace listener that intercepts Debug.Assert failures and shows a developer dialog
+ /// (Throw / Debug / Ignore / Ignore All) instead of the process-killing default fail-fast.
+ /// The decompiler ships a number of asserts that fire on real-world (but unusual) IL — without
+ /// this, hovering or decompiling such a method would terminate the process.
///
sealed class ILSpyTraceListener : DefaultTraceListener
{
@@ -46,9 +43,11 @@ namespace ILSpy.AppEnv
AssertUiEnabled = false;
}
- // Dedup repeated asserts by call site so a decompile that hits the same assert in 200
- // methods only opens one dialog.
- readonly HashSet seenTopFrames = new();
+ // Call sites the developer chose "Ignore All" for; further asserts from these are dropped.
+ readonly HashSet ignoredTopFrames = new();
+ // Guards against a second assert (e.g. raised while the dispatcher pumps during the dialog)
+ // stacking another dialog on top of the open one.
+ bool dialogIsOpen;
public override void Fail(string? message)
=> Fail(message, null);
@@ -68,13 +67,33 @@ namespace ILSpy.AppEnv
if (frames.Length == 0)
return;
var topFrame = frames[0];
- lock (seenTopFrames)
+ lock (ignoredTopFrames)
{
- if (!seenTopFrames.Add(topFrame))
+ if (ignoredTopFrames.Contains(topFrame) || dialogIsOpen)
return;
+ dialogIsOpen = true;
+ }
+ try
+ {
+ var trimmedStack = string.Join(Environment.NewLine, frames);
+ switch (AssertionFailedDialog.Show(message ?? "(no message)", detailMessage, trimmedStack))
+ {
+ case AssertionAction.Throw:
+ throw new AssertionFailedException(message ?? "(no message)", detailMessage, trimmedStack);
+ case AssertionAction.Debug:
+ Debugger.Break();
+ break;
+ case AssertionAction.IgnoreAll:
+ lock (ignoredTopFrames)
+ ignoredTopFrames.Add(topFrame);
+ break;
+ }
+ }
+ finally
+ {
+ lock (ignoredTopFrames)
+ dialogIsOpen = false;
}
- var trimmedStack = string.Join(Environment.NewLine, frames);
- GlobalExceptionHandler.Show(new AssertionFailedException(message ?? "(no message)", detailMessage, trimmedStack));
}
}
diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs
index 6b1ce02df..3aff65801 100644
--- a/ILSpy/Docking/DockWorkspace.cs
+++ b/ILSpy/Docking/DockWorkspace.cs
@@ -1023,7 +1023,7 @@ namespace ILSpy.Docking
}
catch (OperationCanceledException)
{
- // User cancelled — leave the (empty) report tab; they can close it.
+ content.ShowText(CreateCancelledOutput(title));
}
}
@@ -1052,10 +1052,23 @@ namespace ILSpy.Docking
}
catch (OperationCanceledException)
{
- // User cancelled — leave the (empty) report tab; they can close it.
+ content.ShowText(CreateCancelledOutput(title));
}
}
+ ///
+ /// Builds the report shown in a frozen operation tab when the user cancels it: the tab keeps
+ /// its title but its body is replaced with a short "Operation was cancelled." note so the tab
+ /// is not left blank.
+ ///
+ static TextView.AvaloniaEditTextOutput CreateCancelledOutput(string title)
+ {
+ var output = new TextView.AvaloniaEditTextOutput { Title = title };
+ output.Write(ICSharpCode.ILSpy.Properties.Resources.OperationWasCancelled);
+ output.WriteLine();
+ return output;
+ }
+
///
/// Brings the tool pane with the given into view and
/// activates it. Delegates to , which