Files
ILSpy/ICSharpCode.Decompiler/Metadata/DotNetCorePathFinder.cs
T
Siegfried Pammer 2115bd9028 Read the file system once per reference closure, not per reference
Resolving one assembly resolves its whole reference closure, and every
reference in it asked the same framework directories the same questions.
The worst of it was the scan for the closest version folder of a shared
framework: a directory listing plus a recursive file search, repeated per
reference and per runtime pack - 42 scans for two distinct answers when
decompiling ICSharpCode.ILSpyX.dll.

The scan result is only safe to keep for a bounded time: a runtime can be
installed or removed while ILSpy runs, and reloading an assembly list has
to see that. So it is kept for the length of an explicitly opened scope,
which the type system opens around the closure it resolves and closes
again afterwards; outside a scope the file system is read as before. The
scope owns what was read, so two of them on one resolver do not stack -
the first to end takes it, and the other reads the file system again.

BeginSnapshot is on IAssemblyResolver rather than an interface of its
own: it is core functionality of a resolver, and one implementation is
not an abstraction. This breaks the interface for implementors outside
this repository, who opt out by returning null - which is what the three
resolvers here that hold nothing do.

The remaining probes cost nothing to fix: the preferred runtime pack was
listed among the defaults it already belongs to, so its directory was
scanned twice for every reference that is not in it, and one package
folder was probed once per assembly the package contains.

Measured over 27 references with a fresh resolver each time: 3.3 ms per
assembly before, 3.1 ms without a scope, 1.1 ms with one.

Assisted-by: Claude:claude-opus-5:Claude Code
2026-09-04 19:55:39 +02:00

366 lines
11 KiB
C#

// Copyright (c) 2018 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.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using ICSharpCode.Decompiler.Util;
using LightJson.Serialization;
namespace ICSharpCode.Decompiler.Metadata
{
public class DotNetCorePathFinder
{
class DotNetCorePackageInfo
{
public readonly string Name;
public readonly string Version;
public readonly string Type;
public readonly string Path;
public readonly string[] RuntimeComponents;
public DotNetCorePackageInfo(string fullName, string type, string path, string[] runtimeComponents)
{
var parts = fullName.Split('/');
this.Name = parts[0];
if (parts.Length > 1)
{
this.Version = parts[1];
}
else
{
this.Version = "<UNKNOWN>";
}
this.Type = type;
this.Path = path;
this.RuntimeComponents = runtimeComponents ?? Empty<string>.Array;
}
}
static readonly string[] LookupPaths = new string[] {
Environment.GetEnvironmentVariable("NUGET_PACKAGES"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages")
};
static readonly string[] RuntimePacks = new[] {
"Microsoft.NETCore.App",
"Microsoft.WindowsDesktop.App",
"Microsoft.AspNetCore.App",
"Microsoft.AspNetCore.All"
};
readonly DotNetCorePackageInfo[] packages;
readonly List<string> searchPaths = new List<string>();
readonly List<string> packageBasePaths = new List<string>();
readonly Version targetFrameworkVersion;
readonly string dotnetBasePath = FindDotNetExeDirectory();
readonly string preferredRuntimePack;
/// <summary>
/// The resolver this path finder belongs to, which holds any open scope. Null when nobody
/// owns it, and then every scan below reads the file system.
/// </summary>
internal UniversalAssemblyResolver Owner { get; set; }
public DotNetCorePathFinder(TargetFrameworkIdentifier targetFramework, Version targetFrameworkVersion,
string preferredRuntimePack)
{
this.targetFrameworkVersion = targetFrameworkVersion;
this.preferredRuntimePack = preferredRuntimePack;
if (targetFramework == TargetFrameworkIdentifier.NETStandard)
{
// .NET Standard 2.1 is implemented by .NET Core 3.0 or higher
if (targetFrameworkVersion.Major == 2 && targetFrameworkVersion.Minor == 1)
{
this.targetFrameworkVersion = new Version(3, 0, 0);
}
}
}
public DotNetCorePathFinder(string parentAssemblyFileName, string targetFrameworkIdString, string preferredRuntimePack,
TargetFrameworkIdentifier targetFramework, Version targetFrameworkVersion, ReferenceLoadInfo loadInfo = null)
: this(targetFramework, targetFrameworkVersion, preferredRuntimePack)
{
string assemblyName = Path.GetFileNameWithoutExtension(parentAssemblyFileName);
string basePath = Path.GetDirectoryName(parentAssemblyFileName);
searchPaths.Add(basePath);
var depsJsonFileName = Path.Combine(basePath, $"{assemblyName}.deps.json");
if (File.Exists(depsJsonFileName))
{
packages = LoadPackageInfos(depsJsonFileName, targetFrameworkIdString).ToArray();
// Every runtime component of a package resolves to the same folder, so without
// this the same directory is probed once per assembly the package contains, for
// every reference that is looked up.
var knownPackageBasePaths = new HashSet<string>();
foreach (var path in LookupPaths)
{
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
foreach (var p in packages)
{
foreach (var item in p.RuntimeComponents)
{
var itemPath = Path.GetDirectoryName(item);
var fullPath = Path.Combine(path, p.Name, p.Version, itemPath).ToLowerInvariant();
if (knownPackageBasePaths.Add(fullPath) && Directory.Exists(fullPath))
packageBasePaths.Add(fullPath);
}
}
}
}
else
{
loadInfo?.AddMessage(assemblyName, MessageKind.Warning, $"{assemblyName}.deps.json could not be found!");
}
}
public void AddSearchDirectory(string path)
{
this.searchPaths.Add(path);
}
public void RemoveSearchDirectory(string path)
{
this.searchPaths.Remove(path);
}
public string TryResolveDotNetCore(IAssemblyReference name)
{
foreach (var basePath in searchPaths.Concat(packageBasePaths))
{
var file = Path.Combine(basePath, name.Name + ".dll");
if (File.Exists(file))
{
return file;
}
file = Path.Combine(basePath, name.Name + ".exe");
if (File.Exists(file))
{
return file;
}
}
return TryResolveDotNetCoreShared(name, out _);
}
internal string GetReferenceAssemblyPath(string targetFramework)
{
var (tfi, version) = UniversalAssemblyResolver.ParseTargetFramework(targetFramework);
string identifier, identifierExt;
switch (tfi)
{
case TargetFrameworkIdentifier.NETCoreApp:
identifier = "Microsoft.NETCore.App";
identifierExt = "netcoreapp" + version.Major + "." + version.Minor;
break;
case TargetFrameworkIdentifier.NETStandard:
identifier = "NETStandard.Library";
identifierExt = "netstandard" + version.Major + "." + version.Minor;
break;
case TargetFrameworkIdentifier.NET:
identifier = "Microsoft.NETCore.App";
identifierExt = "net" + version.Major + "." + version.Minor;
break;
default:
throw new NotSupportedException();
}
string basePath = Path.Combine(dotnetBasePath, "packs", identifier + ".Ref");
string versionFolder = GetClosestVersionFolder(basePath, version);
return Path.Combine(basePath, versionFolder, "ref", identifierExt);
}
static IEnumerable<DotNetCorePackageInfo> LoadPackageInfos(string depsJsonFileName, string targetFramework)
{
var dependencies = JsonReader.Parse(File.ReadAllText(depsJsonFileName));
var runtimeInfos = dependencies["targets"][targetFramework].AsJsonObject;
var libraries = dependencies["libraries"].AsJsonObject;
if (runtimeInfos == null || libraries == null)
yield break;
foreach (var library in libraries)
{
var type = library.Value["type"].AsString;
var path = library.Value["path"].AsString;
var runtimeInfo = runtimeInfos[library.Key].AsJsonObject?["runtime"].AsJsonObject;
string[] components = new string[runtimeInfo?.Count ?? 0];
if (runtimeInfo != null)
{
int i = 0;
foreach (var component in runtimeInfo)
{
components[i] = component.Key;
i++;
}
}
yield return new DotNetCorePackageInfo(library.Key, type, path, components);
}
}
public string TryResolveDotNetCoreShared(IAssemblyReference name, out string runtimePack)
{
if (dotnetBasePath == null)
{
runtimePack = null;
return null;
}
IEnumerable<string> runtimePacks = RuntimePacks;
if (preferredRuntimePack != null)
{
// The preferred pack is usually one of the defaults as well; listing it twice means
// scanning its directory twice for every reference that is not in it.
runtimePacks = new[] { preferredRuntimePack }.Concat(RuntimePacks.Where(p => p != preferredRuntimePack));
}
foreach (string pack in runtimePacks)
{
runtimePack = pack;
string basePath = Path.Combine(dotnetBasePath, "shared", pack);
if (!Directory.Exists(basePath))
continue;
var closestVersion = Owner != null
? Owner.GetOrAddVersionFolder(basePath, p => GetClosestVersionFolder(p, targetFrameworkVersion))
: GetClosestVersionFolder(basePath, targetFrameworkVersion);
var file = Path.Combine(basePath, closestVersion, name.Name + ".dll");
if (File.Exists(file))
{
return file;
}
file = Path.Combine(basePath, closestVersion, name.Name + ".exe");
if (File.Exists(file))
{
return file;
}
}
runtimePack = null;
return null;
}
static string GetClosestVersionFolder(string basePath, Version version)
{
var foundVersions = new DirectoryInfo(basePath).GetDirectories()
.Select(ConvertToVersion)
.Where(v => v.version != null);
foreach (var folder in foundVersions.OrderBy(v => v.version))
{
if (folder.version >= version
&& folder.directory.EnumerateFiles("*.dll", SearchOption.AllDirectories).Any())
{
return folder.directory.Name;
}
}
return version.ToString();
}
internal static (Version version, DirectoryInfo directory) ConvertToVersion(DirectoryInfo directory)
{
string RemoveTrailingVersionInfo()
{
string shortName = directory.Name;
int dashIndex = shortName.IndexOf('-');
if (dashIndex > 0)
{
shortName = shortName.Remove(dashIndex);
}
return shortName;
}
try
{
return (new Version(RemoveTrailingVersionInfo()), directory);
}
catch (Exception ex)
{
Trace.TraceWarning(ex.ToString());
return (null, null);
}
}
public static string FindDotNetExeDirectory()
{
string dotnetExeName = (Environment.OSVersion.Platform == PlatformID.Unix) ? "dotnet" : "dotnet.exe";
foreach (var item in Environment.GetEnvironmentVariable("PATH").Split(Path.PathSeparator))
{
try
{
string fileName = Path.Combine(item, dotnetExeName);
if (!File.Exists(fileName))
continue;
if (Environment.OSVersion.Platform == PlatformID.Unix)
{
if ((new FileInfo(fileName).Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{
fileName = GetRealPath(fileName, Encoding.Default);
if (!File.Exists(fileName))
continue;
}
}
return Path.GetDirectoryName(fileName);
}
catch (ArgumentException) { }
}
return null;
}
static unsafe string GetRealPath(string path, Encoding encoding)
{
var bytes = encoding.GetBytes(path);
fixed (byte* input = bytes)
{
byte* output = NativeMethods.GetRealPath(input, null);
if (output == null)
{
return null;
}
int len = 0;
for (byte* c = output; *c != 0; c++)
{
len++;
}
byte[] result = new byte[len];
Marshal.Copy((IntPtr)output, result, 0, result.Length);
NativeMethods.Free(output);
return encoding.GetString(result);
}
}
static class NativeMethods
{
[DllImport("libc", EntryPoint = "realpath")]
internal static extern unsafe byte* GetRealPath(byte* path, byte* resolvedPath);
[DllImport("libc", EntryPoint = "free")]
internal static extern unsafe void Free(void* ptr);
}
}
}