Skip to content

Annotate ICSharpCode.Decompiler for AOT compatibility - #3946

Closed
ds5678 wants to merge 4 commits into
icsharpcode:masterfrom
ds5678:aot-compatibility
Closed

ds5678 wants to merge 4 commits into
icsharpcode:masterfrom
ds5678:aot-compatibility

Conversation

@ds5678

@ds5678 ds5678 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Problem

Currently, the ICSharpCode.Decompiler package is not annotated for AOT compatibility, so downstream consumers get build warnings when trying to compile with NativeAOT.

Solution

  • I added a .NET 10 target.
  • I added UnconditionalSuppressMessage attributes to suppress build warnings where necessary. Of course, all suppressions are annotated with justifications for why they're actually safe.
    • Unfortunately, UnconditionalSuppressMessage is only available on .NET 5 and later.
  • I added obsolete attributes to several serialization-related methods to align with .NET 10 (and fix the compile errors it was giving me).
  • I fixed some code that was not compatible with AOT to make it compatible.

@christophwille

Copy link
Copy Markdown
Member

Hi Jeremy, you should have asked before embarking on this endeavor. Because ics.d is intentionally netstandard only and will remain netstandard for the forseeeable future, and multi-targetting is absolutely off-limits (we had that discussion internally more than once).

Reason why no multi-targetting: then all our projects (tests, ILSpy) would start using the net10 tfm compiled variant and basically the netstandard target would go untested. And netstandard is our ticket for being included in Roslyn.

If you can come up with a different approach from multi-targetting, we can talk.

@ds5678

ds5678 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Reason why no multi-targetting: then all our projects (tests, ILSpy) would start using the net10 tfm compiled variant and basically the netstandard target would go untested. And netstandard is our ticket for being included in Roslyn.

Is my latest commit an acceptable solution to this concern?

@christophwille

Copy link
Copy Markdown
Member

@siegfriedpammer Would having the SetTargetFramework attribute on icsd.tests suffice, and the rest of ILSpy use the net10 target? That way both TFMs should be properly exercised in our repository.

</GetPackageVersionDependsOn>
</PropertyGroup>

<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">

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.

If we target net10, why would we compare to net80?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's the earliest runtime that IsAotCompatible is valid. A warning is issued if the property is applied on an earlier framework.

NETSDK1210

IsAotCompatible and EnableAotAnalyzer are not supported for the target framework. Consider multi-targeting to a supported framework to enable ahead-of-time compilation analysis, and set IsAotCompatible only for the supported frameworks. For example:
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>

@christophwille

Copy link
Copy Markdown
Member

Note

This comment was entirely AI generated (Claude Fable 5 via Claude Code) on behalf of @christophwille.

Here is an assessment of the SetTargetFramework approach and the alternatives, given the constraint that netstandard2.0 must remain the tested target.

TL;DR

