From 18a1591f12f313b2b01edadfa6a5e4d87552056b Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 6 Jun 2026 21:01:59 +0200 Subject: [PATCH] Surface fire-and-forget command failures immediately Commands and context-menu entries kick off async work as `_ = SomeAsync()`, discarding the task. When one faulted, the failure only surfaced via TaskScheduler.UnobservedTaskException after the GC finalized the dropped task -- late, on the finalizer thread, and disconnected from the gesture that caused it (a click would appear to do nothing, then a mysterious crash report arrived seconds later). Add a HandleExceptions() extension that observes such a task on the UI context and routes any fault to the existing GlobalExceptionHandler at the point of failure (treating cancellation as a normal outcome), and route the user-invoked command/entry sites through it. The global unhandled-exception net already existed; this just makes these failures prompt and attributable instead of deferred to GC. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Commands/CreateDiagramContextMenuEntry.cs | 2 +- ILSpy/Commands/DecompileAllCommand.cs | 6 +- .../Commands/ExportProjectContextMenuEntry.cs | 4 +- .../ExtractPackageEntryContextMenuEntry.cs | 4 +- ILSpy/Commands/FileCommands.cs | 6 +- ILSpy/Commands/GeneratePdbContextMenuEntry.cs | 2 +- ILSpy/Commands/HelpCommands.cs | 2 +- ILSpy/Commands/Pdb2XmlCommand.cs | 2 +- ILSpy/Commands/SaveCodeContextMenuEntry.cs | 2 +- ILSpy/Commands/SelectPdbContextMenuEntry.cs | 2 +- ILSpy/FireAndForgetExtensions.cs | 55 +++++++++++++++++++ ILSpy/TreeNodes/AssemblyTreeNode.cs | 2 +- ILSpy/TreeNodes/ImageListResourceEntryNode.cs | 2 +- ILSpy/TreeNodes/ResourceEntryNode.cs | 2 +- ILSpy/TreeNodes/ResourceTreeNode.cs | 2 +- ILSpy/TreeNodes/ResourcesFileTreeNode.cs | 2 +- 16 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 ILSpy/FireAndForgetExtensions.cs diff --git a/ILSpy/Commands/CreateDiagramContextMenuEntry.cs b/ILSpy/Commands/CreateDiagramContextMenuEntry.cs index 4e18c0a42..aa9af3995 100644 --- a/ILSpy/Commands/CreateDiagramContextMenuEntry.cs +++ b/ILSpy/Commands/CreateDiagramContextMenuEntry.cs @@ -66,7 +66,7 @@ namespace ILSpy.Commands var assembly = (context.SelectedTreeNodes?.FirstOrDefault() as AssemblyTreeNode)?.LoadedAssembly; if (assembly == null) return; - _ = ExecuteAsync(assembly.FileName); + ExecuteAsync(assembly.FileName).HandleExceptions(); } async Task ExecuteAsync(string assemblyFile) diff --git a/ILSpy/Commands/DecompileAllCommand.cs b/ILSpy/Commands/DecompileAllCommand.cs index 6a351bb60..2c37225ef 100644 --- a/ILSpy/Commands/DecompileAllCommand.cs +++ b/ILSpy/Commands/DecompileAllCommand.cs @@ -61,7 +61,7 @@ namespace ILSpy.Commands public override bool CanExecute(object? parameter) => Directory.Exists(OutputDir); - public override void Execute(object? parameter) => _ = ExecuteAsync(); + public override void Execute(object? parameter) => ExecuteAsync().HandleExceptions(); async Task ExecuteAsync() { @@ -132,7 +132,7 @@ namespace ILSpy.Commands public override bool CanExecute(object? parameter) => Directory.Exists(OutputDir); - public override void Execute(object? parameter) => _ = ExecuteAsync(); + public override void Execute(object? parameter) => ExecuteAsync().HandleExceptions(); async Task ExecuteAsync() { @@ -203,7 +203,7 @@ namespace ILSpy.Commands this.dockWorkspace = dockWorkspace; } - public override void Execute(object? parameter) => _ = ExecuteAsync(); + public override void Execute(object? parameter) => ExecuteAsync().HandleExceptions(); async Task ExecuteAsync() { diff --git a/ILSpy/Commands/ExportProjectContextMenuEntry.cs b/ILSpy/Commands/ExportProjectContextMenuEntry.cs index e65bb72e6..a0d7f93e0 100644 --- a/ILSpy/Commands/ExportProjectContextMenuEntry.cs +++ b/ILSpy/Commands/ExportProjectContextMenuEntry.cs @@ -57,8 +57,8 @@ namespace ILSpy.Commands { if (!ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out var assemblies, out var solutionMode)) return; - _ = ProjectExport.PromptAndExportAsync(assemblies, solutionMode, - languageService.CurrentLanguage, dockWorkspace, settingsService); + ProjectExport.PromptAndExportAsync(assemblies, solutionMode, + languageService.CurrentLanguage, dockWorkspace, settingsService).HandleExceptions(); } } } diff --git a/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs b/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs index d2907547b..01dd01b3b 100644 --- a/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs +++ b/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs @@ -73,7 +73,7 @@ namespace ILSpy.Commands .FirstOrDefault(asm => asm.PackageEntry == null); if (bundleNode == null) return; - _ = ExecuteAsync(selectedNodes); + ExecuteAsync(selectedNodes).HandleExceptions(); } async Task ExecuteAsync(SharpTreeNode[] selectedNodes) @@ -267,7 +267,7 @@ namespace ILSpy.Commands { if (context.SelectedTreeNodes is not [AssemblyTreeNode { PackageEntry: null } asm]) return; - _ = ExecuteAsync(asm); + ExecuteAsync(asm).HandleExceptions(); } async Task ExecuteAsync(AssemblyTreeNode asm) diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 88b2a2815..07625c736 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -110,7 +110,7 @@ namespace ILSpy.Commands as global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow; if (owner == null) return; - _ = ShowAsync(owner); + ShowAsync(owner).HandleExceptions(); } async System.Threading.Tasks.Task ShowAsync(global::Avalonia.Controls.Window owner) @@ -300,8 +300,8 @@ namespace ILSpy.Commands { if (!ProjectExport.TryGetExportableAssemblies(assemblyTreeModel.SelectedItems, out var assemblies, out var solutionMode)) return; - _ = ProjectExport.PromptAndExportAsync(assemblies, solutionMode, - languageService.CurrentLanguage, dockWorkspace, settingsService); + ProjectExport.PromptAndExportAsync(assemblies, solutionMode, + languageService.CurrentLanguage, dockWorkspace, settingsService).HandleExceptions(); } } diff --git a/ILSpy/Commands/GeneratePdbContextMenuEntry.cs b/ILSpy/Commands/GeneratePdbContextMenuEntry.cs index 6e3994fbf..a8d1ac6fa 100644 --- a/ILSpy/Commands/GeneratePdbContextMenuEntry.cs +++ b/ILSpy/Commands/GeneratePdbContextMenuEntry.cs @@ -69,7 +69,7 @@ namespace ILSpy.Commands var selectedNodes = context.SelectedTreeNodes?.OfType().ToArray(); if (selectedNodes == null || selectedNodes.Length == 0) return; - _ = ExecuteAsync(selectedNodes.Select(n => n.LoadedAssembly).ToArray()); + ExecuteAsync(selectedNodes.Select(n => n.LoadedAssembly).ToArray()).HandleExceptions(); } async Task ExecuteAsync(LoadedAssembly[] assemblies) diff --git a/ILSpy/Commands/HelpCommands.cs b/ILSpy/Commands/HelpCommands.cs index 12e89a505..9ff6777f3 100644 --- a/ILSpy/Commands/HelpCommands.cs +++ b/ILSpy/Commands/HelpCommands.cs @@ -39,6 +39,6 @@ namespace ILSpy.Commands // Force-checks regardless of the 7-day throttle and surfaces the result in the panel, // including an explicit "up to date" banner when no newer version is found. public override void Execute(object? parameter) - => _ = updatePanel.CheckIfUpdatesAvailableAsync(notifyOnUpToDate: true); + => updatePanel.CheckIfUpdatesAvailableAsync(notifyOnUpToDate: true).HandleExceptions(); } } diff --git a/ILSpy/Commands/Pdb2XmlCommand.cs b/ILSpy/Commands/Pdb2XmlCommand.cs index 876cb1962..785196f55 100644 --- a/ILSpy/Commands/Pdb2XmlCommand.cs +++ b/ILSpy/Commands/Pdb2XmlCommand.cs @@ -66,7 +66,7 @@ namespace ILSpy.Commands var nodes = assemblyTreeModel.SelectedItems.OfType().ToArray(); if (nodes.Length == 0) return; - _ = ExecuteAsync(nodes); + ExecuteAsync(nodes).HandleExceptions(); } async Task ExecuteAsync(AssemblyTreeNode[] nodes) diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs index c202ee480..de1d9a832 100644 --- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs +++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs @@ -64,7 +64,7 @@ namespace ILSpy.Commands if (SolutionExport.TryGetAssemblies(nodes, out var assemblies)) { - _ = SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace); + SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions(); return; } diff --git a/ILSpy/Commands/SelectPdbContextMenuEntry.cs b/ILSpy/Commands/SelectPdbContextMenuEntry.cs index 9d1afdcc1..c1d45c242 100644 --- a/ILSpy/Commands/SelectPdbContextMenuEntry.cs +++ b/ILSpy/Commands/SelectPdbContextMenuEntry.cs @@ -61,7 +61,7 @@ namespace ILSpy.Commands var assembly = (context.SelectedTreeNodes?.FirstOrDefault() as AssemblyTreeNode)?.LoadedAssembly; if (assembly == null) return; - _ = ExecuteAsync(assembly); + ExecuteAsync(assembly).HandleExceptions(); } async Task ExecuteAsync(ICSharpCode.ILSpyX.LoadedAssembly assembly) diff --git a/ILSpy/FireAndForgetExtensions.cs b/ILSpy/FireAndForgetExtensions.cs new file mode 100644 index 000000000..0c44343a5 --- /dev/null +++ b/ILSpy/FireAndForgetExtensions.cs @@ -0,0 +1,55 @@ +// 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 System.Threading.Tasks; + +using ILSpy.AppEnv; + +namespace ILSpy +{ + public static class FireAndForgetExtensions + { + /// + /// Observes a fire-and-forget task started from a command or event handler. Awaits it on the + /// captured (UI) context and routes any failure to + /// immediately, at the point of failure. Without this, a discarded faulted task + /// (_ = SomethingAsync()) only surfaces via + /// after the GC finalizes it — late, on + /// the finalizer thread, and disconnected from the gesture that triggered it. + /// is treated as a normal cooperative cancel, not an + /// error. + /// + public static async void HandleExceptions(this Task task) + { + ArgumentNullException.ThrowIfNull(task); + try + { + await task.ConfigureAwait(true); + } + catch (OperationCanceledException) + { + // Cooperative cancellation is an expected outcome, not a failure. + } + catch (Exception ex) + { + GlobalExceptionHandler.Show(ex); + } + } + } +} diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 21a1b9e6c..5c2e1d186 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -161,7 +161,7 @@ namespace ILSpy.TreeNodes var language = languageService.CurrentLanguage; if (string.IsNullOrEmpty(language.ProjectFileExtension)) return false; - _ = SaveAsProjectOrSingleFileAsync(language); + SaveAsProjectOrSingleFileAsync(language).HandleExceptions(); return true; } diff --git a/ILSpy/TreeNodes/ImageListResourceEntryNode.cs b/ILSpy/TreeNodes/ImageListResourceEntryNode.cs index 3d4e5a404..4da887af7 100644 --- a/ILSpy/TreeNodes/ImageListResourceEntryNode.cs +++ b/ILSpy/TreeNodes/ImageListResourceEntryNode.cs @@ -179,7 +179,7 @@ namespace ILSpy.ImageList public override bool Save() { - _ = SaveAsync(); + SaveAsync().HandleExceptions(); return true; } diff --git a/ILSpy/TreeNodes/ResourceEntryNode.cs b/ILSpy/TreeNodes/ResourceEntryNode.cs index f38a7527f..8b2728036 100644 --- a/ILSpy/TreeNodes/ResourceEntryNode.cs +++ b/ILSpy/TreeNodes/ResourceEntryNode.cs @@ -67,7 +67,7 @@ namespace ILSpy.TreeNodes public override bool Save() { - _ = SaveAsync(); + SaveAsync().HandleExceptions(); return true; } diff --git a/ILSpy/TreeNodes/ResourceTreeNode.cs b/ILSpy/TreeNodes/ResourceTreeNode.cs index 530afc43a..3543dcd39 100644 --- a/ILSpy/TreeNodes/ResourceTreeNode.cs +++ b/ILSpy/TreeNodes/ResourceTreeNode.cs @@ -66,7 +66,7 @@ namespace ILSpy.TreeNodes public override bool Save() { - _ = SaveAsync(); + SaveAsync().HandleExceptions(); return true; } diff --git a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs index d976771d3..491cc9fdb 100644 --- a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs +++ b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs @@ -134,7 +134,7 @@ namespace ILSpy.TreeNodes public override bool Save() { - _ = SaveDialogAsync(); + SaveDialogAsync().HandleExceptions(); return true; }