Browse Source

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
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
18a1591f12
  1. 2
      ILSpy/Commands/CreateDiagramContextMenuEntry.cs
  2. 6
      ILSpy/Commands/DecompileAllCommand.cs
  3. 4
      ILSpy/Commands/ExportProjectContextMenuEntry.cs
  4. 4
      ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs
  5. 6
      ILSpy/Commands/FileCommands.cs
  6. 2
      ILSpy/Commands/GeneratePdbContextMenuEntry.cs
  7. 2
      ILSpy/Commands/HelpCommands.cs
  8. 2
      ILSpy/Commands/Pdb2XmlCommand.cs
  9. 2
      ILSpy/Commands/SaveCodeContextMenuEntry.cs
  10. 2
      ILSpy/Commands/SelectPdbContextMenuEntry.cs
  11. 55
      ILSpy/FireAndForgetExtensions.cs
  12. 2
      ILSpy/TreeNodes/AssemblyTreeNode.cs
  13. 2
      ILSpy/TreeNodes/ImageListResourceEntryNode.cs
  14. 2
      ILSpy/TreeNodes/ResourceEntryNode.cs
  15. 2
      ILSpy/TreeNodes/ResourceTreeNode.cs
  16. 2
      ILSpy/TreeNodes/ResourcesFileTreeNode.cs

2
ILSpy/Commands/CreateDiagramContextMenuEntry.cs

@ -66,7 +66,7 @@ namespace ILSpy.Commands @@ -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)

6
ILSpy/Commands/DecompileAllCommand.cs

@ -61,7 +61,7 @@ namespace ILSpy.Commands @@ -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 @@ -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 @@ -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()
{

4
ILSpy/Commands/ExportProjectContextMenuEntry.cs

@ -57,8 +57,8 @@ namespace ILSpy.Commands @@ -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();
}
}
}

4
ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs

@ -73,7 +73,7 @@ namespace ILSpy.Commands @@ -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 @@ -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)

6
ILSpy/Commands/FileCommands.cs

@ -110,7 +110,7 @@ namespace ILSpy.Commands @@ -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 @@ -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();
}
}

2
ILSpy/Commands/GeneratePdbContextMenuEntry.cs

@ -69,7 +69,7 @@ namespace ILSpy.Commands @@ -69,7 +69,7 @@ namespace ILSpy.Commands
var selectedNodes = context.SelectedTreeNodes?.OfType<AssemblyTreeNode>().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)

2
ILSpy/Commands/HelpCommands.cs

@ -39,6 +39,6 @@ namespace ILSpy.Commands @@ -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();
}
}

2
ILSpy/Commands/Pdb2XmlCommand.cs

@ -66,7 +66,7 @@ namespace ILSpy.Commands @@ -66,7 +66,7 @@ namespace ILSpy.Commands
var nodes = assemblyTreeModel.SelectedItems.OfType<AssemblyTreeNode>().ToArray();
if (nodes.Length == 0)
return;
_ = ExecuteAsync(nodes);
ExecuteAsync(nodes).HandleExceptions();
}
async Task ExecuteAsync(AssemblyTreeNode[] nodes)

2
ILSpy/Commands/SaveCodeContextMenuEntry.cs

@ -64,7 +64,7 @@ namespace ILSpy.Commands @@ -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;
}

2
ILSpy/Commands/SelectPdbContextMenuEntry.cs

@ -61,7 +61,7 @@ namespace ILSpy.Commands @@ -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)

55
ILSpy/FireAndForgetExtensions.cs

@ -0,0 +1,55 @@ @@ -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
{
/// <summary>
/// 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 <see cref="GlobalExceptionHandler"/>
/// immediately, at the point of failure. Without this, a discarded faulted task
/// (<c>_ = SomethingAsync()</c>) only surfaces via
/// <see cref="TaskScheduler.UnobservedTaskException"/> after the GC finalizes it — late, on
/// the finalizer thread, and disconnected from the gesture that triggered it.
/// <see cref="OperationCanceledException"/> is treated as a normal cooperative cancel, not an
/// error.
/// </summary>
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);
}
}
}
}

2
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -161,7 +161,7 @@ namespace ILSpy.TreeNodes @@ -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;
}

2
ILSpy/TreeNodes/ImageListResourceEntryNode.cs

@ -179,7 +179,7 @@ namespace ILSpy.ImageList @@ -179,7 +179,7 @@ namespace ILSpy.ImageList
public override bool Save()
{
_ = SaveAsync();
SaveAsync().HandleExceptions();
return true;
}

2
ILSpy/TreeNodes/ResourceEntryNode.cs

@ -67,7 +67,7 @@ namespace ILSpy.TreeNodes @@ -67,7 +67,7 @@ namespace ILSpy.TreeNodes
public override bool Save()
{
_ = SaveAsync();
SaveAsync().HandleExceptions();
return true;
}

2
ILSpy/TreeNodes/ResourceTreeNode.cs

@ -66,7 +66,7 @@ namespace ILSpy.TreeNodes @@ -66,7 +66,7 @@ namespace ILSpy.TreeNodes
public override bool Save()
{
_ = SaveAsync();
SaveAsync().HandleExceptions();
return true;
}

2
ILSpy/TreeNodes/ResourcesFileTreeNode.cs

@ -134,7 +134,7 @@ namespace ILSpy.TreeNodes @@ -134,7 +134,7 @@ namespace ILSpy.TreeNodes
public override bool Save()
{
_ = SaveDialogAsync();
SaveDialogAsync().HandleExceptions();
return true;
}

Loading…
Cancel
Save