Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
41 changes: 8 additions & 33 deletions src/Cli/dn/DotnetRootResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.InteropServices;
using Microsoft.DotNet.HostFxr;

namespace Microsoft.DotNet.Cli;

Expand Down Expand Up @@ -84,37 +85,11 @@ internal static string ResolveHostfxrPath(
Func<string, bool> directoryExists,
Func<string, string[]> getDirectories,
Func<string, bool> fileExists)
{
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
if (!directoryExists(fxrDir))
{
return string.Empty;
}

// Pick the highest version directory by parsing version numbers
string? latestFxr = getDirectories(fxrDir)
.Select(path => new
{
Path = path,
Version = Version.TryParse(Path.GetFileName(path), out Version? version) ? version : null
})
.Where(candidate => candidate.Version is not null)
.OrderByDescending(candidate => candidate.Version)
.Select(candidate => candidate.Path)
.FirstOrDefault();

if (latestFxr is null)
{
return string.Empty;
}

string hostfxrName = isWindows
? "hostfxr.dll"
: isMacOS
? "libhostfxr.dylib"
: "libhostfxr.so";

string hostfxrPath = Path.Combine(latestFxr, hostfxrName);
return fileExists(hostfxrPath) ? hostfxrPath : string.Empty;
}
=> HostFxrPathResolver.ResolveHostFxrPath(
dotnetRoot,
isWindows,
isMacOS,
directoryExists,
getDirectories,
fileExists);
}
8 changes: 8 additions & 0 deletions src/Cli/dn/dn.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
</PackageReference>
</ItemGroup>

<ItemGroup>
<PackageReference Include="NuGet.Versioning" />
</ItemGroup>

<ItemGroup>
<Compile Include="$(RepoRoot)src\Common\HostFxrPathResolver.cs" LinkBase="Common" />
Comment thread
lbussell marked this conversation as resolved.
</ItemGroup>

<!--
When building inside Visual Studio, publish the AOT binaries and overlay managed dependencies
so the "Debug dn (AOT native)" launch profile is ready to go.
Expand Down
80 changes: 80 additions & 0 deletions src/Common/HostFxrPathResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using NuGet.Versioning;

namespace Microsoft.DotNet.HostFxr;

/// <summary>
/// Locates the native <c>hostfxr</c> library underneath a .NET installation root.
/// This is used as a fallback when the host does not publish the <c>HOSTFXR_PATH</c>
/// runtime property (for example when the SDK is launched via <c>dotnet exec dotnet.dll</c>
/// rather than as a first-class SDK command, which is how the <c>dnx</c> script works).
/// Dependencies are injected so the pure path-resolution logic can be unit tested.
/// <para>
/// This file is source-shared (via <c>&lt;Compile Include&gt;</c>) into both the
/// <c>Microsoft.DotNet.NativeWrapper</c> resolver and the Native AOT <c>dn</c> host so
/// the two stay in lockstep; a prior divergence between separate copies is what caused
/// https://github.com/dotnet/sdk/issues/55238 to be fixed in only one place.
/// </para>
/// </summary>
internal static class HostFxrPathResolver
{
/// <summary>
/// Finds the highest-versioned <c>hostfxr</c> library under
/// <paramref name="dotnetRoot"/>/host/fxr, returning its full path or
/// <see cref="string.Empty"/> if it cannot be found.
/// </summary>
internal static string ResolveHostFxrPath(
string? dotnetRoot,
bool isWindows,
bool isMacOS,
Func<string, bool> directoryExists,
Func<string, string[]> getDirectories,
Func<string, bool> fileExists)
{
if (string.IsNullOrEmpty(dotnetRoot))
{
return string.Empty;
}

string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
if (!directoryExists(fxrDir))
{
return string.Empty;
}

// Match the native host's get_latest_fxr behavior: consider every valid
// semantic version, including prerelease versions, and select the highest.
// SDK and runtime roll-forward settings apply after hostfxr is loaded and
// do not affect hostfxr selection.
string? latestFxr = getDirectories(fxrDir)
.Select(path =>
{
SemanticVersion.TryParse(Path.GetFileName(path), out SemanticVersion? version);
return new
{
Path = path,
Version = version
};
})
.Where(candidate => candidate.Version is not null)
.OrderByDescending(candidate => candidate.Version)
.Select(candidate => candidate.Path)
.FirstOrDefault();

if (latestFxr is null)
{
return string.Empty;
}

string hostfxrName = isWindows
? "hostfxr.dll"
: isMacOS
? "libhostfxr.dylib"
: "libhostfxr.so";

string hostfxrPath = Path.Combine(latestFxr, hostfxrName);
return fileExists(hostfxrPath) ? hostfxrPath : string.Empty;
}
}
51 changes: 51 additions & 0 deletions src/Resolvers/Microsoft.DotNet.NativeWrapper/HostFxrLocator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#if NET
using Microsoft.DotNet.HostFxr;

namespace Microsoft.DotNet.NativeWrapper
{
/// <summary>
/// NativeWrapper-facing entry point for locating the native <c>hostfxr</c> library.
/// The actual path-resolution logic lives in the source-shared
/// <see cref="HostFxrPathResolver"/> (under <c>src/Common</c>) so this resolver and the
/// Native AOT <c>dn</c> host stay in lockstep.
/// </summary>
internal static class HostFxrLocator
{
/// <summary>
/// Finds the highest-versioned <c>hostfxr</c> library under
/// <paramref name="dotnetRoot"/>/host/fxr, returning its full path or
/// <see cref="string.Empty"/> if it cannot be found.
/// </summary>
internal static string ResolveHostFxrPath(
string? dotnetRoot,
bool isWindows,
bool isMacOS,
Func<string, bool> directoryExists,
Func<string, string[]> getDirectories,
Func<string, bool> fileExists)
=> HostFxrPathResolver.ResolveHostFxrPath(
dotnetRoot,
isWindows,
isMacOS,
directoryExists,
getDirectories,
fileExists);

/// <summary>
/// Resolves the <c>hostfxr</c> path against the live filesystem, deriving the .NET
/// installation root from the current process (the running <c>dotnet</c> host).
/// </summary>
internal static string ResolveHostFxrPath()
=> ResolveHostFxrPath(
EnvironmentProvider.GetDotnetExeDirectory(),
isWindows: OperatingSystem.IsWindows(),
isMacOS: OperatingSystem.IsMacOS(),
directoryExists: Directory.Exists,
getDirectories: Directory.GetDirectories,
fileExists: File.Exists);
}
}
#endif
18 changes: 15 additions & 3 deletions src/Resolvers/Microsoft.DotNet.NativeWrapper/Interop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,26 @@ private static IntPtr HostFxrResolver(Assembly assembly, string libraryName)
return IntPtr.Zero;
}

if (string.IsNullOrEmpty(s_hostFxrPath))
string? hostFxrPath = s_hostFxrPath;

// The host only publishes the HOSTFXR_PATH runtime property for first-class SDK
// commands (e.g. `dotnet build`). When the SDK is launched via `dotnet exec dotnet.dll`
// (as the `dnx` script does), the property is absent. On glibc the bare `libhostfxr`
// load succeeds against the already-loaded library so this resolver never runs, but on
// musl it does not, so fall back to locating hostfxr under the running .NET root.
if (string.IsNullOrEmpty(hostFxrPath))
{
hostFxrPath = HostFxrLocator.ResolveHostFxrPath();
}

if (string.IsNullOrEmpty(hostFxrPath))
{
throw new HostFxrRuntimePropertyNotSetException();
}

if (!NativeLibrary.TryLoad(s_hostFxrPath, out var handle))
if (!NativeLibrary.TryLoad(hostFxrPath, out var handle))
{
throw new HostFxrNotFoundException(s_hostFxrPath);
throw new HostFxrNotFoundException(hostFxrPath);
}

