Skip to content

Native AOT fidelity for the ICSharpCode.Decompiler package - #4259

Merged
siegfriedpammer merged 10 commits into
masterfrom
decompiler-aot-fidelity
Oct 19, 2026
Merged

siegfriedpammer merged 10 commits into
masterfrom
decompiler-aot-fidelity

Conversation

@christophwille

@christophwille christophwille commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Makes the netstandard2.0 ICSharpCode.Decompiler package 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 as analysis.md; the findings are referred to as F1-F6 and the recommendations as R1-R9 throughout.

Analysis document:

analysis.md

What changed, per commit

  1. Polyfill UnconditionalSuppressMessageAttribute for netstandard2.0 (R1). ILLink and ILC match this attribute by namespace and name, so an internal copy in ICSharpCode.Decompiler/TrimAttributes.cs is honored exactly like the framework's own type. Only the attribute that is actually used is polyfilled. NRTAttributes.cs gets the same #if !NET5_0_OR_GREATER guard, so a .NETCoreApp compile of the sources (R6, not done here) would see no duplicates. The annotated reference assembly produced by TunnelVisionLabs.ReferenceAssemblyAnnotator does not contain this type, so there is no CS0436, and no test project refers to the name through InternalsVisibleTo.

  2. Keep decompiler frames in DecompilerException traces under Native AOT (F1, R2). StackFrame.GetMethod() is [RequiresUnreferencedCode] and returns null for every frame without reflection metadata, which under AOT is most frames; GetStackTrace skipped those, so an exception from a native host lost the decompiler's own frames. Method-less frames are now printed from StackFrame.ToString() (AOT knows the names: at Ns.Type.Method() + 0x2b), with the at offset ... in file:line:column noise 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. DecompilerExceptionTests pins the managed format (throwing method, file:line, inner-first ordering); the AOT side is asserted by the gate.

  3. Suppress IL2026 on the four untyped DecompilerEventSource events (F2, R3). Events 3, 8, 13 and 14 have argument shapes without a typed WriteEvent overload in netstandard2.0 and bind to WriteEvent(int, params object[]), whose annotation names strings and primitives as the safe case. One attribute per event; no behaviour change.

  4. 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 a shared/Microsoft.NETCore.App/<version> directory and otherwise the newest installed shared framework of the dotnet on PATH (via the existing DotNetCorePathFinder.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 no mscorlib and 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 ThrowOnAssemblyResolveErrors set (the DecompilerSettings default), GetMscorlibBasePath threw NotSupportedException("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 the ResolutionException to the end of the chain. Four tests in UniversalAssemblyResolverTests cover the helper (managed host, newest-version selection incl. prerelease folders, no shared framework) and the no-throw chain.

  5. Mark the assembly IsTrimmable and IsAotCompatible (F3, R4). The SDK only writes these AssemblyMetadata attributes 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 lets TrimMode=partial consumers trim the assembly.

  6. Add the Native AOT gate (F4, R5). ICSharpCode.Decompiler.AotTest/ is a net10.0 console app, in no solution, that CI publishes with PublishAot on ubuntu-latest (job "Decompiler Native AOT gate"): whole assembly rooted (TrimmerRootAssembly), per-site warnings (TrimmerSingleWarn=false), EventSourceSupport=true so 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.dll from lib/net462 of 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 the DecompilerException trace 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.

  7. Package readme (R8): a "Trimming and Native AOT" section (annotations, the gate, EventSourceSupport defaulting to false under PublishAot, where facades come from on AOT/single-file hosts).

  8. AsyncAwaitDecompiler: moveNextMethod.IsNil (R9) instead of comparing the handle struct to null. Cleanup only: CreateILAst already 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

  • Red baseline: the gate on master fails with exactly the five IL2026 the analysis predicted (4x DecompilerEventSource, 1x DecompilerException.GetStackTrace), nothing else.
  • After the changes: zero ILC warnings and byte-identical native/managed output for all three inputs on Windows (win-x64, local), Linux (WSL Ubuntu 24.04, local) and CI (ubuntu-latest, this branch's run).
  • Consumer view (unrooted PublishAot, default TrimmerSingleWarn, EventSourceSupport=true, package-style dll reference): no warning mentions ICSharpCode.Decompiler.
  • ICSharpCode.Decompiler.Tests on the Release build: 4678 passed, 0 failed, 24 skipped (the usual ILSpy-tests-dependent ignores). ILSpy.AddIn.VS2022, which compiles UniversalAssemblyResolver.cs and DotNetCorePathFinder.cs as linked sources, compiles.
  • The IsTrimmable/IsAotCompatible attributes are present in the built assembly (checked with ilspycmd).

Assisted-by: Claude:claude-fable-5-1:Claude Code

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
Comment thread ICSharpCode.Decompiler/TrimAttributes.cs Outdated
Comment thread ICSharpCode.Decompiler.AotTest/ICSharpCode.Decompiler.AotTest.csproj Outdated
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
@christophwille

Copy link
Copy Markdown
Member Author

Both review comments are addressed; replies with details are on the threads.

  • #if !NET5_0_OR_GREATER in TrimAttributes.cs: never false here (the sources only compile as netstandard2.0, and as net472 for the add-in's linked sources). It was scaffolding for a .NETCoreApp analyzer compile that was considered and not added. Dropped from both polyfill files in 045d5fd; NRTAttributes.cs is back to its previous content.
  • "CI runs the Linux leg" in ICSharpCode.Decompiler.AotTest.csproj: there is only one gate job, on Linux, so "leg" was misleading. The comment now names the job and why Linux is the host that matters (ILC's analysis is OS-independent; a Linux host exercises the resolver's fallback for hosts without a .NET Framework installation). Fixed in 84192f5.

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
siegfriedpammer merged commit 48fc74e into master Oct 19, 2026
20 checks passed
@siegfriedpammer
siegfriedpammer deleted the decompiler-aot-fidelity branch October 19, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build Automation Decompiler The decompiler engine itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants