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
2 changes: 1 addition & 1 deletion src/Compilers/Core/MSBuildTask/ManagedToolTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ protected override bool ValidateParameters()
{
// Set DOTNET_ROOT so that the apphost executables launch properly.
// Unset all other DOTNET_ROOT* variables so for example DOTNET_ROOT_X64 does not override ours.
if (RuntimeHostInfo.GetToolDotNetRoot() is { } dotNetRoot)
if (RuntimeHostInfo.GetToolDotNetRoot(Log.LogMessage) is { } dotNetRoot)
{
Log.LogMessage("Setting {0} to '{1}'", RuntimeHostInfo.DotNetRootEnvironmentName, dotNetRoot);
EnvironmentVariables =
Expand Down
112 changes: 112 additions & 0 deletions src/Compilers/Core/MSBuildTaskTests/RuntimeHostInfoTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.IO;
using System.Runtime.InteropServices;
using Microsoft.CodeAnalysis.Test.Utilities;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.CodeAnalysis.BuildTasks.UnitTests;

public sealed class RuntimeHostInfoTests(ITestOutputHelper output)
{
private readonly ITestOutputHelper _output = output;

[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
public void DotNetInPath()
{
var previousPath = Environment.GetEnvironmentVariable("PATH");
try
{
using var tempRoot = new TempRoot();
var testDir = tempRoot.CreateDirectory();
var globalDotNetDir = testDir.CreateDirectory("global-dotnet");
var globalDotNetExe = globalDotNetDir.CreateFile($"dotnet{PlatformInformation.ExeExtension}");
Environment.SetEnvironmentVariable("PATH", globalDotNetDir.Path);

Assert.Equal(globalDotNetDir.Path, RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
}
finally
{
Environment.SetEnvironmentVariable("PATH", previousPath);
}
}

[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
public void DotNetInPath_None()
{
var previousPath = Environment.GetEnvironmentVariable("PATH");
try
{
Environment.SetEnvironmentVariable("PATH", "");

Assert.Null(RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
}
finally
{
Environment.SetEnvironmentVariable("PATH", previousPath);
}
}

[Fact, WorkItem("https://github.com/dotnet/msbuild/issues/12669")]
public void DotNetInPath_Symlinked()
{
var previousPath = Environment.GetEnvironmentVariable("PATH");
try
{
using var tempRoot = new TempRoot();
var testDir = tempRoot.CreateDirectory();
var globalDotNetDir = testDir.CreateDirectory("global-dotnet");
var globalDotNetExe = globalDotNetDir.CreateFile($"dotnet{PlatformInformation.ExeExtension}");
var binDir = testDir.CreateDirectory("bin");
var symlinkPath = Path.Combine(binDir.Path, $"dotnet{PlatformInformation.ExeExtension}");

// Create symlink from binDir to the actual dotnet executable
File.CreateSymbolicLink(path: symlinkPath, pathToTarget: globalDotNetExe.Path);

Environment.SetEnvironmentVariable("PATH", binDir.Path);

Assert.Equal(globalDotNetDir.Path, RuntimeHostInfo.GetToolDotNetRoot(_output.WriteLine));
}
finally
{
Environment.SetEnvironmentVariable("PATH", previousPath);
}
}
}

#if !NET
file static class NativeMethods
{
extension(File)
{
/// <remarks>
/// Only used by tests currently (might need some hardening if this is to be used by production code).
/// </remarks>
public static void CreateSymbolicLink(string path, string pathToTarget)
{
bool ok = CreateSymbolicLink(
lpSymlinkFileName: path,
lpTargetFileName: pathToTarget,
dwFlags: SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE);
if (!ok)
{
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
}
}
}

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CreateSymbolicLink(
string lpSymlinkFileName,
string lpTargetFileName,
uint dwFlags);

private const uint SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x2;
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ public void GetServerEnvironmentVariables_IncludesDotNetRoot()

var envVars = BuildServerConnection.GetServerEnvironmentVariables(currentEnvironment);

if (RuntimeHostInfo.GetToolDotNetRoot() is { } dotNetRoot)
if (RuntimeHostInfo.GetToolDotNetRoot(Logger.Log) is { } dotNetRoot)
{
// Should have environment variables including DOTNET_ROOT
Assert.NotNull(envVars);
Expand Down
2 changes: 1 addition & 1 deletion src/Compilers/Shared/BuildServerConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ private static IntPtr CreateEnvironmentBlock(Dictionary<string, string> environm
/// <returns>Dictionary of environment variables to set, or null if no custom environment is needed</returns>
internal static Dictionary<string, string>? GetServerEnvironmentVariables(System.Collections.IDictionary currentEnvironment, ICompilerServerLogger? logger = null)
{
if (RuntimeHostInfo.GetToolDotNetRoot() is not { } dotNetRoot)
if (RuntimeHostInfo.GetToolDotNetRoot(logger is null ? null : logger.Log) is not { } dotNetRoot)
{
return null;
}
Expand Down
82 changes: 82 additions & 0 deletions src/Compilers/Shared/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
#nullable enable

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Win32.SafeHandles;

namespace Microsoft.CodeAnalysis.CommandLine
{
Expand Down Expand Up @@ -93,5 +95,85 @@ out PROCESS_INFORMATION lpProcessInformation

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern IntPtr GetCommandLine();

#if !NET
//------------------------------------------------------------------------------
// ResolveLinkTarget
//------------------------------------------------------------------------------
extension(File)
{
public static FileSystemInfo? ResolveLinkTarget(string path, bool returnFinalTarget)
{
if (!returnFinalTarget) throw new NotSupportedException();

using var handle = CreateFileW(
lpFileName: path,
dwDesiredAccess: FILE_READ_ATTRIBUTES,
dwShareMode: FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
lpSecurityAttributes: IntPtr.Zero,
dwCreationDisposition: OPEN_EXISTING,
dwFlagsAndAttributes: FILE_FLAG_BACKUP_SEMANTICS, // needed for directories
hTemplateFile: IntPtr.Zero);

if (handle.IsInvalid)
{
return null;
}

uint flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
uint needed = GetFinalPathNameByHandleW(hFile: handle, lpszFilePath: null, cchFilePath: 0, dwFlags: flags);
if (needed == 0) return null;

var sb = new StringBuilder((int)needed + 1);
uint len = GetFinalPathNameByHandleW(hFile: handle, lpszFilePath: sb, cchFilePath: (uint)sb.Capacity, dwFlags: flags);
if (len == 0) return null;

return new FileInfo(TrimWin32ExtendedPrefix(sb.ToString()));
}
}

private static string TrimWin32ExtendedPrefix(string s)
{
if (s.StartsWith(@"\\?\UNC\", StringComparison.Ordinal))
return @"\\" + s.Substring(8);
if (s.StartsWith(@"\\?\", StringComparison.Ordinal))
return s.Substring(4);
return s;
}

// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants
private const uint FILE_READ_ATTRIBUTES = 0x0080;

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
private const uint FILE_SHARE_READ = 0x00000001;
private const uint FILE_SHARE_WRITE = 0x00000002;
private const uint FILE_SHARE_DELETE = 0x00000004;
private const uint OPEN_EXISTING = 3;
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew
private const uint VOLUME_NAME_DOS = 0x0;
private const uint FILE_NAME_NORMALIZED = 0x0;

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);

// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle hFile,
StringBuilder? lpszFilePath,
uint cchFilePath,
uint dwFlags);
#endif

}
}
39 changes: 24 additions & 15 deletions src/Compilers/Shared/RuntimeHostInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using System;
using System.IO;
using Microsoft.CodeAnalysis.CommandLine;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis
Expand All @@ -32,29 +33,32 @@ internal static class RuntimeHostInfo
/// <summary>
/// The <c>DOTNET_ROOT</c> that should be used when launching executable tools.
/// </summary>
internal static string? GetToolDotNetRoot()
internal static string? GetToolDotNetRoot(Action<string, object[]>? logger)
{
if (GetDotNetHostPath() is { } dotNetHostPath)
var dotNetPath = GetDotNetPathOrDefault();

// Resolve symlinks to dotnet
try
{
return Path.GetDirectoryName(dotNetHostPath);
var resolvedPath = File.ResolveLinkTarget(dotNetPath, returnFinalTarget: true);
if (resolvedPath != null)
{
dotNetPath = resolvedPath.FullName;
}
}

return null;
}

private static string? GetDotNetHostPath()
{
if (Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName) is { Length: > 0 } pathToDotNet)
catch (Exception ex)
{
return pathToDotNet;
logger?.Invoke("Failed to resolve symbolic link for dotnet path '{0}': {1}", [dotNetPath, ex.Message]);
return null;
}

if (Environment.GetEnvironmentVariable(DotNetExperimentalHostPathEnvironmentName) is { Length: > 0 } pathToDotNetExperimental)
var directoryName = Path.GetDirectoryName(dotNetPath);
if (string.IsNullOrEmpty(directoryName))
{
return pathToDotNetExperimental;
return null;
}

return null;
return directoryName;
}

/// <summary>
Expand All @@ -64,11 +68,16 @@ internal static class RuntimeHostInfo
/// </summary>
internal static string GetDotNetPathOrDefault()
{
if (GetDotNetHostPath() is { } pathToDotNet)
if (Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName) is { Length: > 0 } pathToDotNet)
Copy link
Member

Choose a reason for hiding this comment

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

This PR effectively means we set DOTNET_ROOT for our apphost based on the following priority order:

  1. %DOTNET_HOST_PATH%
  2. %DOTNET_EXPERIMENTAL_HOST_PATH%
  3. First dotnet on %PATH%
  4. YOLO dotnet

The first two are definitely standard practice for tools inside the SDK. I'm not sure about (3) though. Essentially what is the appropriate way to invoke a tool when the .NET 10 SDK is loaded into MSBuild 17.x? @baronfel, @rainersigwald?

Copy link
Member Author

@jjonescz jjonescz Nov 3, 2025

Choose a reason for hiding this comment

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

FWIW, it's equivalent to how we always found dotnet host for launching tools, this PR just fixes a recent regression where we stopped doing (3) when we introduced apphosts.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah I think this feels fine. @dsplaisted any concerns for finding-private-SDK-from-environment-variables?

Copy link
Member

Choose a reason for hiding this comment

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

FWIW, it's equivalent to how we always found dotnet host for launching tools, this PR just fixes a recent regression where we stopped doing (3) when we introduced apphosts.

Agree it's taking us back to the old state. At the same time there's been a lot of discussion on how should we be launching .NET based processes: both in the SDK and more generally. I'd rather us be part of the agreed on standard practice vs. deviating if possible.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds like Rainer thinks this is fine, I'm going to merge tomorrow unless there are other concerns

{
return pathToDotNet;
}

if (Environment.GetEnvironmentVariable(DotNetExperimentalHostPathEnvironmentName) is { Length: > 0 } pathToDotNetExperimental)
{
return pathToDotNetExperimental;
}

var (fileName, sep) = PlatformInformation.IsWindows
? ("dotnet.exe", new char[] { ';' })
: ("dotnet", new char[] { ':' });
Expand Down
Loading