Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/build-ilspy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,85 @@ jobs:
name: ilspycmd RID packages ${{ matrix.rid }} ${{ steps.version.outputs.ILSPY_VERSION_NUMBER }} (Release)
path: ridpackages/*.nupkg
if-no-files-found: error

# Native AOT gate for the ICSharpCode.Decompiler package (ICSharpCode.Decompiler.AotTest, in no
# solution). The publish roots the whole netstandard2.0 assembly and treats every ILC trim/AOT
# warning as an error, so a new reflection pattern anywhere in the decompiler fails here. The
# native binary and the managed build then decompile the same three inputs (a netstandard2.0,
# a .NET Framework and a net10.0 assembly) and their output has to match byte for byte; each run
# also checks that a DecompilerException thrown from a nested helper still names that helper
# (Native AOT keeps no reflection metadata for most frames). Linux only: the analysis is
# platform-independent, and the .NET Framework input exercises the resolver's non-Windows
# fallback, which finds framework facades through the dotnet on PATH rather than the runtime
# directory of the (native) host.
DecompilerAotGate:
name: Decompiler Native AOT gate
needs: Gate
if: needs.Gate.outputs.run == 'true'
runs-on: ubuntu-latest

defaults:
run:
shell: pwsh

steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false

- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ env.DOTNET_SDK_VERSION }}
dotnet-quality: ${{ env.DOTNET_SDK_QUALITY }}
cache: true
cache-dependency-path: '**/packages.lock.json'
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/.dotnet
DOTNET_ROOT: ${{ runner.temp }}/.dotnet

- name: Get Version
run: ./BuildTools/ghactions-install.ps1

- name: Publish (Native AOT, every ILC warning is an error)
run: >
dotnet publish ICSharpCode.Decompiler.AotTest -c Release -r linux-x64
-p:RestoreEnablePackagePruning=false -o aotgate/native

# Framework-dependent, so that the managed host resolves framework facades from the shared
# framework like the native host does (a self-contained host would find them in its own
# directory instead).
- name: Build (managed, for comparison)
run: >
dotnet build ICSharpCode.Decompiler.AotTest -c Release -p:PublishAot=false
-p:RestoreEnablePackagePruning=false -o aotgate/managed

- name: Compare native and managed output
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$native = Resolve-Path aotgate/native/ICSharpCode.Decompiler.AotTest
$managed = Resolve-Path aotgate/managed/ICSharpCode.Decompiler.AotTest
$cache = (dotnet nuget locals global-packages -l) -replace '^global-packages: ', ''
$assemblies = @(
(Resolve-Path aotgate/managed/ICSharpCode.Decompiler.dll).Path,
"$cache/system.reflection.metadata/9.0.0/lib/net462/System.Reflection.Metadata.dll",
"$managed.dll")
foreach ($assembly in $assemblies) {
$name = [System.IO.Path]::GetFileNameWithoutExtension($assembly)
"=== $name"
& $native $assembly "aotgate/$name.native.cs"
& $managed $assembly "aotgate/$name.managed.cs"
$nativeHash = (Get-FileHash "aotgate/$name.native.cs").Hash
$managedHash = (Get-FileHash "aotgate/$name.managed.cs").Hash
if ($nativeHash -ne $managedHash) { throw "native and managed output differ for $name" }
"identical: $((Get-Item "aotgate/$name.native.cs").Length) bytes"
}

