Skip to content

Fix #3510: keep exporting a project when a member cannot be decompiled - #3971

Open
siegfriedpammer wants to merge 4 commits into
masterfrom
fix/3510-resilient-project-export
Open

Fix #3510: keep exporting a project when a member cannot be decompiled#3971
siegfriedpammer wants to merge 4 commits into
masterfrom
fix/3510-resilient-project-export

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Aug 9, 2026

Copy link
Copy Markdown
Member

Problem

Save Code / ilspycmd -p aborted the entire export on the first member it could not decompile. One unsupported method in a large assembly left the user with nothing: no sources, no .csproj, and no way to work around it (#3510).

What changed

  • CSharpDecompiler.DecompileBody records the failure instead of throwing. The member keeps its signature and the exception takes the place of its body - a comment with the full trace and where to report it, in the same spot warnings about the code go. The remaining members decompile normally, and the failures are exposed as CSharpDecompiler.Errors (reset per decompilation).
  • WholeProjectDecompiler applies the same rule per file, per resource and around the assembly-info file, so an export always runs to completion. What it recovered from is on WholeProjectDecompiler.Errors.
  • Those failures are surfaced where the user looks when the export finishes: the export report in ILSpy (one headline per failure, each with its full exception in a collapsed "Exception details" fold) and stderr in ilspycmd. The ITextOutput handed to the language is discarded by both export callers, so the errors travel on DecompilationOptions.DecompilationErrors and are rendered by the caller that owns the report.
  • ilspycmd keeps its failure signal: it lists the failures on stderr and exits non-zero, as it did when the decompiler threw. The new --ignore-decompilation-errors flag exits with success instead, for automation that wants the partial output to count as a success.

Recovering silently would trade one bad outcome for a worse one, so the inline comment names https://github.com/icsharpcode/ILSpy/issues/new and every summary repeats it.

This also improves the text view: a single bad method used to replace the whole type with an error page; the type is now shown with the error in place of that one member.

Release notes

Project export no longer stops at the first member it cannot decompile. The affected member is written out with the error text in place of its code and the export runs to completion; ILSpy lists the failures in the export report and ilspycmd prints them to stderr. ilspycmd exits with a non-zero status when this happens - pass --ignore-decompilation-errors to treat such a run as successful.

Drive-by fixes

  • SmartTextOutputExtensions.WriteExceptionDetails split the exception text without trimming, so for exceptions that render a trailing newline (DecompilerException does) the fold reached one line past the last frame and a collapsed fold swallowed the line behind it. That affected the PDB generator and the assembly tree node too.
  • DecompilerTabPageModel's decompilation-failure path dumped the raw stack trace into the text view instead of using that folded helper.
  • ilspycmd wrote the .csproj through File.OpenWrite, which does not truncate: re-exporting into the same directory could leave a shorter project file with trailing bytes from the previous run.

Tests

  • DecompilationErrorRecoveryTests - a failing method body keeps the rest of the type, is recorded as an error, and does not carry over into the next decompilation.
  • WholeProjectDecompilerTests.FailuresDoNotAbortTheExport - a failing decompile, two failing file creations (a type file and the assembly-info file) and a failing resource enumeration are all reported, and every other file is still written.
  • WholeProjectDecompilerTests.OneFailingResourceDoesNotDropTheOthers - a resource that cannot be written costs that resource alone.
  • ProjectExportTests.Export_Report_Names_The_Failures_And_Where_To_Report_Them - the export report names the failure and the report URL, and the export still counts as successful.
  • SmartTextOutputExtensionsTests.Exception_Fold_Stops_At_The_Last_Frame - the fold does not extend past the last frame.

Failures are injected with a throwing IILTransform / IAstTransform and an overridden CreateFile, so the tests do not depend on a decompiler bug that may get fixed.

Decompiler suite green (3367 tests); ilspycmd tests and the affected UI tests green.


Written by an AI agent (Claude) on Siegfried's behalf.

Any exception raised while decompiling a method body tore down the whole
export, so one unsupported method in a large assembly left the user with
nothing at all - no sources, no .csproj, and no way around it. A member
that cannot be decompiled now keeps its signature and carries the
exception in front of it, and the exporter applies the same rule per file
and per resource, so an export always runs to completion.

Continuing silently would trade one bad outcome for a worse one: the
error text names the report URL, and ILSpy and ilspycmd both summarise
everything that failed once the export finishes, so these failures still
reach the issue tracker.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The project export hands the language an ITextOutput that both callers throw
away and build their own status report from, so the summary of what could not
be decompiled never reached the screen: an export finished with nothing but
"Project written to ...", exactly the silence this was meant to prevent. The
failures now travel on DecompilationOptions and land in the report for both
the single-project and the solution path.

While here: a decompilation that fails outright dumped its whole stack trace
into the text view. Put it in the "Exception details" fold that the rest of
the app already uses for exceptions.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review (high effort, multi-agent, Claude Code)

The PR's error-recovery machinery has structural gaps that defeat its own goal: RecordingErrors wraps an eager method so code-file failures still abort the whole export, CreateFile was hoisted out of the per-file try so one I/O failure kills the entire run, the catch handler writes to the possibly-failed writer, and the yield-break wrapper silently drops all resources after the first failure.

Additionally, the removed rethrow silently changes exit-code behavior for ilspycmd and PowerShell consumers, CSharpDecompiler.Errors accumulates across calls without a reset, and the UI exporter drops recovered errors when the export later throws. A pre-existing File.OpenWrite truncation bug and two minor cleanups (duplicated summary phrasing, a history-referencing comment) round out the findings.

10 findings (8 correctness, 2 cleanup) posted as inline comments; all were independently verified against the PR head.


Automated review run via Claude Code (claude-fable-5); findings verified by independent verifier agents.

Comment thread ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
Comment thread ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
Comment thread ILSpy/Commands/ProjectExporter.cs Outdated
Comment thread ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs Outdated
Comment thread ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs Outdated
Comment thread ILSpy.Tests/SmartTextOutputExtensionsTests.cs Outdated
Review of the recovery machinery turned up paths that still took the whole
export down. Creating a file and building its decompiler sat outside the
per-file try, so a path too long for the file system aborted every remaining
file; the handler then wrote its error comment into the very writer that may
have just failed. Wrapping WriteCodeFilesInProject in the error-recording
enumerator guarded nothing, because the method is eager - the assembly-info
file it produces needed its own guard. Recovery around a resource enumeration
cannot skip a single resource either, since an iterator is finished once it
throws, so the resource loop now recovers per resource.

CSharpDecompiler.Errors describes one decompilation, so it is reset when a run
starts. Swallowing the exception cost every consumer outside project export
its failure signal: ilspycmd reports the failures on stderr and exits non-zero
again, with --ignore-decompilation-errors for automation that wants the
partial output to count as success.

The error text now goes into the member's body, where warnings about the code
already go.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The README carries the tool's --help output verbatim, so a new option is
invisible to anyone reading the NuGet package page until it is listed here.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (high effort, multi-agent, Claude Code)

Re-reviewed at head 9874542 after the updates. The earlier round's structural issues (eager RecordingErrors wrap, CreateFile outside the per-file try, catch writing to the failed writer, yield-break dropping items) are addressed. This round surfaced 10 findings (8 correctness, 2 cleanup), all independently verified, posted as inline comments.

The most severe are correctness gaps in the recovery itself:

  • the non-SDK .csproj still references source files whose creation failed (a broken deliverable the old abort never produced),
  • the PowerShell cmdlets were never taught to read the new Errors lists, so failures become fully silent,
  • one mid-iterator failure in WriteMiscellaneousFilesInProject silently drops app.manifest/app.config,
  • recovered errors are lost from the UI report when the export later throws, and
  • an unguarded writer Dispose in the finally block can re-introduce the whole-export abort on disk-full.

Remaining findings cover truncated files contradicting the "replaced by error text" message, RecordingErrors' unbounded retry against non-compiler-iterator overrides, a stderr/exit-code documentation contradiction in ilspycmd, lost regression coverage in the round-trip suite, and a duplicated per-error headline format.


Automated review run via Claude Code (claude-fable-5); findings verified by independent verifier agents.

assemblyInfo = Enumerable.Empty<ProjectItemInfo>();
}

return files.Select(f => new ProjectItemInfo("Compile", f.Key)).Concat(assemblyInfo);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] The .csproj still references .cs files whose creation failed.

files.Select emits a Compile ProjectItemInfo for every file group unconditionally, including groups whose CreateFile or decompilation failed and were skipped by the recovery.

Failure scenario: export with the classic (non-SDK) project format where CreateFile throws for one file (e.g. PathTooLongException for a deeply nested namespace, or the IOException path the new FailuresDoNotAbortTheExport test itself simulates). ProcessFiles records the error and skips the file, but ProjectFileWriterDefault writes an explicit <Compile Include="..."/> item for it - the exported project fails to load/build in Visual Studio/MSBuild with a missing-file error, while GetErrorSummaryLines told the user "the affected code was replaced by the error text in the output" even though no such file exists. Before this PR the export aborted instead of producing a broken .csproj.

CleanUpMethodDeclaration(entityDecl, body, function, localSettings.DecompileMemberBodies);
}
catch (Exception innerException) when (!(innerException is OperationCanceledException || innerException is DecompilerException))
catch (Exception innerException) when (!(innerException is OperationCanceledException))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Consumers not updated to read Errors silently lose the failure signal - the PowerShell cmdlets in this repo among them.

DecompileBody no longer throws, so any library consumer that relied on the exception and was not taught to inspect the new Errors list loses failures entirely. In this repo, the PowerShell cmdlets' catch/WriteError(DecompilationFailed) path is now dead code.

