Fix #3510: keep exporting a project when a member cannot be decompiled - #3971
Fix #3510: keep exporting a project when a member cannot be decompiled#3971siegfriedpammer wants to merge 4 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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
Errorslists, so failures become fully silent, - one mid-iterator failure in
WriteMiscellaneousFilesInProjectsilently drops app.manifest/app.config, - recovered errors are lost from the UI report when the export later throws, and
- an unguarded writer
Disposein 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); |
There was a problem hiding this comment.
[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)) |
There was a problem hiding this comment.
[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")); |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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()) |
There was a problem hiding this comment.
[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}:"); |
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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(); |
There was a problem hiding this comment.
[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}"); |
There was a problem hiding this comment.
[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.
Problem
Save Code /
ilspycmd -paborted 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.DecompileBodyrecords 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 asCSharpDecompiler.Errors(reset per decompilation).WholeProjectDecompilerapplies 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 onWholeProjectDecompiler.Errors.ilspycmd. TheITextOutputhanded to the language is discarded by both export callers, so the errors travel onDecompilationOptions.DecompilationErrorsand are rendered by the caller that owns the report.ilspycmdkeeps its failure signal: it lists the failures on stderr and exits non-zero, as it did when the decompiler threw. The new--ignore-decompilation-errorsflag 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/newand 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
Drive-by fixes
SmartTextOutputExtensions.WriteExceptionDetailssplit the exception text without trimming, so for exceptions that render a trailing newline (DecompilerExceptiondoes) 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.ilspycmdwrote the.csprojthroughFile.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/IAstTransformand an overriddenCreateFile, so the tests do not depend on a decompiler bug that may get fixed.Decompiler suite green (3367 tests);
ilspycmdtests and the affected UI tests green.Written by an AI agent (Claude) on Siegfried's behalf.