Browse Source

Surface recovered export failures in the report the user actually sees

An export's ITextOutput goes nowhere: ProjectExporter and SolutionWriter both
hand the language a throwaway PlainTextOutput and build their own status
report, so a language writing failures into that output is invisible. The
failures travel on DecompilationOptions instead and are rendered by the caller
that owns the report - each one with its full exception in a collapsed fold,
which is what makes a bug report actionable.

Drive-by: WriteExceptionDetails split the exception text without trimming, so
for exceptions rendering a trailing newline the fold reached one line past the
last frame and swallowed the line behind it; and the tab's own decompilation-
failure path had regressed to dumping a raw stack trace instead of using that
helper.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
pull/3976/head
Siegfried Pammer 1 month ago committed by Siegfried Pammer
parent
commit
1c8a6bb781
  1. 67
      ILSpy.Tests/Languages/ProjectExportTests.cs
  2. 65
      ILSpy.Tests/SmartTextOutputExtensionsTests.cs
  3. 1
      ILSpy/Commands/ProjectExport.cs
  4. 48
      ILSpy/Commands/ProjectExporter.cs
  5. 9
      ILSpy/DecompilationOptions.cs
  6. 18
      ILSpy/Languages/CSharpLanguage.cs
  7. 4
      ILSpy/SmartTextOutputExtensions.cs
  8. 21
      ILSpy/SolutionWriter.cs
  9. 6
      ILSpy/TextView/DecompilerTabPageModel.cs

67
ILSpy.Tests/Languages/ProjectExportTests.cs

@ -25,12 +25,16 @@ using Avalonia.Headless.NUnit; @@ -25,12 +25,16 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.Solution;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Commands;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.ViewModels;
using ICSharpCode.ILSpy.Views;
using ICSharpCode.ILSpyX;
using NUnit.Framework;
@ -63,6 +67,69 @@ public class ProjectExportTests @@ -63,6 +67,69 @@ public class ProjectExportTests
cs.ProjectFileExtension.Should().Be(".csproj");
}
/// <summary>
/// An export finishes even when parts of the assembly cannot be decompiled, so the failures
/// have to reach the result report: the ITextOutput handed to the language is thrown away by
/// the export path, and the report is all the user ever sees.
/// </summary>
[AvaloniaTest]
public async Task Export_Report_Names_The_Failures_And_Where_To_Report_Them()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var loaded = await vm.OpenFixtureAsync();
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyExport_" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var options = new ProjectExportOptions(tempDir, UseSdkStyleProjectFormat: true,
UseNestedDirectoriesForNamespaces: false, RemoveDeadCode: false, RemoveDeadStores: false,
UseDebugSymbols: false, StrongNameKeyFile: null, GeneratePdb: false,
EmbedSourceFilesInPdb: false);
var result = await ProjectExporter.ExportAsync([loaded], solutionMode: false, options,
new DecompilerSettings(), new FailingLanguage(), progress: null, default);
var sw = new StringWriter();
ProjectExporter.WriteDecompilationErrors(new PlainTextOutput(sw), result.Errors);
string written = sw.ToString();
result.Success.Should().BeTrue("a recovered failure still produces a project");
written.Should().Contain("Error decompiling C.M",
"the failing member must be named");
written.Should().Contain("Simulated failure",
"the exception the export recovered from must show up, stack trace and all");
written.Should().Contain(CSharpDecompiler.DecompilationErrorReportUrl,
"the report is where the user is asked to file the bug");
}
finally
{
try
{ Directory.Delete(tempDir, recursive: true); }
catch { /* best-effort */ }
}
}
/// <summary>Stands in for a decompiler that recovered from a failure while exporting.</summary>
sealed class FailingLanguage : Language
{
public override string Name => "Failing";
public override string FileExtension => ".cs";
public override string ProjectFileExtension => ".csproj";
public override ProjectId? DecompileAssembly(LoadedAssembly assembly, ITextOutput output, DecompilationOptions options)
{
options.DecompilationErrors.Add(new DecompilerException(assembly.GetMetadataFileOrNull()!,
"Error decompiling C.M", new System.InvalidOperationException("Simulated failure")));
return null;
}
}
[AvaloniaTest]
public async Task DecompileAssembly_With_SaveAsProjectDirectory_Writes_Csproj_And_Cs_Files()
{

65
ILSpy.Tests/SmartTextOutputExtensionsTests.cs

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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 AwesomeAssertions;
using ICSharpCode.Decompiler;
using ICSharpCode.ILSpy.TextView;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class SmartTextOutputExtensionsTests
{
/// <summary>
/// A collapsed fold hides everything up to its end offset, so a fold reaching past the last
/// frame takes the line after it - the one the reader needs to see - down with it.
/// </summary>
[Test]
public void Exception_Fold_Stops_At_The_Last_Frame()
{
var output = new AvaloniaEditTextOutput();
output.Write("Something failed:");
output.WriteLine();
output.WriteExceptionDetails(ExceptionWithTrace());
output.Write("this line must stay visible");
output.WriteLine();
string text = output.GetText();
var fold = output.Foldings.Should().ContainSingle().Subject;
text[fold.StartOffset..fold.EndOffset].Should().NotEndWith("\n",
"the fold must end with the last frame, not with the newline behind it");
text.Should().Contain("this line must stay visible");
}
static Exception ExceptionWithTrace() => new TrailingNewlineException();
/// <summary>
/// Stands in for the exceptions that actually reach this helper: <see cref="DecompilerException"/>
/// renders a trailing newline, which would push the fold one line past the last frame.
/// </summary>
sealed class TrailingNewlineException : Exception
{
public override string ToString() => "boom" + Environment.NewLine + " at Frame1" + Environment.NewLine;
}
}

1
ILSpy/Commands/ProjectExport.cs

@ -211,6 +211,7 @@ namespace ICSharpCode.ILSpy.Commands @@ -211,6 +211,7 @@ namespace ICSharpCode.ILSpy.Commands
var o = new AvaloniaEditTextOutput { Title = title };
o.Write(result.StatusText);
o.WriteLine();
ProjectExporter.WriteDecompilationErrors(o, result.Errors);
if (result.Success && Directory.Exists(options.OutputDirectory))
{
o.AddOpenFolderButton(options.OutputDirectory);

48
ILSpy/Commands/ProjectExporter.cs

@ -95,7 +95,7 @@ namespace ICSharpCode.ILSpy.Commands @@ -95,7 +95,7 @@ namespace ICSharpCode.ILSpy.Commands
.ConfigureAwait(false);
}
AppendSkipReport(report, skipReport);
return new SolutionExportResult(solution.Success, report.ToString());
return new SolutionExportResult(solution.Success, report.ToString()) { Errors = solution.Errors };
}
var projectResult = await Task
@ -125,6 +125,32 @@ namespace ICSharpCode.ILSpy.Commands @@ -125,6 +125,32 @@ namespace ICSharpCode.ILSpy.Commands
return report.ToString();
}
/// <summary>
/// Writes what the decompiler could not handle: one headline per failure, each followed by
/// the full exception in a collapsed fold. The export replaces the affected code with the
/// error text and finishes rather than failing, so this is where the user learns anything
/// went wrong - and the trace is right there to paste into the bug report.
/// </summary>
internal static void WriteDecompilationErrors(ITextOutput output, IReadOnlyList<DecompilerException> errors)
{
if (errors.Count == 0)
return;
output.WriteLine();
foreach (string line in CSharpDecompiler.GetErrorSummaryLines(errors.Count))
{
output.WriteLine(line);
}
foreach (var error in errors)
{
output.WriteLine();
output.WriteLine(CSharpDecompiler.GetErrorHeadline(error));
output.WriteExceptionDetails(error);
}
// Keeps the caller's result button off the last frame's line, the same way the
// error-free report ends with a blank line.
output.WriteLine();
}
static void AppendSkipReport(StringBuilder report, string skipReport)
{
if (skipReport.Length == 0)
@ -138,16 +164,18 @@ namespace ICSharpCode.ILSpy.Commands @@ -138,16 +164,18 @@ namespace ICSharpCode.ILSpy.Commands
{
var report = new StringBuilder();
bool success;
// Declared out here because the failures the export recovered from are worth reporting
// even when it goes on to fail: their error text is already in the written sources.
var decompileOptions = new DecompilationOptions(settingsClone) {
FullDecompilation = true,
EscapeInvalidIdentifiers = true,
CancellationToken = ct,
SaveAsProjectDirectory = options.OutputDirectory,
StrongNameKeyFile = options.StrongNameKeyFile,
ProgressIndicator = progress,
};
try
{
var decompileOptions = new DecompilationOptions(settingsClone) {
FullDecompilation = true,
EscapeInvalidIdentifiers = true,
CancellationToken = ct,
SaveAsProjectDirectory = options.OutputDirectory,
StrongNameKeyFile = options.StrongNameKeyFile,
ProgressIndicator = progress,
};
language.DecompileAssembly(assembly, new PlainTextOutput(new StringWriter()), decompileOptions);
report.AppendLine("Project written to " + options.OutputDirectory);
success = true;
@ -165,7 +193,7 @@ namespace ICSharpCode.ILSpy.Commands @@ -165,7 +193,7 @@ namespace ICSharpCode.ILSpy.Commands
if (options.GeneratePdb && success)
GeneratePdbs(new[] { assembly }, _ => options.OutputDirectory, settingsClone, options, report, ct);
return new SolutionExportResult(success, report.ToString());
return new SolutionExportResult(success, report.ToString()) { Errors = [.. decompileOptions.DecompilationErrors] };
}
static void GeneratePdbs(IReadOnlyList<LoadedAssembly> assemblies,

9
ILSpy/DecompilationOptions.cs

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Threading;
using ICSharpCode.Decompiler;
@ -82,6 +83,14 @@ namespace ICSharpCode.ILSpy @@ -82,6 +83,14 @@ namespace ICSharpCode.ILSpy
/// </summary>
public IProgress<DecompilationProgress>? ProgressIndicator { get; set; }
/// <summary>
/// Filled by the project-export path with the members, files and resources the decompiler
/// could not handle. The export writes the error text into the affected sources and runs to
/// completion instead of failing, so the caller's result report is where the user learns
/// that anything went wrong at all - the ITextOutput of an export is discarded.
/// </summary>
public IList<DecompilerException> DecompilationErrors { get; } = new List<DecompilerException>();
// Deliberately no parameterless constructor: every decompilation must make an explicit
// choice of settings. Callers inside the app want the user's current settings (see
// SettingsService.CreateEffectiveDecompilerSettings), and a silent new DecompilerSettings()

18
ILSpy/Languages/CSharpLanguage.cs

@ -555,8 +555,22 @@ namespace ICSharpCode.ILSpy.Languages @@ -555,8 +555,22 @@ namespace ICSharpCode.ILSpy.Languages
targetDirectory,
WholeProjectDecompiler.CleanUpFileName(module.Name, ProjectFileExtension));
ProjectId? id;
using (var writer = new System.IO.StreamWriter(projectFileName))
id = decompiler.DecompileProject(module, targetDirectory, writer, options.CancellationToken);
try
{
using (var writer = new System.IO.StreamWriter(projectFileName))
id = decompiler.DecompileProject(module, targetDirectory, writer, options.CancellationToken);
}
finally
{
// The export does not abort on what it cannot decompile; hand the failures to the
// caller, whose result report - not this ITextOutput - is what the user sees when it
// finishes. Sources carrying error text are already on disk even if the export went
// on to fail, so this belongs in the finally.
foreach (var error in decompiler.Errors)
{
options.DecompilationErrors.Add(error);
}
}
output.WriteLine("// Project written to " + targetDirectory);
return id;
}

4
ILSpy/SmartTextOutputExtensions.cs

@ -52,7 +52,9 @@ namespace ICSharpCode.ILSpy @@ -52,7 +52,9 @@ namespace ICSharpCode.ILSpy
// The fold span is counted in WriteLine() calls, not in the '\n' characters embedded in a
// single Write(). Emit the trace one line per WriteLine() so the fold genuinely spans
// multiple lines; otherwise it collapses to a single line and is dropped as noise.
var lines = ex.ToString().Split('\n');
// Trailing newlines would put the fold's end past the last frame, so the collapsed
// section would swallow the line after it.
var lines = ex.ToString().TrimEnd().Split('\n');
for (int i = 0; i < lines.Length; i++)
{
if (i > 0)

21
ILSpy/SolutionWriter.cs

@ -38,7 +38,15 @@ namespace ICSharpCode.ILSpy @@ -38,7 +38,15 @@ namespace ICSharpCode.ILSpy
/// solution was produced and the human-readable status report (the same breadcrumb the WPF
/// version printed into the decompiler text view).
/// </summary>
public sealed record SolutionExportResult(bool Success, string StatusText);
public sealed record SolutionExportResult(bool Success, string StatusText)
{
/// <summary>
/// What the decompiler could not handle while exporting. These are recovered failures - the
/// affected code was replaced by the error text and the export ran to completion - so they
/// do not make <see cref="Success"/> false; the caller renders them for the user to report.
/// </summary>
public IReadOnlyList<DecompilerException> Errors { get; init; } = [];
}
/// <summary>
/// Creates a Visual Studio solution containing one decompiled project per assembly. The
@ -82,6 +90,7 @@ namespace ICSharpCode.ILSpy @@ -82,6 +90,7 @@ namespace ICSharpCode.ILSpy
readonly IProgress<DecompilationProgress>? progress;
readonly ConcurrentBag<ProjectItem> projects;
readonly ConcurrentBag<string> statusOutput;
readonly ConcurrentBag<DecompilerException> decompilationErrors = new();
// How far each project has got, keyed by assembly short name -- unique, because duplicate names
// abort the export before any project runs. The workers fill these in as they decompile and the
@ -206,7 +215,7 @@ namespace ICSharpCode.ILSpy @@ -206,7 +215,7 @@ namespace ICSharpCode.ILSpy
// statusOutput only collects per-assembly failures; an empty bag means every project built.
if (statusOutput.Count != 0)
return new SolutionExportResult(false, report.ToString());
return new SolutionExportResult(false, report.ToString()) { Errors = [.. decompilationErrors] };
report.AppendLine("Successfully decompiled the following assemblies into Visual Studio projects:");
foreach (var n in allAssemblies)
@ -215,7 +224,7 @@ namespace ICSharpCode.ILSpy @@ -215,7 +224,7 @@ namespace ICSharpCode.ILSpy
if (allAssemblies.Count == projects.Count)
report.AppendLine("Created the Visual Studio Solution file.");
return new SolutionExportResult(true, report.ToString());
return new SolutionExportResult(true, report.ToString()) { Errors = [.. decompilationErrors] };
}
// Reports the whole solution's progress: the file counts of every project added up. The projects
@ -323,6 +332,12 @@ namespace ICSharpCode.ILSpy @@ -323,6 +332,12 @@ namespace ICSharpCode.ILSpy
// The project-export path writes the .csproj into SaveAsProjectDirectory itself; the
// ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here.
var projectInfo = language.DecompileAssembly(loadedAssembly, new PlainTextOutput(new StringWriter()), options);
// Recovered failures travel on the result, not in statusOutput: a non-empty
// statusOutput means the solution is incomplete, and these projects did get written.
foreach (var error in options.DecompilationErrors)
{
decompilationErrors.Add(error);
}
if (projectInfo != null)
{
// SolutionCreator.FixAllProjectReferences parses each project file off disk, so the

6
ILSpy/TextView/DecompilerTabPageModel.cs

@ -595,8 +595,10 @@ namespace ICSharpCode.ILSpy.TextView @@ -595,8 +595,10 @@ namespace ICSharpCode.ILSpy.TextView
catch (Exception ex)
{
output.WriteLine();
output.WriteLine("/* Decompilation failed:");
output.WriteLine(ex.ToString());
output.WriteLine("/* Decompilation failed: " + ex.Message);
// The trace goes in a collapsed fold: what the reader needs is the message,
// and the frames only when they go looking for them.
output.WriteExceptionDetails(ex);
output.WriteLine("*/");
}
return (output, cts.Token);

Loading…
Cancel
Save