Failure scenario: Get-DecompiledSource (or Get-DecompiledProject) on an assembly containing a method the decompiler cannot handle. Before, the cmdlet raised a PowerShell error record (DecompilationFailed) that scripts could trap; now it completes successfully, returns source with error comments buried inside, and never inspects Decompiler.Errors/WholeProjectDecompiler.Errors - automated pipelines treat known-broken output as clean.

if (module != null)
{
files.AddRange(WriteMiscellaneousFilesInProject(module));
files.AddRange(RecordingErrors(WriteMiscellaneousFilesInProject(module), file, "miscellaneous files"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] A failure writing one misc file silently drops the rest (app.manifest, app.config).

WriteMiscellaneousFilesInProject got only the RecordingErrors enumeration wrapper, not per-item recovery. A throw while writing app.ico leaves the compiler-generated iterator finished, so RecordingErrors records one icon error and stops - app.manifest and app.config, yielded after the icon in the same iterator, are never written and never mentioned. This is the exact truncation the PR's own resource test pins against for resources.

Failure scenario: File.WriteAllBytes for app.ico throws (disk full, read-only target, or corrupt icon-group data in CreateApplicationIcon). The user reads a report naming only the icon failure and ships an exported project that silently lost its application manifest and config file; the removed abort at least made the incompleteness loud.

output.WriteLine("// Project written to " + targetDirectory);
// 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.
foreach (var error in decompiler.Errors)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Recovered errors are lost from the UI report when DecompileProject itself throws.

decompiler.Errors is copied into options.DecompilationErrors only after DecompileProject returns; when it throws (e.g. projectWriter.Write or the strong-name-key File.Copy fails), this copy loop never runs.

Failure scenario: during a UI project export, several methods fail decompilation (recovered, error stubs written into the .cs files on disk), then writing the .csproj throws an IOException. ProjectExporter.ExportProject's catch path builds the report from decompileOptions.DecompilationErrors, which is empty - the user sees only the fatal IO error and never learns that the source files already written to their output directory contain silently stubbed-out members. Copying errors in a finally (or reading them in the catch path) would preserve them.

// are unaffected and the user still gets a complete project; the error
// takes the place of the file's contents.
RecordError(innerException as DecompilerException ?? new DecompilerException(module, $"Error decompiling for '{file.Key}'", innerException));
WriteErrorComment(w, innerException);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] The error comment is appended after partial content, but the message claims the code was "replaced".

When the output visitor throws midway through writing a type (reachable after part of the file was already emitted - e.g. an exception from a resolve-annotation lookup during output, or an IOException mid-write), WriteErrorComment appends the comment after whatever CSharpOutputVisitor already wrote, rather than replacing the file's contents.

Failure scenario: the exported .cs file contains half a class with unclosed braces plus the trailing error comment; the exported project fails to compile with cascading syntax errors in that file, while both the error summary shown to the user ("the affected code was replaced by the error text in the output") and the code comment claim the error text took the place of the contents. Truncating the stream (w.Flush(); stream.SetLength(0) or reopening the file) before writing the comment would match the message.

T item;
try
{
if (!enumerator.MoveNext())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] RecordingErrors can loop forever on non-compiler-generated iterators.

The "an iterator that throws is left in its finished state" assumption only holds for compiler-generated iterators. But this wrapper is applied to WriteResourceFilesInProject/WriteMiscellaneousFilesInProject, which are protected-virtual extension points of a published NuGet library - overrides can return any IEnumerable.

Failure scenario: a WholeProjectDecompiler subclass overrides WriteResourceFilesInProject with a hand-written enumerator that throws the same exception on every MoveNext call (e.g. one wrapping a broken stream that throws IOException without advancing, or one that validates state and throws ObjectDisposedException each call). RecordingErrors catches, records, and calls MoveNext again in the while (true) loop: the export hangs forever and the errors list grows unboundedly until OutOfMemoryException, instead of reporting one error and finishing. A bounded retry count (or bailing out after the first catch per enumerator) would keep the guarantee.

if (errors.Count == 0)
return;
decompilationErrors.AddRange(errors);
Console.Error.WriteLine($"While decompiling {assemblyFileName}:");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] --ignore-decompilation-errors still writes the failure list to stderr, contradicting its documentation.