SetTargetFramework does technically satisfy the constraint (everything in-repo compiles against and tests the netstandard2.0 build), but it is the fragile version of the right idea:

  • It is scattered across ~12 csproj files and must be remembered on every future ProjectReference. Forgetting it fails silently — we would start testing the net10 build without noticing.
  • NuGet restore is documented to not honor it (NuGet/Home#12436) — the restore graph still nearest-matches, so a net10/net11 consumer's lock file and transitive-dependency view follow the net10 target while compilation uses the netstandard asset. With RestoreLockedMode on the core libraries and CI keyed off packages.lock.json, that mismatch is exactly the kind of thing this repo is sensitive to.
  • There is also a new gap this PR doesn't address: the shipped net10.0 asset would be executed by zero tests — we would be publishing an entirely untested binary as the one AOT consumers actually load.

Alternatives, ranked for the constraint

1. Stay netstandard2.0-only and polyfill the annotation attributes (recommended)

The trimmer/ILC match UnconditionalSuppressMessage, RequiresUnreferencedCode, RequiresDynamicCode, DynamicallyAccessedMembers etc. by fully-qualified name, not by assembly — internal copies defined in the netstandard2.0 build are honored when a downstream app NativeAOT-publishes against it. The repo already does exactly this for nullability attributes in ICSharpCode.Decompiler/NRTAttributes.cs, so it is an established in-repo pattern (PolySharp exists for the same reason ecosystem-wide). The IsAotCompatible/IsTrimmable markers are just assembly-level AssemblyMetadata attributes that the SDK normally injects for net7+; they can be added by hand to the netstandard target.

Result: one TFM, one binary, the tested binary is the shipped binary, nothing about the Roslyn-inclusion story changes, and none of the csproj churn in consumers is needed. Most of the genuinely valuable content of this PR — the actual AOT code fixes, obsoletions, and suppressions with justifications — carries over unchanged.

The one real gap: the SDK's trim/AOT analyzers don't run on a netstandard2.0 TFM, so annotations can't be verified at build time. That can be fixed with a non-packed validation build — either a CI-only sibling csproj compiling the same sources under net10.0 with <EnableAotAnalyzer>/<EnableTrimAnalyzer> on, or an inner multi-target whose net10 compile is excluded from the package and whose only job is analysis. That keeps the annotations honest without shipping a second asset.

2. If shipping two TFMs is acceptable: centralize the pin and test both

Instead of ~12 scattered SetTargetFramework attributes, put one

<ProjectReference Update="@(ProjectReference)"
                  Condition="'%(FileName)' == 'ICSharpCode.Decompiler'"
                  SetTargetFramework="TargetFramework=netstandard2.0" />

in the root Directory.Build.targets — one place, future references automatically pinned, no silent-regression risk. Then make the pin a property (SetTargetFramework="TargetFramework=$(DecompilerTestTfm)") and add a CI matrix leg that flips it to net10.0 for the decompiler test suite, so both shipped assets get test coverage instead of neither problem existing.

The NuGet-restore caveat above still applies and needs an updatedeps.ps1 run plus a check that consumer lock files are stable. In practice the runtime risk here is low because the netstandard build's package deps (System.Memory, System.Reflection.Metadata, System.Collections.Immutable) are all inbox on net10, but it should be verified, not assumed.

3. Conditional TFM (net10 only when packing) — reject

<TargetFrameworks> that differ between dev and pack builds would keep consumer csproj files untouched, but it makes restore graphs unstable between local and CI, which is incompatible with the repo's locked-mode restore, and the net10 asset would be even less exercised.

Bottom line

If "netstandard is the product and must be the tested thing" is a hard rule, then multi-targeting solves the problem in the wrong place. Option 1 gives downstream NativeAOT consumers exactly what this PR set out to deliver (no AOT warnings, real suppressions with justifications, the IsAotCompatible marker) while keeping the package single-TFM and keeping the tested and shipped binary identical.

Suggested direction: keep the code fixes and suppressions, replace the multi-target + SetTargetFramework mechanics with polyfilled attributes, and add an analyzer-only net10 validation build so the annotations can't rot. If there is a concrete AOT scenario that name-matched attributes in a netstandard asset can't fix, that would be the argument for option 2 — but then the centralized pin and a net10 test leg should be conditions of acceptance.

string runtimeDir = Path.GetDirectoryName(typeof(object).Module.FullyQualifiedName)!;
string? runtimeDir = Path.GetDirectoryName(GetSystemObjectModuleFullyQualifiedName());
if (string.IsNullOrEmpty(runtimeDir))
goto NotFound;

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.

Why not use a static local function?

@siegfriedpammer

Copy link
Copy Markdown
Member

@ds5678 would polyfilling the attributes work for you?

@ds5678

ds5678 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@ds5678 would polyfilling the attributes work for you?

AOT warnings only appear on versions of .NET that have been annotated, which was the original reason for multi-targeting .NET 10. Without the compiler issuing warnings, code changes can silently break AOT compatibility.

@christophwille

Copy link
Copy Markdown
Member

But Claude also suggested "helper" constructs to verify the warnings to the side.

@ds5678

ds5678 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

I assume that this is the ideal solution?

tunnelvisionlabs/ReferenceAssemblyAnnotator#95

@siegfriedpammer

Copy link
Copy Markdown
Member

Isn't ReferenceAssemblyAnnotator pretty much dead?

@christophwille

Copy link
Copy Markdown
Member

@ds5678 Did you have a chance to look into option 1 (without touching ReferenceAssemblyAnnotator). We do want to support your AOT needs.

@ds5678

ds5678 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Did you have a chance to look into option 1 (without touching ReferenceAssemblyAnnotator). We do want to support your AOT needs.

I was waiting to see if there was any movement on my ReferenceAssemblyAnnotator pull request because I think that is the best solution. If another week goes by with no news, I'll work on the option you suggested.

@christophwille

Copy link
Copy Markdown
Member

#4241 adds a few AOT fixes

@christophwille

Copy link
Copy Markdown
Member

@ds5678 you might want to take a look at #4259 and #4241 if those two get you close to where you wanted to be with AOT.

siegfriedpammer added a commit that referenced this pull request Oct 19, 2026
* Polyfill UnconditionalSuppressMessageAttribute for netstandard2.0

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

* Keep decompiler frames in DecompilerException traces under Native AOT

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

* Suppress IL2026 on the four untyped DecompilerEventSource events

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

* Resolve framework facades on single-file and Native AOT hosts

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

* Mark ICSharpCode.Decompiler as trimmable and AOT-compatible

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

* Add the Native AOT gate for the ICSharpCode.Decompiler package

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

* Document trimming and Native AOT behaviour in the package readme

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

* Check the MoveNext handle for IsNil instead of comparing it to null

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

* Drop the NET5_0_OR_GREATER guard from the attribute polyfills

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

* Say where the AOT gate runs instead of calling it a leg

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

@ds5678 closing this one as the other two PRs have been merged. It would be great if you could give our changes a spin to see if we caught everything!

opensource-fork-sync Bot pushed a commit to cla7aye15I4nd/opensource-icsharpcode__ILSpy that referenced this pull request Oct 19, 2026
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 icsharpcode#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
@ds5678

ds5678 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@ds5678 closing this one as the other two PRs have been merged. It would be great if you could give our changes a spin to see if we caught everything!

Yes, I'm no longer getting build warnings on my end. Thank you for working on this!

@ds5678
ds5678 deleted the aot-compatibility branch October 20, 2026 22:50
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.

3 participants