return handle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@

<ItemGroup>
<PackageReference Include="Microsoft.Deployment.DotNet.Releases" />
<PackageReference Include="NuGet.Versioning" Condition="'$(TargetFramework)' != 'net472'" />
</ItemGroup>

<ItemGroup>
<Compile Include="$(RepoRoot)src\Common\HostFxrPathResolver.cs" LinkBase="Common" Condition="'$(TargetFramework)' != 'net472'" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="dotnet-aot" PublicKey="0024000004800000940000000602000000240000525341310004000001000100F33A29044FA9D740C9B3213A93E57C84B472C84E0B8A0E1AE48E67A9F8F6DE9D5F7F3D52AC23E48AC51801F1DC950ABE901DA34D2A9E3BAADB141A17C77EF3C565DD5EE5054B91CF63BB3C6AB83F72AB3AAFE93D0FC3C2348B764FAFB0B1C0733DE51459AEAB46580384BF9D74C4E28164B7CDE247F891BA07891C9D872AD2BB" />
<InternalsVisibleTo Include="dotnet-aot.Tests" PublicKey="0024000004800000940000000602000000240000525341310004000001000100F33A29044FA9D740C9B3213A93E57C84B472C84E0B8A0E1AE48E67A9F8F6DE9D5F7F3D52AC23E48AC51801F1DC950ABE901DA34D2A9E3BAADB141A17C77EF3C565DD5EE5054B91CF63BB3C6AB83F72AB3AAFE93D0FC3C2348B764FAFB0B1C0733DE51459AEAB46580384BF9D74C4E28164B7CDE247F891BA07891C9D872AD2BB" />
</ItemGroup>

</Project>
112 changes: 107 additions & 5 deletions test/dotnet-aot.Tests/DotnetRootResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,24 +180,126 @@ public void ResolveHostfxrPath_PicksHighestVersion()
}

[TestMethod]
public void ResolveHostfxrPath_SkipsPrereleaseDirectories()
public void ResolveHostfxrPath_FindsPrereleaseVersionDirectory()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewers: Note this change in behavior. It wasn't clear to me that skipping prerelease versions was intentional or just a bug that AI created a test case to match its behavior. So I'm proposing that prerelease versions be handled.

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.

Interesting catch - I think it's reasonable to support preview versions of hostfxr, but I think it might be most-compatible if the hostfxr location took the 'allow prerelease' setting into account, via:

Actually, it might be best to align with the way the runtime locates hostfxr - @elinor-fung do you know how/when the runtime will look for preview hostfxrs?

@mthalman mthalman Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I checked the native host's get_latest_fxr implementation: it allows prerelease versions and selects the highest valid SemVer. Neither DOTNET_ROLL_FORWARD_TO_PRERELEASE nor sdk.allowPrerelease affects hostfxr selection. I updated the fallback to use NuGet.Versioning.SemanticVersion and retain that behavior in c38bdd3.

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.

Awesome, thanks for doing that!

{
// On a preview-only install, host/fxr contains a single prerelease-named
// directory (e.g. "10.0.0-preview.5"). Version.TryParse can't parse that string,
// so the numeric core must be used instead of skipping the directory.
string dotnetRoot = BuildPath(true, "dotnet");
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
string preview = Path.Combine(fxrDir, "10.0.0-preview.5");
string expectedPath = Path.Combine(preview, "hostfxr.dll");

string result = DotnetRootResolver.ResolveHostfxrPath(
dotnetRoot: dotnetRoot,
isWindows: true,
isMacOS: false,
directoryExists: _ => true,
getDirectories: _ => new[] { preview },
fileExists: _ => true);

Assert.AreEqual(expectedPath, result);
}