ReportDecompilationErrors writes to stderr unconditionally; the help text and README say "without this option the failures are listed on stderr and the exit status is non-zero", implying the flag suppresses both.

Failure scenario: a CI script runs ilspycmd -p --ignore-decompilation-errors -o out asm.dll and, per the documented contract, treats any stderr output as a real failure (a common scripting pattern, e.g. 2>err.txt && [ -s err.txt ]). The decompiler recovers from one method-body failure: exit code is 0 as promised, but stderr still carries the full error listing, so the script reports the export as failed even though the user explicitly opted into ignoring decompilation errors. Either suppress the listing under the flag or reword the docs to say only the exit status is affected.

/// method still yields a complete project. Callers should show this list to the user -
/// otherwise the failures ship silently and never get reported.
/// </summary>
public IReadOnlyList<DecompilerException> Errors => errors;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] The round-trip suite loses its regression signal: nothing asserts Errors is empty.

ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs calls DecompileProject without asserting the new Errors list is empty, so decompiler regressions that previously failed the suite via a thrown DecompilerException can now pass green.

Failure scenario: a regression makes CSharpDecompiler crash on a void method in e.g. FSharp.Core that the round-trip run's own NUnit tests never execute. Previously DecompileProject threw and the RoundtripAssembly test failed immediately; now the method body is replaced by { ; } plus comments, the project recompiles, the round-trip tests still pass, and the regression ships undetected by CI. An Assert.That(decompiler.Errors, Is.Empty) after the export restores the signal.

{
RecordError(error);
}
w?.Dispose();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[cleanup] Unguarded w?.Dispose() in the finally block can re-introduce the whole-export abort.

StreamWriter.Dispose flushes buffered output; on a full disk or dropped network share it throws IOException from the finally block, outside the catch that is supposed to confine failures to one file. The exception propagates out of the Parallel.ForEach body, stops the remaining files, and DecompileProject throws - the whole-project abort that #3510 set out to eliminate, triggered by exactly the disk-full scenario WriteErrorComment's own guard anticipates two hunks earlier. Wrapping the dispose in the same try/catch-and-RecordError treatment closes the gap.

foreach (var error in errors)
{
output.WriteLine();
output.WriteLine($"{error.Message}: {error.InnerException?.Message}");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[cleanup] Per-error headline format is duplicated between UI and CLI.

The $"{error.Message}: {error.InnerException?.Message}" format appears both here in WriteDecompilationErrors and in IlspyCmdProgram.ReportDecompilationErrors, despite the PR introducing GetErrorSummaryLines precisely so all front ends render failures identically. The two front ends' per-error lines only agree by copy-paste; the next tweak (adding the member's declaring type, trimming the inner message) lands in one and not the other, and the CLI and UI start describing the same failure differently. A companion helper next to GetErrorSummaryLines (e.g. CSharpDecompiler.GetErrorHeadline(DecompilerException)) removes the duplication.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants