Browse Source

Fix #2054: repair a chain of type forwarders that cycles

A chain of type forwarders is followed by assembly name, and every name resolves relative
to the assembly being decompiled - so a chain that leaves for another framework can be
pulled straight back into the directory it started in. A .NET Standard 2.0 assembly
sitting among .NET Framework 4.6.1 facades lost System.Linq.Enumerable that way: the
chain went netstandard -> System.Core (from the shared framework) -> System.Linq (back to
the input directory) -> netstandard, arriving at an assembly it had already passed
through. Nothing in the closure defines the type, so it stayed unknown and every LINQ
call decompiled as a static call with a delegate cast.

Once the closure is loaded, chains that return to an assembly they already visited are
walked a second time, resolving each hop next to the assembly that forwards it. The
assembly ending the repaired chain is loaded only once it is confirmed to declare the
type; it then wins the deduplication against the assembly of the same name it displaces,
which version order says nothing about. An assembly that neither forwards nor declares
the type ends the walk with nothing loaded, so a failed repair cannot displace anything.

Only chains that are already broken are walked twice. Preferring the forwarder's own
directory as a resolution policy was tried first and rejected: measured against a corpus,
it moved a .NET 8 facade's System.Runtime reference out of a net4x compilation, splitting
type identities so that overrides printed as virtual. The two cases cannot be told apart
where references are resolved, because that layer sees assembly names, not the type whose
chain is or is not terminating.

AssemblyReference now knows the module that declares it, which is what lets a hop be
resolved next to its forwarder, and its metadata reader is that module's.

A chain that cannot be repaired is reported in the reference load log the UI already
shows, once per reference: a facade forwards hundreds of types and they all fail together.

Assisted-by: Claude:claude-opus-5:Claude Code
pull/4098/head
Siegfried Pammer 2 weeks ago
parent
commit
af941339c0
  1. 284
      ICSharpCode.Decompiler.Tests/TypeSystem/TypeForwarderResolutionTests.cs
  2. 37
      ICSharpCode.Decompiler/Metadata/AssemblyReferences.cs
  3. 2
      ICSharpCode.Decompiler/Metadata/MetadataFile.cs
  4. 9
      ICSharpCode.Decompiler/Metadata/ReferenceLoadInfo.cs
  5. 36
      ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs
  6. 194
      ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs
  7. 12
      ICSharpCode.Decompiler/TypeSystem/MetadataModule.cs
  8. 8
      ICSharpCode.ILSpyX/LoadedAssembly.cs
  9. 6
      ILSpy.ReadyToRun/ReadyToRunLanguage.cs
  10. 2
      ILSpy/Metadata/MetadataTableTreeNode.cs

284
ICSharpCode.Decompiler.Tests/TypeSystem/TypeForwarderResolutionTests.cs

@ -0,0 +1,284 @@ @@ -0,0 +1,284 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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 System.Linq;
using System.Threading.Tasks;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Tests.Helpers;
using ICSharpCode.Decompiler.TypeSystem;
using NUnit.Framework;
namespace ICSharpCode.Decompiler.Tests.TypeSystem
{
/// <summary>
/// A facade defines no types; it forwards them onwards. Resolving its references relative to the
/// assembly being decompiled - which is what every probe does by default - can take a chain of
/// forwarders out of the framework it started in and into an unrelated set of facades, where it
/// forwards in circles until the cycle guard gives up and the type is lost. That is issue #2054,
/// where a .NET Standard 2.0 assembly sitting among .NET Framework 4.6.1 facades lost
/// System.Linq.Enumerable and every LINQ call decompiled as a cast-laden static call.
///
/// The layout below is that graph in miniature: the input directory holds a facade chain that
/// closes on itself, and only the second directory holds an assembly that defines the type.
/// </summary>
[TestFixture]
public class TypeForwarderResolutionTests
{
/// <summary>Forwards to Mid, which only the framework directory has.</summary>
const string ShimIL = @"
.assembly extern Mid { .ver 1:0:0:0 }
.assembly Shim { .ver 1:0:0:0 }
.class extern forwarder Ns.T
{
.assembly extern Mid
}
";
/// <summary>The framework's facade: forwards to Impl, which exists in both directories.</summary>
const string MidIL = @"
.assembly extern Impl { .ver 1:0:0:0 }
.assembly Mid { .ver 1:0:0:0 }
.class extern forwarder Ns.T
{
.assembly extern Impl
}
";
/// <summary>
/// The copy next to the input assembly: a facade that forwards back to Shim and closes the
/// cycle. Deliberately the higher version, so that picking the right assembly cannot be an
/// accident of the highest-version-wins deduplication of referenced assemblies.
/// </summary>
const string ImplCycleIL = @"
.assembly extern Shim { .ver 1:0:0:0 }
.assembly Impl { .ver 2:0:0:0 }
.class extern forwarder Ns.T
{
.assembly extern Shim
}
";
/// <summary>The copy next to Mid: the only assembly in the graph that defines the type.</summary>
const string ImplRealIL = @"
.assembly extern System.Runtime { .ver 8:0:0:0 }
.assembly Impl { .ver 1:0:0:0 }
.class public auto ansi beforefieldinit Ns.T
extends [System.Runtime]System.Object
{
}
";
/// <summary>
/// Exists in both directories and defines its type in both, so it is nobody's facade. Main
/// references it directly; an ordinary reference must keep resolving next to the assembly
/// being decompiled.
/// </summary>
const string DupIL = @"
.assembly extern System.Runtime { .ver 8:0:0:0 }
.assembly Dup { .ver {VERSION}:0:0:0 }
.class public auto ansi beforefieldinit Ns.D
extends [System.Runtime]System.Object
{
}
";
/// <summary>
/// A second cycle, for a type nothing in the graph declares: Shim2 -> Mid2 -> Impl2 -> Shim2.
/// </summary>
const string Shim2IL = @"
.assembly extern Mid2 { .ver 1:0:0:0 }
.assembly Shim2 { .ver 1:0:0:0 }
.class extern forwarder Ns.U
{
.assembly extern Mid2
}
";
const string Mid2IL = @"
.assembly extern Impl2 { .ver 1:0:0:0 }
.assembly Mid2 { .ver 1:0:0:0 }
.class extern forwarder Ns.U
{
.assembly extern Impl2
}
";
/// <summary>The copy next to the input assembly, closing the cycle.</summary>
const string Impl2CycleIL = @"
.assembly extern Shim2 { .ver 1:0:0:0 }
.assembly Impl2 { .ver 2:0:0:0 }
.class extern forwarder Ns.U
{
.assembly extern Shim2
}
";
/// <summary>
/// The copy next to Mid2. It ends the chain - it forwards nothing - but it does not declare
/// Ns.U either, so the repair must leave it alone rather than load it over the Impl2 that the
/// input directory holds.
/// </summary>
const string Impl2DecoyIL = @"
.assembly extern System.Runtime { .ver 8:0:0:0 }
.assembly Impl2 { .ver 1:0:0:0 }
.class public auto ansi beforefieldinit Ns.SomethingElse
extends [System.Runtime]System.Object
{
}
";
const string MainIL = @"
.assembly extern System.Runtime { .ver 8:0:0:0 }
.assembly extern Shim { .ver 1:0:0:0 }
.assembly extern Dup { .ver 1:0:0:0 }
.assembly extern Shim2 { .ver 1:0:0:0 }
.assembly Main { }
.class public auto ansi beforefieldinit Consumer
extends [System.Runtime]System.Object
{
.method public hidebysig instance void UseForwarded(class [Shim]Ns.T t) cil managed
{
ret
}
.method public hidebysig instance void UseDuplicated(class [Dup]Ns.D d) cil managed
{
ret
}
.method public hidebysig instance void UseUndeclared(class [Shim2]Ns.U u) cil managed
{
ret
}
}
";
string inputDirectory;
string frameworkDirectory;
string mainAssemblyPath;
[OneTimeSetUp]
public async Task SetUp()
{
string root = Path.Combine(Path.GetTempPath(), "ILSpy-TypeForwarderResolution-" + Guid.NewGuid().ToString("N"));
inputDirectory = Path.Combine(root, "input");
frameworkDirectory = Path.Combine(root, "framework");
Directory.CreateDirectory(inputDirectory);
Directory.CreateDirectory(frameworkDirectory);
await AssembleAsync(inputDirectory, "Shim", ShimIL).ConfigureAwait(false);
await AssembleAsync(inputDirectory, "Impl", ImplCycleIL).ConfigureAwait(false);
await AssembleAsync(inputDirectory, "Dup", DupIL.Replace("{VERSION}", "2")).ConfigureAwait(false);
await AssembleAsync(frameworkDirectory, "Mid", MidIL).ConfigureAwait(false);
await AssembleAsync(frameworkDirectory, "Impl", ImplRealIL).ConfigureAwait(false);
await AssembleAsync(frameworkDirectory, "Dup", DupIL.Replace("{VERSION}", "1")).ConfigureAwait(false);
await AssembleAsync(inputDirectory, "Shim2", Shim2IL).ConfigureAwait(false);
await AssembleAsync(inputDirectory, "Impl2", Impl2CycleIL).ConfigureAwait(false);
await AssembleAsync(frameworkDirectory, "Mid2", Mid2IL).ConfigureAwait(false);
await AssembleAsync(frameworkDirectory, "Impl2", Impl2DecoyIL).ConfigureAwait(false);
mainAssemblyPath = await AssembleAsync(inputDirectory, "Main", MainIL).ConfigureAwait(false);
}
[OneTimeTearDown]
public void TearDown()
{
Directory.Delete(Path.GetDirectoryName(inputDirectory), recursive: true);
}
static Task<string> AssembleAsync(string directory, string name, string il)
{
string sourceFile = Path.Combine(directory, name + ".il");
File.WriteAllText(sourceFile, il);
return Tester.AssembleIL(sourceFile, AssemblerOptions.Library);
}
DecompilerTypeSystem CreateTypeSystem()
{
var mainModule = new PEFile(mainAssemblyPath);
// The target framework decides which probe runs first, and the bug only shows on the
// .NET Core path finder, which the .NET Standard identifier selects.
var resolver = new UniversalAssemblyResolver(mainAssemblyPath, throwOnError: false,
".NETStandard,Version=v2.0");
resolver.AddSearchDirectory(frameworkDirectory);
return new DecompilerTypeSystem(mainModule, resolver);
}
IParameter GetParameterOf(DecompilerTypeSystem typeSystem, string methodName)
{
var consumer = typeSystem.MainModule.GetTypeDefinition(new TopLevelTypeName(string.Empty, "Consumer"));
Assert.That(consumer, Is.Not.Null, "the fixture assembly must declare Consumer");
var method = consumer.Methods.SingleOrDefault(m => m.Name == methodName);
Assert.That(method, Is.Not.Null, $"Consumer must declare {methodName}");
return method.Parameters.Single();
}
[Test]
public void ForwarderChainLeavingTheInputDirectoryResolvesInTheDirectoryItReached()
{
var parameter = GetParameterOf(CreateTypeSystem(), "UseForwarded");
using (Assert.EnterMultipleScope())
{
Assert.That(parameter.Type.Kind, Is.Not.EqualTo(TypeKind.Unknown),
"the forwarded type must resolve; the chain used to cycle back into the input directory");
Assert.That(parameter.Type.FullName, Is.EqualTo("Ns.T"));
Assert.That(parameter.Type.GetDefinition().ParentModule.FullAssemblyName, Does.Contain("Version=1.0.0.0"),
"it must come from the assembly that defines it, not from the higher-versioned facade next to the input");
}
}
[Test]
public void ChainThatCannotBeRepairedLeavesTheResolvedAssembliesAlone()
{
// The repaired chain ends at an assembly that forwards nothing - but declares nothing
// either. Loading it would displace the assembly of the same name the input directory
// holds, on the strength of an assumption that does not hold, so the repair declines and
// the type stays unresolved exactly as it was.
var typeSystem = CreateTypeSystem();
var parameter = GetParameterOf(typeSystem, "UseUndeclared");
using (Assert.EnterMultipleScope())
{
Assert.That(parameter.Type.Kind, Is.EqualTo(TypeKind.Unknown),
"nothing declares Ns.U, so no repair can find it");
var impl2 = typeSystem.Modules.SingleOrDefault(m => m.AssemblyName == "Impl2");
Assert.That(impl2, Is.Not.Null, "Impl2 must still be loaded");
Assert.That(impl2.FullAssemblyName, Does.Contain("Version=2.0.0.0"),
"the copy next to the input assembly must not be displaced by one that declares nothing");
}
}
[Test]
public void OrdinaryReferenceStillResolvesNextToTheAssemblyBeingDecompiled()
{
var parameter = GetParameterOf(CreateTypeSystem(), "UseDuplicated");
using (Assert.EnterMultipleScope())
{
Assert.That(parameter.Type.FullName, Is.EqualTo("Ns.D"));
Assert.That(parameter.Type.GetDefinition().ParentModule.FullAssemblyName, Does.Contain("Version=2.0.0.0"),
"a reference from an assembly that is not a facade keeps resolving next to the input assembly");
}
}
}
}

37
ICSharpCode.Decompiler/Metadata/AssemblyReferences.cs

@ -220,9 +220,25 @@ namespace ICSharpCode.Decompiler.Metadata @@ -220,9 +220,25 @@ namespace ICSharpCode.Decompiler.Metadata
{
readonly System.Reflection.Metadata.AssemblyReference entry;
public MetadataReader Metadata { get; }
public AssemblyReferenceHandle Handle { get; }
/// <summary>
/// The module that declares this reference. Assembly resolution is otherwise anchored on the
/// assembly being decompiled, no matter which assembly is asking; a resolver that has to tell
/// the two apart - to keep a chain of type forwarders inside the framework it started in,
/// say - needs to know who made the reference.
/// </summary>
public MetadataFile ReferencingModule { get; }
public MetadataReader Metadata => ReferencingModule.Metadata;
/// <summary>
/// Asks the resolver to look next to <see cref="ReferencingModule"/> before anywhere else.
/// Set while repairing a chain of type forwarders that resolution took out of the framework
/// it was walking through; ordinary references leave it alone and resolve as they always did.
/// </summary>
public bool PreferNextToReferencingModule { get; }
public bool IsWindowsRuntime => (entry.Flags & AssemblyFlags.WindowsRuntime) != 0;
public bool IsRetargetable => (entry.Flags & AssemblyFlags.Retargetable) != 0;
@ -325,26 +341,17 @@ namespace ICSharpCode.Decompiler.Metadata @@ -325,26 +341,17 @@ namespace ICSharpCode.Decompiler.Metadata
}
}
public AssemblyReference(MetadataReader metadata, AssemblyReferenceHandle handle)
{
if (metadata == null)
throw new ArgumentNullException(nameof(metadata));
if (handle.IsNil)
throw new ArgumentNullException(nameof(handle));
Metadata = metadata;
Handle = handle;
entry = metadata.GetAssemblyReference(handle);
}
public AssemblyReference(MetadataFile module, AssemblyReferenceHandle handle)
public AssemblyReference(MetadataFile module, AssemblyReferenceHandle handle,
bool preferNextToReferencingModule = false)
{
if (module == null)
throw new ArgumentNullException(nameof(module));
if (handle.IsNil)
throw new ArgumentNullException(nameof(handle));
Metadata = module.Metadata;
Handle = handle;
entry = Metadata.GetAssemblyReference(handle);
ReferencingModule = module;
PreferNextToReferencingModule = preferNextToReferencingModule;
entry = module.Metadata.GetAssemblyReference(handle);
}
public override string ToString()

2
ICSharpCode.Decompiler/Metadata/MetadataFile.cs

@ -130,7 +130,7 @@ namespace ICSharpCode.Decompiler.Metadata @@ -130,7 +130,7 @@ namespace ICSharpCode.Decompiler.Metadata
var value = assemblyReferences;
if (value.IsDefault)
{
value = Metadata.AssemblyReferences.Select(r => new AssemblyReference(this.Metadata, r)).ToImmutableArray();
value = Metadata.AssemblyReferences.Select(r => new AssemblyReference(this, r)).ToImmutableArray();
assemblyReferences = value;
}
return value;

9
ICSharpCode.Decompiler/Metadata/ReferenceLoadInfo.cs

@ -22,6 +22,15 @@ using System.Linq; @@ -22,6 +22,15 @@ using System.Linq;
namespace ICSharpCode.Decompiler.Metadata
{
/// <summary>
/// Implemented by assembly resolvers that keep a log of how each reference was resolved, so that
/// the type system can report a reference it could not follow to the same place.
/// </summary>
public interface IReferenceLoadInfoProvider
{
ReferenceLoadInfo LoadInfo { get; }
}
public class ReferenceLoadInfo
{
readonly Dictionary<string, UnresolvedAssemblyNameReference> loadedAssemblyReferences = new Dictionary<string, UnresolvedAssemblyNameReference>();

36
ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs

@ -298,6 +298,11 @@ namespace ICSharpCode.Decompiler.Metadata @@ -298,6 +298,11 @@ namespace ICSharpCode.Decompiler.Metadata
}
string? file;
#if !VSADDIN
file = FindNextToReferencingModule(name);
if (file != null)
return file;
#endif
switch (targetFrameworkIdentifier)
{
case TargetFrameworkIdentifier.NET:
@ -307,20 +312,45 @@ namespace ICSharpCode.Decompiler.Metadata @@ -307,20 +312,45 @@ namespace ICSharpCode.Decompiler.Metadata
goto default;
file = dotNetCorePathFinder.Value.TryResolveDotNetCore(name);
if (file != null)
return file;
break;
goto default;
case TargetFrameworkIdentifier.Silverlight:
if (IsZeroOrAllOnes(targetFrameworkVersion))
goto default;
file = ResolveSilverlight(name, targetFrameworkVersion);
if (file != null)
return file;
break;
goto default;
default:
return ResolveInternal(name);
file = ResolveInternal(name);
break;
}
return file;
}
#if !VSADDIN
/// <summary>
/// Every other probe searches relative to the assembly being decompiled, whichever assembly
/// is asking. A caller that is following a chain of type forwarders needs the opposite: the
/// next assembly in the chain has to come from where the chain currently is, or the chain
/// leaves the framework it reached and can end up going in circles (issue #2054). Only a
/// caller that knows it is repairing such a chain asks for this, by setting
/// <see cref="AssemblyReference.PreferNextToReferencingModule"/>.
/// </summary>
string? FindNextToReferencingModule(IAssemblyReference name)
{
if (name is not AssemblyReference { PreferNextToReferencingModule: true, ReferencingModule: { } referrer })
return null;
// An entry of a package or a single-file bundle carries its path inside the container,
// which would be probed relative to the current working directory.
if (!Path.IsPathRooted(referrer.FileName))
return null;
string? directory = Path.GetDirectoryName(referrer.FileName);
return directory == null ? null : SearchDirectory(name, directory);
}
#endif
DotNetCorePathFinder InitDotNetCorePathFinder()
{
DotNetCorePathFinder dotNetCorePathFinder;

194
ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs

@ -273,8 +273,16 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -273,8 +273,16 @@ namespace ICSharpCode.Decompiler.TypeSystem
"System.Runtime.CompilerServices.Unsafe"
};
/// <summary>
/// Where the resolver keeps one, the log that records how each reference was resolved. The
/// type system reports the forwarder chains it cannot follow there, next to the resolution
/// messages for the same reference.
/// </summary>
internal ReferenceLoadInfo ReferenceLoadInfo { get; private set; }
private async Task InitializeAsync(MetadataFile mainModule, IAssemblyResolver assemblyResolver)
{
ReferenceLoadInfo = (assemblyResolver as IReferenceLoadInfoProvider)?.LoadInfo;
DecompilerEventSource.Log.TypeSystemInitStart(mainModule.Name);
int referencedAssembliesResolved = 0;
try
@ -287,6 +295,170 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -287,6 +295,170 @@ namespace ICSharpCode.Decompiler.TypeSystem
}
}
/// <summary>
/// Walks the type forwarders of every loaded assembly and looks for chains that come back to
/// an assembly they already passed through. Such a chain never reaches a definition, so the
/// type it forwards is lost. It is walked a second time with each hop resolved next to the
/// assembly forwarding it, which keeps it inside the framework it reached, and the assembly
/// that ends the repaired chain is returned so the caller can load it.
/// </summary>
/// <remarks>
/// Only chains that are already broken are walked twice: a chain that reaches a definition is
/// left exactly as it resolved, so nothing that decompiles correctly today changes.
/// </remarks>
static async Task<HashSet<MetadataFile>> RepairCyclicTypeForwardersAsync(
List<MetadataFile> referencedAssemblies, IAssemblyResolver assemblyResolver)
{
// The chain is followed the way the type system follows it: by assembly short name, over
// the assemblies that are loaded, keeping the highest version of each name.
var loadedByName = new Dictionary<string, MetadataFile>(StringComparer.OrdinalIgnoreCase);
foreach (var file in referencedAssemblies)
{
if (!file.IsAssembly)
continue;
if (!loadedByName.TryGetValue(file.Name, out var existing)
|| file.Metadata.GetAssemblyDefinition().Version > existing.Metadata.GetAssemblyDefinition().Version)
{
loadedByName[file.Name] = file;
}
}
var repaired = new HashSet<MetadataFile>();
// The same chain carries every type a facade forwards; walking one of them settles it.
var alreadyWalked = new HashSet<(MetadataFile, string)>();
foreach (var file in referencedAssemblies.ToArray())
{
var metadata = file.Metadata;
foreach (var handle in metadata.ExportedTypes)
{
var exportedType = metadata.GetExportedType(handle);
// Only a row that names another assembly can start a chain that leaves this one.
// A row implemented by an AssemblyFile stays inside this assembly - the type lives
// in one of its other modules - and a nested type is implemented by its enclosing
// exported type, so it travels with the chain of the enclosing name.
if (exportedType.Implementation.Kind != SRM.HandleKind.AssemblyReference)
continue;
var typeName = exportedType.GetFullTypeName(metadata);
var reference = (SRM.AssemblyReferenceHandle)exportedType.Implementation;
string targetName = metadata.GetString(metadata.GetAssemblyReference(reference).Name);
if (!alreadyWalked.Add((file, targetName)))
continue;
if (!ChainIsCyclic(file, typeName, targetName, loadedByName))
continue;
var definition = await FollowChainNextToForwardersAsync(file, typeName, reference, assemblyResolver)
.ConfigureAwait(false);
if (definition != null && !loadedByName.ContainsValue(definition))
{
repaired.Add(definition);
}
}
}
return repaired;
}
/// <summary>
/// Whether following <paramref name="typeName"/> through the loaded assemblies returns to one
/// it already passed through. A chain that ends anywhere else - at an assembly that does not
/// forward the type onwards, or at a name nothing resolves to - is not this method's business.
/// </summary>
static bool ChainIsCyclic(MetadataFile start, FullTypeName typeName, string targetName,
Dictionary<string, MetadataFile> loadedByName)
{
var visited = new HashSet<MetadataFile> { start };
for (int hop = 0; hop < MaxTypeForwarderHops; hop++)
{
if (!loadedByName.TryGetValue(targetName, out var next))
return false;
if (!visited.Add(next))
return true;
var forwarder = next.GetTypeForwarder(typeName);
if (forwarder.IsNil)
return false;
var exportedType = next.Metadata.GetExportedType(forwarder);
// Anything but another assembly ends the chain here: an AssemblyFile row puts the
// type in a sibling module of this assembly, and a nested type row points back at
// its enclosing type rather than onwards.
if (exportedType.Implementation.Kind != SRM.HandleKind.AssemblyReference)
return false;
var reference = (SRM.AssemblyReferenceHandle)exportedType.Implementation;
targetName = next.Metadata.GetString(next.Metadata.GetAssemblyReference(reference).Name);
}
return false;
}
/// <summary>
/// Follows the chain again with every hop resolved next to the assembly that forwards it, and
/// returns the assembly the chain ends at - the one that holds the definition, where the
/// repair worked. Null where it still leads nowhere.
/// </summary>
static async Task<MetadataFile> FollowChainNextToForwardersAsync(MetadataFile start,
FullTypeName typeName, SRM.AssemblyReferenceHandle reference, IAssemblyResolver assemblyResolver)
{
var current = start;
var visited = new HashSet<MetadataFile> { start };
for (int hop = 0; hop < MaxTypeForwarderHops; hop++)
{
MetadataFile next;
try
{
next = await assemblyResolver.ResolveAsync(
new AssemblyReference(current, reference, preferNextToReferencingModule: true))
.ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
return null;
}
if (next == null || !visited.Add(next))
return null;
var forwarder = next.GetTypeForwarder(typeName);
if (forwarder.IsNil)
{
// The chain ends here, which is only worth anything if the type is really
// declared here: an assembly that neither forwards nor defines it would
// otherwise be loaded, and displace the assembly it shares its name with.
return DefinesType(next, typeName) ? next : null;
}
var exportedType = next.Metadata.GetExportedType(forwarder);
if (exportedType.Implementation.Kind == SRM.HandleKind.AssemblyFile)
{
// The type is declared in another module of this assembly, which the loader pulls
// in along with it, so the chain ends here and ends well.
return next;
}
if (exportedType.Implementation.Kind != SRM.HandleKind.AssemblyReference)
{
// A nested type, implemented by its enclosing exported type. The chain that
// matters is the enclosing type's, and that one is walked in its own right.
return null;
}
current = next;
reference = (SRM.AssemblyReferenceHandle)exportedType.Implementation;
}
return null;
}
/// <summary>
/// Whether the file declares the type itself. Walking every type definition is affordable
/// here because it only happens for a chain that is already known to be broken.
/// </summary>
static bool DefinesType(MetadataFile file, FullTypeName typeName)
{
var metadata = file.Metadata;
foreach (var handle in metadata.TypeDefinitions)
{
if (handle.GetFullTypeName(metadata) == typeName)
return true;
}
return false;
}
/// <summary>
/// Chains are a handful of hops long in practice; the cap only stops a malformed assembly
/// from walking forever.
/// </summary>
const int MaxTypeForwarderHops = 16;
/// <returns>The number of references in the final set passed to Init(): distinct
/// resolved assemblies (same-name lower-version duplicates dropped) plus resolved
/// non-assembly modules.</returns>
@ -372,6 +544,16 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -372,6 +544,16 @@ namespace ICSharpCode.Decompiler.TypeSystem
}
}
// A chain of type forwarders is followed by assembly name, and every name is resolved
// relative to the assembly being decompiled - so a chain that leaves for another
// framework can be pulled straight back and end up at an assembly it already visited.
// Nothing in the closure defines the type then, and it is lost (issue #2054). Such a
// chain is already broken, so walking it again costs nothing: this time each hop is
// resolved next to the assembly that forwards it, and whatever that turns up is added.
var repairedFiles = await RepairCyclicTypeForwardersAsync(referencedAssemblies, assemblyResolver)
.ConfigureAwait(false);
referencedAssemblies.AddRange(repairedFiles);
if (!(identifier == TargetFrameworkIdentifier.NET && version >= new Version(7, 0)))
{
typeSystemOptions &= ~TypeSystemOptions.NativeIntegersWithoutAttribute;
@ -379,7 +561,7 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -379,7 +561,7 @@ namespace ICSharpCode.Decompiler.TypeSystem
var mainModuleWithOptions = mainModule.WithOptions(typeSystemOptions);
// create IModuleReferences for all references
var referencedAssembliesWithOptions = new List<IModuleReference>(referencedAssemblies.Count);
Dictionary<string, (Version version, int insertionIndex)> referenceAssemblyVersionMap = new();
Dictionary<string, (Version version, int insertionIndex, bool repaired)> referenceAssemblyVersionMap = new();
foreach (var file in referencedAssemblies)
{
// if the file is an assembly, we need to make sure to deduplicate all assemblies,
@ -387,18 +569,22 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -387,18 +569,22 @@ namespace ICSharpCode.Decompiler.TypeSystem
if (file.IsAssembly)
{
var newFileVersion = file.Metadata.GetAssemblyDefinition().Version;
// A file the forwarder repair found holds the definition the chain was looking
// for, which the assembly it shares its name with does not - version order says
// nothing about that, so it wins outright.
bool isRepaired = repairedFiles.Contains(file);
if (referenceAssemblyVersionMap.TryGetValue(file.Name, out var info))
{
if (newFileVersion >= info.version)
if (isRepaired || (newFileVersion >= info.version && !info.repaired))
{
referencedAssembliesWithOptions[info.insertionIndex] = file.WithOptions(typeSystemOptions);
referenceAssemblyVersionMap[file.Name] = (newFileVersion, info.insertionIndex);
referenceAssemblyVersionMap[file.Name] = (newFileVersion, info.insertionIndex, isRepaired);
}
continue;
}
else
{
referenceAssemblyVersionMap[file.Name] = (file.Metadata.GetAssemblyDefinition().Version, referencedAssembliesWithOptions.Count);
referenceAssemblyVersionMap[file.Name] = (newFileVersion, referencedAssembliesWithOptions.Count, isRepaired);
}
}
referencedAssembliesWithOptions.Add(file.WithOptions(typeSystemOptions));

12
ICSharpCode.Decompiler/TypeSystem/MetadataModule.cs

@ -366,7 +366,7 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -366,7 +366,7 @@ namespace ICSharpCode.Decompiler.TypeSystem
IModule ResolveModuleUncached(AssemblyReferenceHandle handle)
{
var asmRef = new Metadata.AssemblyReference(metadata, handle);
var asmRef = new Metadata.AssemblyReference(MetadataFile, handle);
return Compilation.FindModuleByReference(asmRef);
}
@ -941,6 +941,16 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -941,6 +941,16 @@ namespace ICSharpCode.Decompiler.TypeSystem
return td;
}
}
else
{
// The chain of forwarders came back to this module, so nothing in it defines the
// type and the assemblies involved disagree about where it lives - mismatched
// facades from two frameworks, typically. Recorded once per reference: a facade
// forwards hundreds of types, and they all fail together.
(Compilation as DecompilerTypeSystem)?.ReferenceLoadInfo?.AddMessageOnce(
module.FullAssemblyName, MessageKind.Warning,
$"Could not follow the type forwarders for {typeName.ReflectionName}: they lead back to {AssemblyName}.");
}
}
return new UnknownType(typeName);

8
ICSharpCode.ILSpyX/LoadedAssembly.cs

@ -578,7 +578,7 @@ namespace ICSharpCode.ILSpyX @@ -578,7 +578,7 @@ namespace ICSharpCode.ILSpyX
return debugInfoProvider;
}
sealed class MyAssemblyResolver : IAssemblyResolver
sealed class MyAssemblyResolver : IAssemblyResolver, IReferenceLoadInfoProvider
{
readonly LoadedAssembly parent;
readonly bool loadOnDemand;
@ -609,6 +609,12 @@ namespace ICSharpCode.ILSpyX @@ -609,6 +609,12 @@ namespace ICSharpCode.ILSpyX
this.referenceLoadInfo = parent.LoadedAssemblyReferencesInfo;
}
/// <summary>
/// The log the resolution messages go to, so the type system can report the forwarder
/// chains it cannot follow to the same place.
/// </summary>
public ReferenceLoadInfo LoadInfo => referenceLoadInfo;
public MetadataFile? Resolve(IAssemblyReference reference)
{
return ResolveAsync(reference).GetAwaiter().GetResult();

6
ILSpy.ReadyToRun/ReadyToRunLanguage.cs

@ -302,7 +302,11 @@ namespace ICSharpCode.ILSpy.ReadyToRun @@ -302,7 +302,11 @@ namespace ICSharpCode.ILSpy.ReadyToRun
public IAssemblyMetadata FindAssembly(MetadataReader metadataReader, AssemblyReferenceHandle assemblyReferenceHandle, string parentFile)
{
return GetAssemblyMetadata(assemblyResolver.Resolve(new Decompiler.Metadata.AssemblyReference(metadataReader, assemblyReferenceHandle)));
// Only the reader is handed in, so the reference is resolved by name: an
// AssemblyReference is tied to the file that declares it.
var reference = metadataReader.GetAssemblyReference(assemblyReferenceHandle);
var name = Decompiler.Metadata.AssemblyNameReference.Parse(reference.GetFullAssemblyName(metadataReader));
return GetAssemblyMetadata(assemblyResolver.Resolve(name));
}
public IAssemblyMetadata FindAssembly(string simpleName, string parentFile)

2
ILSpy/Metadata/MetadataTableTreeNode.cs

@ -94,7 +94,7 @@ namespace ICSharpCode.ILSpy.Metadata @@ -94,7 +94,7 @@ namespace ICSharpCode.ILSpy.Metadata
output.Write(metadata.GetString(moduleReference.Name));
break;
case HandleKind.AssemblyReference:
var asmRef = new ICSharpCode.Decompiler.Metadata.AssemblyReference(metadata, (AssemblyReferenceHandle)handle);
var asmRef = new ICSharpCode.Decompiler.Metadata.AssemblyReference(module, (AssemblyReferenceHandle)handle);
output.Write(asmRef.ToString());
break;
case HandleKind.Parameter:

Loading…
Cancel
Save