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