Browse Source

Show a Throw/Debug/Ignore dialog for assertion failures; report cancel

Debug.Assert failures were routed straight to the single-button unhandled-
exception dialog and deduplicated by call site, so the only available action
was effectively "ignore". Restore the developer dialog the WPF host offered:
Throw raises the assertion as an exception, Debug breaks into the debugger,
Ignore continues, and Ignore All suppresses that call site for the session.
Asserts fire on the background decompiler thread, so the dialog marshals to the
UI thread and blocks the caller on a nested dispatcher frame until a choice is
made, matching how the WPF version stayed responsive.

Separately, cancelling a long-running operation tab left it blank; it now shows
"Operation was cancelled." so the frozen tab explains why it stopped.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 4 weeks ago
parent
commit
0ad96caf76
  1. 163
      ILSpy/AppEnv/AssertionFailedDialog.cs
  2. 4
      ILSpy/AppEnv/GlobalExceptionHandler.cs
  3. 47
      ILSpy/AppEnv/ILSpyTraceListener.cs
  4. 17
      ILSpy/Docking/DockWorkspace.cs

163
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
{
/// <summary>
/// The choice a developer makes when a <c>Debug.Assert</c> fires (see <see cref="ILSpyTraceListener"/>).
/// </summary>
enum AssertionAction
{
/// <summary>Raise an <see cref="AssertionFailedException"/> so the failure propagates up the call stack.</summary>
Throw,
/// <summary>Break into an attached debugger via <see cref="System.Diagnostics.Debugger.Break"/>.</summary>
Debug,
/// <summary>Continue past this one assert.</summary>
Ignore,
/// <summary>Continue and suppress every further assert raised from the same call site this session.</summary>
IgnoreAll,
}
/// <summary>
/// Modal dialog shown when a <c>Debug.Assert</c> fails, offering Throw / Debug / Ignore / Ignore All.
/// Asserts fire on whichever thread is decompiling (usually a background thread), so <see cref="Show"/>
/// 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.
/// </summary>
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<AssertionAction> 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;
}
}
}

4
ILSpy/AppEnv/GlobalExceptionHandler.cs

@ -52,8 +52,8 @@ namespace ILSpy.AppEnv
/// <summary> /// <summary>
/// Surfaces <paramref name="exception"/> through the same UI as an unhandled exception. /// Surfaces <paramref name="exception"/> through the same UI as an unhandled exception.
/// Used by <see cref="AssertSuppressor"/> to route <c>Debug.Assert</c> failures to the /// Lets call sites that swallow exceptions in a fire-and-forget continuation still report
/// regular exception dialog instead of letting them fail-fast the process. /// them through the standard dialog.
/// </summary> /// </summary>
public static void Show(Exception exception) => Report(exception); public static void Show(Exception exception) => Report(exception);

47
ILSpy/AppEnv/ILSpyTraceListener.cs

@ -24,13 +24,10 @@ using System.Linq;
namespace ILSpy.AppEnv namespace ILSpy.AppEnv
{ {
/// <summary> /// <summary>
/// Trace listener that downgrades <c>Debug.Assert</c> failures from process-killing /// Trace listener that intercepts <c>Debug.Assert</c> failures and shows a developer dialog
/// fail-fasts to a regular surfaced exception. The decompiler ships a number of asserts /// (Throw / Debug / Ignore / Ignore All) instead of the process-killing default fail-fast.
/// that fire on real-world (but unusual) IL — without this, hovering or decompiling such /// The decompiler ships a number of asserts that fire on real-world (but unusual) IL — without
/// a method would terminate the process. /// this, hovering or decompiling such a method would terminate the process.
///
/// We route through <see cref="GlobalExceptionHandler.Show"/> so asserts land in the same
/// dialog as any other unhandled exception.
/// </summary> /// </summary>
sealed class ILSpyTraceListener : DefaultTraceListener sealed class ILSpyTraceListener : DefaultTraceListener
{ {
@ -46,9 +43,11 @@ namespace ILSpy.AppEnv
AssertUiEnabled = false; AssertUiEnabled = false;
} }
// Dedup repeated asserts by call site so a decompile that hits the same assert in 200 // Call sites the developer chose "Ignore All" for; further asserts from these are dropped.
// methods only opens one dialog. readonly HashSet<string> ignoredTopFrames = new();
readonly HashSet<string> seenTopFrames = 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) public override void Fail(string? message)
=> Fail(message, null); => Fail(message, null);
@ -68,13 +67,33 @@ namespace ILSpy.AppEnv
if (frames.Length == 0) if (frames.Length == 0)
return; return;
var topFrame = frames[0]; var topFrame = frames[0];
lock (seenTopFrames) lock (ignoredTopFrames)
{ {
if (!seenTopFrames.Add(topFrame)) if (ignoredTopFrames.Contains(topFrame) || dialogIsOpen)
return; 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));
} }
} }

17
ILSpy/Docking/DockWorkspace.cs

@ -1023,7 +1023,7 @@ namespace ILSpy.Docking
} }
catch (OperationCanceledException) 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) catch (OperationCanceledException)
{ {
// User cancelled — leave the (empty) report tab; they can close it. content.ShowText(CreateCancelledOutput(title));
} }
} }
/// <summary>
/// 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.
/// </summary>
static TextView.AvaloniaEditTextOutput CreateCancelledOutput(string title)
{
var output = new TextView.AvaloniaEditTextOutput { Title = title };
output.Write(ICSharpCode.ILSpy.Properties.Resources.OperationWasCancelled);
output.WriteLine();
return output;
}
/// <summary> /// <summary>
/// Brings the tool pane with the given <paramref name="contentId"/> into view and /// Brings the tool pane with the given <paramref name="contentId"/> into view and
/// activates it. Delegates to <see cref="ILSpyDockFactory.ShowToolPane"/>, which /// activates it. Delegates to <see cref="ILSpyDockFactory.ShowToolPane"/>, which

Loading…
Cancel
Save