[TestMethod]
public void ResolveHostfxrPath_PicksHighestVersion_IncludingPrerelease()
{
string dotnetRoot = BuildPath(true, "dotnet");
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
string v900 = Path.Combine(fxrDir, "9.0.0");
string expectedPath = Path.Combine(v900, "hostfxr.dll");
string preview = Path.Combine(fxrDir, "10.0.0-preview.5");
string expectedPath = Path.Combine(preview, "hostfxr.dll");

string result = DotnetRootResolver.ResolveHostfxrPath(
dotnetRoot: dotnetRoot,
isWindows: true,
isMacOS: false,
directoryExists: _ => true,
getDirectories: _ => new[] { v900, preview },
fileExists: _ => true);

Assert.AreEqual(expectedPath, result);
}

[TestMethod]
public void ResolveHostfxrPath_PrefersStableOverPrereleaseOfSameCore()
{
string dotnetRoot = BuildPath(true, "dotnet");
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
string stable = Path.Combine(fxrDir, "10.0.0");
string preview = Path.Combine(fxrDir, "10.0.0-preview.5");
string expectedPath = Path.Combine(stable, "hostfxr.dll");

string result = DotnetRootResolver.ResolveHostfxrPath(
dotnetRoot: dotnetRoot,
isWindows: true,
isMacOS: false,
directoryExists: _ => true,
getDirectories: _ => new[] { preview, stable },
fileExists: _ => true);

Assert.AreEqual(expectedPath, result);
}

[TestMethod]
public void ResolveHostfxrPath_TreatsBuildMetadataAsStable()
{
string dotnetRoot = BuildPath(true, "dotnet");
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
string stable = Path.Combine(fxrDir, "10.0.0+build.1");
string preview = Path.Combine(fxrDir, "10.0.0-preview.5");
string expectedPath = Path.Combine(stable, "hostfxr.dll");

string result = DotnetRootResolver.ResolveHostfxrPath(
dotnetRoot: dotnetRoot,
isWindows: true,
isMacOS: false,
directoryExists: _ => true,
getDirectories: _ => new[] { preview, stable },
fileExists: _ => true);

Assert.AreEqual(expectedPath, result);
}

[TestMethod]
public void ResolveHostfxrPath_OrdersPrereleaseSegmentsNumerically()
{
// "preview.10" must sort after "preview.6"; a plain ordinal compare gets this wrong.
string dotnetRoot = BuildPath(true, "dotnet");
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
string preview6 = Path.Combine(fxrDir, "10.0.0-preview.6");
string preview10 = Path.Combine(fxrDir, "10.0.0-preview.10");
string expectedPath = Path.Combine(preview10, "hostfxr.dll");

string result = DotnetRootResolver.ResolveHostfxrPath(
dotnetRoot: dotnetRoot,
isWindows: true,
isMacOS: false,
directoryExists: _ => true,
getDirectories: _ => new[] { preview6, preview10 },
fileExists: _ => true);

Assert.AreEqual(expectedPath, result);
}

[TestMethod]
public void ResolveHostfxrPath_SkipsInvalidSemanticVersions()
{
string dotnetRoot = BuildPath(true, "dotnet");
string fxrDir = Path.Combine(dotnetRoot, "host", "fxr");
string twoPartVersion = Path.Combine(fxrDir, "99.0");
string leadingZero = Path.Combine(fxrDir, "98.0.0-preview.01");
string valid = Path.Combine(fxrDir, "10.0.0");
string expectedPath = Path.Combine(valid, "hostfxr.dll");

// Version.TryParse fails for prerelease strings like "10.0.0-preview.5"
string result = DotnetRootResolver.ResolveHostfxrPath(
dotnetRoot: dotnetRoot,
isWindows: true,
isMacOS: false,
directoryExists: _ => true,
getDirectories: _ => new[] { preview, v900 },
getDirectories: _ => new[] { twoPartVersion, leadingZero, valid },
fileExists: _ => true);

// Should pick 9.0.0 since the preview dir is skipped
Assert.AreEqual(expectedPath, result);
}

Expand Down
Loading
Loading