- name: Upload decompiled output on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: decompiler-aot-gate-output
path: aotgate/*.cs
if-no-files-found: ignore
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Test support:
- `TestPlugin/` — sample plugin exercising the plugin-loading system (`net10.0`)
- `TestFixtures.Resources/` — generates resource fixtures consumed by the decompiler tests
- `TestTools/` — file-based apps run by hand against real-world assemblies: `nugetfuzz` (crash/assert sweep over nuget.org packages) and `decompdiff` (output diff between two decompiler builds). In no solution and not run by CI; see `TestTools/README.md`
- `ICSharpCode.Decompiler.AotTest/` — Native AOT gate for the decompiler package: CI publishes it with `PublishAot` (whole assembly rooted, every ILC warning an error) and compares the native binary's output with the managed build's. In no solution; see its `README.md`

Windows-only frontends, packaging, and tests:
- `ILSpy.AddIn/`, `ILSpy.AddIn.VS2022/` — Visual Studio add-ins (`net472`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
Native AOT gate for the ICSharpCode.Decompiler package. Publishing this project with ILC analyzes
the shipped netstandard2.0 assembly in full and fails on any trim/AOT warning; running the native
binary and the managed build on the same inputs checks that the output is identical. It is not in
any solution: PublishAot pulls the ILCompiler and runtime packs into every restore, and the gate
only makes sense as a publish. CI publishes it on Linux only (job "Decompiler Native AOT gate"
in build-ilspy.yml): the ILC analysis does not depend on the host OS, and a Linux host is the
one that exercises the resolver's fallback for hosts without a .NET Framework installation.
README.md has the commands for a local run.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<!-- Every ILC trim/AOT warning fails the publish, and every method of the decompiler is
analyzed (TrimmerRootAssembly below), not only what Main reaches. -->
<IlcTreatWarningsAsErrors>true</IlcTreatWarningsAsErrors>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- ILC defaults the feature switch to false and then removes the [Event] call sites of
DecompilerEventSource before analysis; the gate wants them analyzed. -->
<EventSourceSupport>true</EventSourceSupport>
<!-- PublishAot adds SDK-pinned ILCompiler and runtime pack references that a committed lock
file cannot carry; the referenced decompiler project still restores in locked mode. -->
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
<!-- The managed comparison build (PublishAot=false, framework-dependent) runs on whatever
runtime the machine has; CI only installs the SDK's own, newer one. -->
<RollForward>LatestMajor</RollForward>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\ICSharpCode.Decompiler\ICSharpCode.Decompiler.csproj" />
<TrimmerRootAssembly Include="ICSharpCode.Decompiler" />
</ItemGroup>

</Project>
91 changes: 91 additions & 0 deletions ICSharpCode.Decompiler.AotTest/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Christoph Wille
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.IO;

using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.Metadata;

namespace ICSharpCode.Decompiler.AotTest
{
/// <summary>
/// Decompiles one assembly to one file and then checks that a DecompilerException thrown
/// from a nested helper still names that helper in its stack trace. The same binary is run
/// as a Native AOT publish and as a managed build; the gate compares the two output files
/// byte for byte and relies on the exit code for the stack-trace check.
/// </summary>
static class Program
{
static int Main(string[] args)
{
if (args.Length != 2)
{
Console.Error.WriteLine("usage: ICSharpCode.Decompiler.AotTest <input assembly> <output file>");
return 2;
}
string input = Path.GetFullPath(args[0]);
string output = Path.GetFullPath(args[1]);

// Unresolved references become unknown types in the output instead of an exception,
// so a reference that one host resolves and the other does not shows up as an output
// difference, which is what the gate compares. (Every shipping consumer, ilspycmd
// included, runs with this setting.)
var settings = new DecompilerSettings { ThrowOnAssemblyResolveErrors = false };
var decompiler = new CSharpDecompiler(input, settings);
File.WriteAllText(output, decompiler.DecompileWholeModuleAsString());
Console.WriteLine($"decompiled {input} -> {output}");

// Which file each reference resolved to depends on the host (shared framework, NuGet
// cache, application directory); the ledger goes to the console only, never into the
// compared output, and explains a mismatch between the native and the managed run.
foreach (var module in decompiler.TypeSystem.ReferencedModules)
{
Console.WriteLine($" {module.FullAssemblyName} -> {module.MetadataFile?.FileName ?? "NOT FOUND"}");
}

string trace;
try
{
AotGateFrames.ThrowFromHelper();
return 3;
}
catch (Exception ex)
{
using var file = new PEFile(input);
trace = new DecompilerException(file, "AOT gate", ex).ToString();
}
Console.WriteLine(trace);
if (trace.Contains(nameof(AotGateFrames.ThrowFromHelper), StringComparison.Ordinal))
{
return 0;
}
Console.Error.WriteLine($"FAIL: the DecompilerException stack trace does not name {nameof(AotGateFrames.ThrowFromHelper)}");
return 1;
}
}

static class AotGateFrames
{
public static void ThrowFromHelper()
{
throw new InvalidOperationException("thrown for the stack trace check");
}
}
}
33 changes: 33 additions & 0 deletions ICSharpCode.Decompiler.AotTest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ICSharpCode.Decompiler.AotTest

Native AOT gate for the `ICSharpCode.Decompiler` NuGet package. It answers two questions about the
shipped netstandard2.0 assembly:

1. Does ILC report any trim or AOT warning for it? The publish roots the whole assembly
(`TrimmerRootAssembly`), reports warnings per call site (`TrimmerSingleWarn=false`), keeps the
`DecompilerEventSource` call sites alive (`EventSourceSupport=true`) and treats every warning as
an error, so a new reflection pattern anywhere in the decompiler fails the publish.
2. Does the native binary behave like the managed one? Both are run on the same inputs and their
decompiled output is compared byte for byte; the binary also wraps an exception thrown from a
nested helper in a `DecompilerException` and exits non-zero if the helper's name is missing
from the trace.

The project is in no solution (a plain solution restore would otherwise pull the ILCompiler packs on
every build) and runs in CI on Linux (`Decompiler Native AOT gate` in `build-ilspy.yml`).

Locally (Linux/macOS with clang installed; on Windows the MSVC linker must be discoverable):

```
dotnet publish ICSharpCode.Decompiler.AotTest -c Release -r linux-x64 -p:RestoreEnablePackagePruning=false -o aotgate/native
dotnet build ICSharpCode.Decompiler.AotTest -c Release -p:PublishAot=false -p:RestoreEnablePackagePruning=false -o aotgate/managed
aotgate/native/ICSharpCode.Decompiler.AotTest aotgate/managed/ICSharpCode.Decompiler.dll aot.cs
aotgate/managed/ICSharpCode.Decompiler.AotTest aotgate/managed/ICSharpCode.Decompiler.dll managed.cs
cmp aot.cs managed.cs
```

The managed build is framework-dependent on purpose: a self-contained host carries the whole
framework in its own directory and resolves references there, which a native host cannot, so the
two would differ for reasons that have nothing to do with the decompiler.

The RID-specific restore rewrites `ICSharpCode.Decompiler.Generators/packages.lock.json`; discard that
change with `git checkout`.
90 changes: 90 additions & 0 deletions ICSharpCode.Decompiler.Tests/DecompilerExceptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Christoph Wille
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;

using ICSharpCode.Decompiler.Metadata;

using NUnit.Framework;

namespace ICSharpCode.Decompiler.Tests
{
/// <summary>
/// DecompilerException prints its own stack trace format (type, method, parameters, file name
/// and line, inner exceptions first). Bug reports depend on the throwing method being named,
/// so the format is pinned here for the managed runtime; the Native AOT gate
/// (ICSharpCode.Decompiler.AotTest) checks the same for frames without reflection metadata.
/// </summary>
[TestFixture]
public class DecompilerExceptionTests
{
static void ThrowFromHelper()
{
throw new InvalidOperationException("inner failure");
}

static DecompilerException WrapHelperException()
{
try
{
ThrowFromHelper();
}
catch (Exception ex)
{
using var file = new PEFile(typeof(DecompilerExceptionTests).Assembly.Location);
return new DecompilerException(file, "outer failure", ex);
}
throw new InvalidOperationException("unreachable");
}

[Test]
public void StackTraceNamesTheThrowingMethodAndItsSourceLocation()
{
string text = WrapHelperException().ToString();

using (Assert.EnterMultipleScope())
{
Assert.That(text, Does.StartWith("outer failure"));
Assert.That(text, Does.Contain("System.InvalidOperationException: inner failure"));
Assert.That(text, Does.Contain(" at ICSharpCode.Decompiler.Tests.DecompilerExceptionTests.ThrowFromHelper()"));
Assert.That(text, Does.Contain(" in DecompilerExceptionTests.cs:line "), "file name without path, with line number");
Assert.That(text, Does.Contain("-- continuing with outer exception (ICSharpCode.Decompiler.DecompilerException) --"));
}
}

static void ThrowWrapped()
{
throw WrapHelperException();
}

[Test]
public void StackTracePropertyUsesTheSameFormat()
{
try
{
ThrowWrapped();
}
catch (DecompilerException ex)
{
Assert.That(ex.StackTrace, Does.Contain(" at ICSharpCode.Decompiler.Tests.DecompilerExceptionTests.ThrowWrapped()"));
return;
}
Assert.Fail("expected a DecompilerException");
}
}
}
40 changes: 40 additions & 0 deletions ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,46 @@ public void RuntimeDirectoryIsTheDirectoryOfTheRunningCorlib()
Assert.That(UniversalAssemblyResolver.GetRuntimeDirectory(), Is.EqualTo(expected));
}

[Test]
public void SharedFrameworkDirectoryIsTheRuntimeDirectoryOnAManagedHost()
{
// A managed host runs from dotnet/shared/Microsoft.NETCore.App/<version>, which is where
// the framework facades live; only single-file and Native AOT hosts, which run from their
// application directory, need the fallback to an installed shared framework.
Assert.That(UniversalAssemblyResolver.GetSharedFrameworkDirectory(),
Is.EqualTo(UniversalAssemblyResolver.GetRuntimeDirectory()));
}

[Test]
public void NewestInstalledSharedFrameworkIsPickedForHostsWithoutOne()
{
string dotnetRoot = Path.Combine(gacDirectory, "dotnet");
foreach (string version in new[] { "9.0.1", "10.0.3", "10.0.0-preview.1", "not-a-version" })
Directory.CreateDirectory(Path.Combine(dotnetRoot, "shared", "Microsoft.NETCore.App", version));

Assert.That(UniversalAssemblyResolver.FindNewestSharedFramework(dotnetRoot),
Is.EqualTo(Path.Combine(dotnetRoot, "shared", "Microsoft.NETCore.App", "10.0.3")));
}

[Test]
public void NoInstalledSharedFrameworkGivesNull()
{
Assert.That(UniversalAssemblyResolver.FindNewestSharedFramework(gacDirectory), Is.Null);
}

[Test]
public void CorlibVersionWithoutAFrameworkDirectoryDoesNotAbortTheLookup()
{
// The corlib lookup by .NET Framework directory is one step of a chain that goes on with
// the GAC, the 4.0 framework directory and the shared framework of the running .NET;
// a version it has no directory for must hand over to those steps instead of throwing,
// which on hosts without a .NET Framework installation was the fate of every corlib
// reference once ThrowOnAssemblyResolveErrors was set.
var resolver = new UniversalAssemblyResolver(null, throwOnError: true, ".NETFramework,Version=v4.8");

Assert.That(() => resolver.FindAssemblyFile(Reference("mscorlib", "3.0.0.0", "b77a5c561934e089")), Throws.Nothing);
}

[Test]
public void HigherReferenceVersionResolvesToTheVersionInstalledInTheGac()
{
Expand Down
Loading
Loading