Repository navigation
Native AOT fidelity for the ICSharpCode.Decompiler package - #4259
Merged
Merged
Conversation
ILLink and ILC match the trim annotation attributes by namespace and name, so an internal copy in the netstandard2.0 build is honored exactly like the framework's own type on .NET 5 and later. This keeps the single target framework, which was the maintainers' condition when the multi-targeting approach of #3946 was rejected, and avoids making the compiled IL depend on attribute types that .NET Framework hosts (the VS add-in, Windows PowerShell) cannot resolve. The guard on both polyfill files keeps a .NETCoreApp compile of these sources free of duplicates. Assisted-by: Claude:claude-fable-5-1:Claude Code
StackFrame.GetMethod() is annotated RequiresUnreferencedCode and returns null for every frame without reflection metadata, which under Native AOT is most of them. The custom stack trace format skipped such frames, so a DecompilerException from a native host reached the issue tracker with the decompiler's own frames missing; the runtime does know the method names and prints them through StackFrame.ToString(). Printing that description for method-less frames keeps the frames, and the IL2026 suppression states the handled case rather than merely the null check. Assisted-by: Claude:claude-fable-5-1:Claude Code
netstandard2.0 has no typed WriteEvent overload for these argument shapes, so they bind to WriteEvent(int, params object[]), whose RequiresUnreferencedCode annotation warns about serializing arbitrary object graphs and names primitives and strings as the safe case. These events carry nothing else, so suppressing per event is the right size; rewriting them with WriteEventCore would add unsafe code for no change in behaviour. Assisted-by: Claude:claude-fable-5-1:Claude Code
Such hosts run from their application directory, which holds no managed assemblies, so everything the resolver took from the directory of the running runtime (the mscorlib and netstandard facades for .NET Framework and netstandard inputs on hosts without a Windows framework directory, the last-resort probe, the ref-pack documentation) came up empty there. The newest shared framework of the dotnet on PATH serves the same purpose; a managed host keeps using its own runtime directory, which is that shared framework already. Measured with the AOT gate on Linux: a .NET Framework assembly decompiled identically by the native and the managed build only after this change. The same run showed that with ThrowOnAssemblyResolveErrors set, the corlib lookup threw NotSupportedException for every .NET Framework reference on such hosts although later steps of the chain (the shared framework among them) could still resolve it; those lookups now yield null and leave the ResolutionException to the end of the chain. Assisted-by: Claude:claude-fable-5-1:Claude Code
The SDK writes these two AssemblyMetadata attributes from the IsTrimmable and IsAotCompatible properties for .NETCoreApp targets only; on netstandard2.0 the properties produce NETSDK1212 and NETSDK1210 and nothing else, so the items are declared by hand. ILLink and ILC read the IsTrimmable marker: warnings from the assembly are then reported per call site instead of as one collapsed IL2104, and TrimMode=partial consumers trim it instead of rooting it whole. The analyzers' VerifyReference*Compatibility checks read both markers. This lands after the warning sites were fixed on purpose, because the marker exposes any remaining warning to every consumer. Assisted-by: Claude:claude-fable-5-1:Claude Code
The trim and AOT analyzers cannot run on a netstandard2.0 project, so nothing in the build would notice a new reflection pattern in the decompiler until a consumer's publish warned about it. Publishing a rooted PublishAot app with warnings as errors checks the shipped binary with the tool consumers use, including compiler-generated code and the polyfilled attributes, which a net10 analyzer compile would never exercise. Running the native binary and the managed build on the same inputs catches behaviour that differs at runtime rather than in the analysis, as the stack trace frames and the framework facade resolution did. The project stays out of the solution because PublishAot pulls the ILCompiler packs into every restore. Assisted-by: Claude:claude-fable-5-1:Claude Code
Consumers hosting the decompiler in a PublishAot app have no way to tell from the package that the tracing provider is off by default there, or that framework facades come from the dotnet on PATH on single-file and Native AOT hosts. Assisted-by: Claude:claude-fable-5-1:Claude Code
A MethodDefinitionHandle is a struct, so the null comparison was always false and the check never did anything; CreateILAst rejects the nil handle later, which is why the fallback still worked. The explicit check says what was meant. Assisted-by: Claude:claude-fable-5-1:Claude Code
siegfriedpammer
requested changes
Oct 19, 2026
These sources only ever compile as netstandard2.0 (and as net472 in the VS add-in), so the condition was always true; it only existed for a .NETCoreApp analyzer compile of the same sources that is not part of the repository. Assisted-by: Claude:claude-fable-5-1:Claude Code
The gate has a single CI job, on Linux; "the Linux leg" suggested a Windows counterpart that does not exist. The comment now names the job and why Linux is the host that matters. Assisted-by: Claude:claude-fable-5-1:Claude Code
Member
Author
|
Both review comments are addressed; replies with details are on the threads.
Neither change alters the compiled assembly; the gate job on the pushed branch verifies that. Assisted-by: Claude:claude-fable-5-1:Claude Code |
siegfriedpammer
approved these changes
Oct 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes the netstandard2.0
ICSharpCode.Decompilerpackage high fidelity for trimmed and Native AOT consumers, following "alternative 1" from #3946 (single target framework, attributes polyfilled by name, hand-written assembly metadata, a validation build) on top of what #4241 fixed at the source. The analysis that led here, with the measurements behind every item below, is attached asanalysis.md; the findings are referred to as F1-F6 and the recommendations as R1-R9 throughout.Analysis document:
analysis.md
What changed, per commit
Polyfill
UnconditionalSuppressMessageAttributefor netstandard2.0 (R1). ILLink and ILC match this attribute by namespace and name, so an internal copy inICSharpCode.Decompiler/TrimAttributes.csis honored exactly like the framework's own type. Only the attribute that is actually used is polyfilled.NRTAttributes.csgets the same#if !NET5_0_OR_GREATERguard, so a .NETCoreApp compile of the sources (R6, not done here) would see no duplicates. The annotated reference assembly produced byTunnelVisionLabs.ReferenceAssemblyAnnotatordoes not contain this type, so there is no CS0436, and no test project refers to the name throughInternalsVisibleTo.Keep decompiler frames in
DecompilerExceptiontraces under Native AOT (F1, R2).StackFrame.GetMethod()is[RequiresUnreferencedCode]and returns null for every frame without reflection metadata, which under AOT is most frames;GetStackTraceskipped those, so an exception from a native host lost the decompiler's own frames. Method-less frames are now printed fromStackFrame.ToString()(AOT knows the names:at Ns.Type.Method() + 0x2b), with theat offset ... in file:line:columnnoise cut off and the CoreCLR/.NET Framework<null>placeholder still skipped, so managed output is unchanged. The IL2026 suppression states the handled case instead of only the null check that Annotate ICSharpCode.Decompiler for AOT compatibility #3946 used as justification.DecompilerExceptionTestspins the managed format (throwing method, file:line, inner-first ordering); the AOT side is asserted by the gate.Suppress IL2026 on the four untyped
DecompilerEventSourceevents (F2, R3). Events 3, 8, 13 and 14 have argument shapes without a typedWriteEventoverload in netstandard2.0 and bind toWriteEvent(int, params object[]), whose annotation names strings and primitives as the safe case. One attribute per event; no behaviour change.Resolve framework facades on single-file and Native AOT hosts (F5, R7). Such hosts run from their application directory, which holds no assemblies, so every place that probed the directory of the running runtime found nothing there: the framework-directory substitute for hosts without a Windows framework directory, the last-resort shared-framework probe, and the mscorlib ref-pack documentation lookup.
UniversalAssemblyResolver.GetSharedFrameworkDirectory()returns the runtime directory when it is ashared/Microsoft.NETCore.App/<version>directory and otherwise the newest installed shared framework of thedotnetonPATH(via the existingDotNetCorePathFinder.FindDotNetExeDirectory()); managed hosts are unaffected because their runtime directory passes the check. Measured on Linux (the gate's net462 input): before this change the native binary resolved nomscorliband its output diverged from the managed build at line 38; after it both resolve the same facade and the output is byte-identical.The same measurement surfaced an older problem: with
ThrowOnAssemblyResolveErrorsset (theDecompilerSettingsdefault),GetMscorlibBasePaththrewNotSupportedException("Version not supported")for every .NET Framework input on non-Windows hosts, managed or native, in the middle of a chain whose later steps (GAC, 4.0 framework directory, shared framework) could still resolve the reference. The corlib lookups now return null and leave theResolutionExceptionto the end of the chain. Four tests inUniversalAssemblyResolverTestscover the helper (managed host, newest-version selection incl. prerelease folders, no shared framework) and the no-throw chain.Mark the assembly
IsTrimmableandIsAotCompatible(F3, R4). The SDK only writes theseAssemblyMetadataattributes for .NETCoreApp targets (on netstandard2.0 the properties give NETSDK1212/NETSDK1210), so they are declared as items in the csproj. Deliberately the last code change: the marker un-collapses any remaining warning for every consumer and letsTrimMode=partialconsumers trim the assembly.Add the Native AOT gate (F4, R5).
ICSharpCode.Decompiler.AotTest/is a net10.0 console app, in no solution, that CI publishes withPublishAotonubuntu-latest(job "Decompiler Native AOT gate"): whole assembly rooted (TrimmerRootAssembly), per-site warnings (TrimmerSingleWarn=false),EventSourceSupport=trueso the[Event]call sites are analyzed,IlcTreatWarningsAsErrors=true. The native binary and a framework-dependent managed build (PublishAot=false) then decompile three inputs (the decompiler itself as netstandard2.0,System.Reflection.Metadata.dllfromlib/net462of the package already in the restore graph, and the app's own net10 assembly); the outputs must be byte-identical, and each run exits non-zero unless theDecompilerExceptiontrace names the throwing helper. The managed comparison is framework-dependent on purpose: a self-contained host resolves references from its own directory, which a native host cannot. The decompiled files are uploaded as an artifact on failure. The project's README documents the local commands.Package readme (R8): a "Trimming and Native AOT" section (annotations, the gate,
EventSourceSupportdefaulting to false underPublishAot, where facades come from on AOT/single-file hosts).AsyncAwaitDecompiler:moveNextMethod.IsNil(R9) instead of comparing the handle struct to null. Cleanup only:CreateILAstalready rejects a nil handle, so the behaviour was already correct (analysis.md's R6/R9 wording was corrected accordingly).Not done: R6 (analyzer-only sibling project for IDE feedback) is optional and everything it would find, the gate finds.
Verification
masterfails with exactly the five IL2026 the analysis predicted (4xDecompilerEventSource, 1xDecompilerException.GetStackTrace), nothing else.PublishAot, defaultTrimmerSingleWarn,EventSourceSupport=true, package-style dll reference): no warning mentionsICSharpCode.Decompiler.ICSharpCode.Decompiler.Testson the Release build: 4678 passed, 0 failed, 24 skipped (the usualILSpy-tests-dependent ignores).ILSpy.AddIn.VS2022, which compilesUniversalAssemblyResolver.csandDotNetCorePathFinder.csas linked sources, compiles.IsTrimmable/IsAotCompatibleattributes are present in the built assembly (checked with ilspycmd).Assisted-by: Claude:claude-fable-5-1:Claude Code