From 3ccc3593c8415561970b9fd6948f67c523937f36 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Thu, 16 Aug 2018 19:21:48 -0700 Subject: [PATCH] MSBuild SDK Resolver Improvements (#9547) 1. When there's no global.json, use latest SDK that is compatible with msbuild, not latest SDK overall 2. Respect VS setting to allow / disallow resolving to preview SDKs --- build/DependencyVersions.props | 5 +- .../Interop.Common.cs | 36 --- .../Interop.NETFramework.cs | 47 --- .../Interop.NETStandard.cs | 53 ---- .../Interop.cs | 135 ++++++++ .../MSBuildSdkResolver.cs | 208 +++++++++++-- ...Microsoft.DotNet.MSBuildSdkResolver.csproj | 11 +- .../Microsoft.DotNet.MSBuildSdkResolver.sln | 19 +- .../NETCoreSdkResolver.cs | 76 +++++ .../VSSettings.cs | 155 ++++++++++ .../GivenAnMSBuildSdkResolver.cs | 287 +++++++++++++++++- ...oft.DotNet.MSBuildSdkResolver.Tests.csproj | 21 +- 12 files changed, 856 insertions(+), 197 deletions(-) delete mode 100644 src/Microsoft.DotNet.MSBuildSdkResolver/Interop.Common.cs delete mode 100644 src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETFramework.cs delete mode 100644 src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETStandard.cs create mode 100644 src/Microsoft.DotNet.MSBuildSdkResolver/Interop.cs create mode 100644 src/Microsoft.DotNet.MSBuildSdkResolver/NETCoreSdkResolver.cs create mode 100644 src/Microsoft.DotNet.MSBuildSdkResolver/VSSettings.cs diff --git a/build/DependencyVersions.props b/build/DependencyVersions.props index 0b0cf7b09..3d0d589e4 100644 --- a/build/DependencyVersions.props +++ b/build/DependencyVersions.props @@ -11,7 +11,10 @@ 2.1.1 2.1.1 2.1.3-servicing-26724-03 - $(MicrosoftNETCoreAppPackageVersion) + + + 3.0.0-preview1-26816-04 + 15.9.0-preview-000006 $(MicrosoftBuildPackageVersion) $(MicrosoftBuildPackageVersion) diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.Common.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.Common.cs deleted file mode 100644 index 580d1220b..000000000 --- a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.Common.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Diagnostics; -using System.Text; - -namespace Microsoft.DotNet.MSBuildSdkResolver -{ - internal static partial class Interop - { - internal static string hostfxr_resolve_sdk(string exe_dir, string working_dir) - { - var buffer = new StringBuilder(capacity: 64); - - for (;;) - { - int size = hostfxr_resolve_sdk(exe_dir, working_dir, buffer, buffer.Capacity); - if (size <= 0) - { - Debug.Assert(size == 0); - return null; - } - - if (size <= buffer.Capacity) - { - break; - } - - buffer.Capacity = size; - } - - return buffer.ToString(); - } - } -} \ No newline at end of file diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETFramework.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETFramework.cs deleted file mode 100644 index 2101637f3..000000000 --- a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETFramework.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -#if NET46 - -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; - -namespace Microsoft.DotNet.MSBuildSdkResolver -{ - internal static partial class Interop - { - internal static readonly bool RunningOnWindows = true; - - static Interop() - { - PreloadLibrary("hostfxr.dll"); - } - - // MSBuild SDK resolvers are required to be AnyCPU, but we have a native dependency and .NETFramework does not - // have a built-in facility for dynamically loading user native dlls for the appropriate platform. We therefore - // preload the version with the correct architecture (from a corresponding sub-folder relative to us) on static - // construction so that subsequent P/Invokes can find it. - private static void PreloadLibrary(string dllFileName) - { - string basePath = Path.GetDirectoryName(typeof(Interop).Assembly.Location); - string architecture = IntPtr.Size == 8 ? "x64" : "x86"; - string dllPath = Path.Combine(basePath, architecture, dllFileName); - - // return value is intentially ignored as we let the subsequent P/Invokes fail naturally. - LoadLibraryExW(dllPath, IntPtr.Zero, LOAD_WITH_ALTERED_SEARCH_PATH); - } - - // lpFileName passed to LoadLibraryEx must be a full path. - private const int LOAD_WITH_ALTERED_SEARCH_PATH = 0x8; - - [DllImport("kernel32", CharSet = CharSet.Unicode, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)] - private static extern IntPtr LoadLibraryExW(string lpFileName, IntPtr hFile, int dwFlags); - - [DllImport("hostfxr", CharSet = CharSet.Unicode, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] - private static extern int hostfxr_resolve_sdk(string exe_dir, string working_dir, [Out] StringBuilder buffer, int buffer_size); - } -} - -#endif // NET46 \ No newline at end of file diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETStandard.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETStandard.cs deleted file mode 100644 index 245f153a0..000000000 --- a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.NETStandard.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -// NOTE: the NET46 build ships with Visual Studio/desktop msbuild on Windows. -// The netstandard1.5 adaptation here acts a proof-of-concept for cross-platform -// portability of the underlying hostfxr API and gives us build and test coverage -// on non-Windows machines. It also ships with msbuild on Mono. -#if NETSTANDARD2_0 - -using System; -using System.Runtime.InteropServices; -using System.Text; - -namespace Microsoft.DotNet.MSBuildSdkResolver -{ - internal static partial class Interop - { - internal static readonly bool RunningOnWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - - internal static string realpath(string path) - { - var ptr = unix_realpath(path, IntPtr.Zero); - var result = Marshal.PtrToStringAnsi(ptr); // uses UTF8 on Unix - unix_free(ptr); - return result; - } - - private static int hostfxr_resolve_sdk(string exe_dir, string working_dir, [Out] StringBuilder buffer, int buffer_size) - { - // hostfxr string encoding is platform -specific so dispatch to the - // appropriately annotated P/Invoke for the current platform. - return RunningOnWindows - ? windows_hostfxr_resolve_sdk(exe_dir, working_dir, buffer, buffer_size) - : unix_hostfxr_resolve_sdk(exe_dir, working_dir, buffer, buffer_size); - } - - [DllImport("hostfxr", EntryPoint = nameof(hostfxr_resolve_sdk), CharSet = CharSet.Unicode, ExactSpelling=true, CallingConvention = CallingConvention.Cdecl)] - private static extern int windows_hostfxr_resolve_sdk(string exe_dir, string working_dir, [Out] StringBuilder buffer, int buffer_size); - - // CharSet.Ansi is UTF8 on Unix - [DllImport("hostfxr", EntryPoint = nameof(hostfxr_resolve_sdk), CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] - private static extern int unix_hostfxr_resolve_sdk(string exe_dir, string working_dir, [Out] StringBuilder buffer, int buffer_size); - - // CharSet.Ansi is UTF8 on Unix - [DllImport("libc", EntryPoint = nameof(realpath), CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] - private static extern IntPtr unix_realpath(string path, IntPtr buffer); - - [DllImport("libc", EntryPoint = "free", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] - private static extern void unix_free(IntPtr ptr); - } -} - -#endif // NETSTANDARD2_0 diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.cs new file mode 100644 index 000000000..cda6dd3d0 --- /dev/null +++ b/src/Microsoft.DotNet.MSBuildSdkResolver/Interop.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.DotNet.MSBuildSdkResolver +{ + internal static partial class Interop + { + internal static readonly bool RunningOnWindows = +#if NET46 + // Not using RuntimeInformation on NET46 to avoid non-in-box framework API, + // which create deployment problems for the resolver. + Path.DirectorySeparatorChar == '\\'; +#else + RuntimeInformation.IsOSPlatform(OSPlatform.Windows); +#endif + + static Interop() + { + if (RunningOnWindows) + { + PreloadWindowsLibrary("hostfxr.dll"); + } + } + + // MSBuild SDK resolvers are required to be AnyCPU, but we have a native dependency and .NETFramework does not + // have a built-in facility for dynamically loading user native dlls for the appropriate platform. We therefore + // preload the version with the correct architecture (from a corresponding sub-folder relative to us) on static + // construction so that subsequent P/Invokes can find it. + private static void PreloadWindowsLibrary(string dllFileName) + { + string basePath = Path.GetDirectoryName(typeof(Interop).Assembly.Location); + string architecture = IntPtr.Size == 8 ? "x64" : "x86"; + string dllPath = Path.Combine(basePath, architecture, dllFileName); + + // return value is intentially ignored as we let the subsequent P/Invokes fail naturally. + LoadLibraryExW(dllPath, IntPtr.Zero, LOAD_WITH_ALTERED_SEARCH_PATH); + } + + // lpFileName passed to LoadLibraryEx must be a full path. + private const int LOAD_WITH_ALTERED_SEARCH_PATH = 0x8; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)] + private static extern IntPtr LoadLibraryExW(string lpFileName, IntPtr hFile, int dwFlags); + + + [Flags] + internal enum hostfxr_resolve_sdk2_flags_t : int + { + disallow_prerelease = 0x1, + } + + internal enum hostfxr_resolve_sdk2_result_key_t : int + { + resolved_sdk_dir = 0, + global_json_path = 1, + } + + internal static class Windows + { + private const CharSet UTF16 = CharSet.Unicode; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = UTF16)] + internal delegate void hostfxr_resolve_sdk2_result_fn( + hostfxr_resolve_sdk2_result_key_t key, + string value); + + [DllImport("hostfxr", CharSet = UTF16, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + internal static extern int hostfxr_resolve_sdk2( + string exe_dir, + string working_dir, + hostfxr_resolve_sdk2_flags_t flags, + hostfxr_resolve_sdk2_result_fn result); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = UTF16)] + internal delegate void hostfxr_get_available_sdks_result_fn( + int sdk_count, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + string[] sdk_dirs); + + [DllImport("hostfxr", CharSet = UTF16, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + internal static extern int hostfxr_get_available_sdks( + string exe_dir, + hostfxr_get_available_sdks_result_fn result); + } + + internal static class Unix + { + // Ansi marhsaling on Unix is actually UTF8 + private const CharSet UTF8 = CharSet.Ansi; + private static string PtrToStringUTF8(IntPtr ptr) => Marshal.PtrToStringAnsi(ptr); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = UTF8)] + internal delegate void hostfxr_resolve_sdk2_result_fn( + hostfxr_resolve_sdk2_result_key_t key, + string value); + + [DllImport("hostfxr", CharSet = UTF8, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + internal static extern int hostfxr_resolve_sdk2( + string exe_dir, + string working_dir, + hostfxr_resolve_sdk2_flags_t flags, + hostfxr_resolve_sdk2_result_fn result); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = UTF8)] + internal delegate void hostfxr_get_available_sdks_result_fn( + int sdk_count, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + string[] sdk_dirs); + + [DllImport("hostfxr", CharSet = UTF8, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + internal static extern int hostfxr_get_available_sdks( + string exe_dir, + hostfxr_get_available_sdks_result_fn result); + + [DllImport("libc", CharSet = UTF8, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr realpath(string path, IntPtr buffer); + + [DllImport("libc", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern void free(IntPtr ptr); + + internal static string realpath(string path) + { + var ptr = realpath(path, IntPtr.Zero); + var result = PtrToStringUTF8(ptr); + free(ptr); + return result; + } + } + } +} diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs index a115a867b..ce648ed7e 100644 --- a/src/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs +++ b/src/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs @@ -3,13 +3,21 @@ using Microsoft.Build.Framework; using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Reflection; namespace Microsoft.DotNet.MSBuildSdkResolver { + // Thread-safety note: + // 1. MSBuild can call the same resolver instance in parallel on multiple threads. + // 2. Nevertheless, in the IDE, project re-evaluation can create new instances for each evaluation. + // + // As such, all state (instance or static) must be guarded against concurrent access/updates. + // Caches of minimum versions, compatible SDKs are static to benefit multiple IDE evaluations. + // VSSettings are also effectively static (singleton instance that can be swapped by tests). + public sealed class DotNetMSBuildSdkResolver : SdkResolver { public override string Name => "Microsoft.DotNet.MSBuildSdkResolver"; @@ -18,29 +26,59 @@ namespace Microsoft.DotNet.MSBuildSdkResolver public override int Priority => 5000; private readonly Func _getEnvironmentVariable; + private readonly VSSettings _vsSettings; + + private static readonly ConcurrentDictionary s_minimumMSBuildVersions + = new ConcurrentDictionary(); + + private static readonly ConcurrentDictionary s_compatibleSdks + = new ConcurrentDictionary(); public DotNetMSBuildSdkResolver() - : this(Environment.GetEnvironmentVariable) + : this(Environment.GetEnvironmentVariable, VSSettings.Ambient) { } - // Test hook to provide environment variables without polluting the test process. - internal DotNetMSBuildSdkResolver(Func getEnvironmentVariable) + // Test constructor + internal DotNetMSBuildSdkResolver(Func getEnvironmentVariable, VSSettings vsSettings) { _getEnvironmentVariable = getEnvironmentVariable; + _vsSettings = vsSettings; + } + + private sealed class CachedResult + { + public string MSBuildSdksDir; + public string NETCoreSdkVersion; } public override SdkResult Resolve(SdkReference sdkReference, SdkResolverContext context, SdkResultFactory factory) { - // These are overrides that are used to force the resolved SDK tasks and targets to come from a given - // base directory and report a given version to msbuild (which may be null if unknown. One key use case - // for this is to test SDK tasks and targets without deploying them inside the .NET Core SDK. - string msbuildSdksDir = _getEnvironmentVariable("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"); - string netcoreSdkVersion = _getEnvironmentVariable("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER"); + string msbuildSdksDir = null; + string netcoreSdkVersion = null; + + if (context.State is CachedResult priorResult) + { + msbuildSdksDir = priorResult.MSBuildSdksDir; + netcoreSdkVersion = priorResult.NETCoreSdkVersion; + } if (msbuildSdksDir == null) { - string netcoreSdkDir = ResolveNetcoreSdkDirectory(context); + // These are overrides that are used to force the resolved SDK tasks and targets to come from a given + // base directory and report a given version to msbuild (which may be null if unknown. One key use case + // for this is to test SDK tasks and targets without deploying them inside the .NET Core SDK. + msbuildSdksDir = _getEnvironmentVariable("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"); + netcoreSdkVersion = _getEnvironmentVariable("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER"); + } + + if (msbuildSdksDir == null) + { + string dotnetExeDir = GetDotnetExeDirectory(); + var resolverResult = ResolveNETCoreSdkDirectory(context, dotnetExeDir); + string netcoreSdkDir = resolverResult.ResolvedSdkDirectory; + string globalJsonPath = resolverResult.GlobalJsonPath; + if (netcoreSdkDir == null) { return factory.IndicateFailure( @@ -66,21 +104,20 @@ namespace Microsoft.DotNet.MSBuildSdkResolver }); } - string minimumMSBuildVersionString = GetMinimumMSBuildVersion(netcoreSdkDir); - var minimumMSBuildVersion = Version.Parse(minimumMSBuildVersionString); + Version minimumMSBuildVersion = GetMinimumMSBuildVersion(netcoreSdkDir); if (context.MSBuildVersion < minimumMSBuildVersion) { return factory.IndicateFailure( new[] { - $"Version {netcoreSdkVersion} of the .NET Core SDK requires at least version {minimumMSBuildVersionString}" + $"Version {netcoreSdkVersion} of the .NET Core SDK requires at least version {minimumMSBuildVersion.ToString()}" + $" of MSBuild. The current available version of MSBuild is {context.MSBuildVersion.ToString()}." + " Change the .NET Core SDK specified in global.json to an older version that requires the MSBuild" + " version currently available." }); } - string minimumVSDefinedSDKVersion = GetMinimumVSDefinedSDKVersion(); + string minimumVSDefinedSDKVersion = GetMinimumVSDefinedSDKVersion(); if (IsNetCoreSDKSmallerThanTheMinimumVersion(netcoreSdkVersion, minimumVSDefinedSDKVersion)) { return factory.IndicateFailure( @@ -93,6 +130,12 @@ namespace Microsoft.DotNet.MSBuildSdkResolver } } + context.State = new CachedResult + { + MSBuildSdksDir = msbuildSdksDir, + NETCoreSdkVersion = netcoreSdkVersion + }; + string msbuildSdkDir = Path.Combine(msbuildSdksDir, sdkReference.Name, "Sdk"); if (!Directory.Exists(msbuildSdkDir)) { @@ -107,18 +150,114 @@ namespace Microsoft.DotNet.MSBuildSdkResolver return factory.IndicateSuccess(msbuildSdkDir, netcoreSdkVersion); } - private static string GetMinimumMSBuildVersion(string netcoreSdkDir) + private sealed class CompatibleSdkKey : IEquatable { - string minimumVersionFilePath = Path.Combine(netcoreSdkDir, "minimumMSBuildVersion"); - if (!File.Exists(minimumVersionFilePath)) + public readonly string DotnetExeDirectory; + public readonly Version MSBuildVersion; + + public CompatibleSdkKey(string dotnetExeDirectory, Version msbuildVersion) { - // smallest version that had resolver support and also - // greater than or equal to the version required by any - // .NET Core SDK that did not have this file. - return "15.3.0"; + DotnetExeDirectory = dotnetExeDirectory; + MSBuildVersion = msbuildVersion; } - return File.ReadLines(minimumVersionFilePath).First().Trim(); + public bool Equals(CompatibleSdkKey other) + { + return other != null + && DotnetExeDirectory == other.DotnetExeDirectory + && MSBuildVersion == other.MSBuildVersion; + } + + public override bool Equals(object obj) + { + return Equals(obj as CompatibleSdkValue); + } + + public override int GetHashCode() + { + int h1 = DotnetExeDirectory.GetHashCode(); + int h2 = MSBuildVersion.GetHashCode(); + return ((h1 << 5) + h1) ^ h2; + } + } + + private sealed class CompatibleSdkValue + { + public readonly string MostRecentCompatible; + public readonly string MostRecentCompatibleNonPreview; + + public CompatibleSdkValue(string mostRecentCompatible, string mostRecentCompatibleNonPreview) + { + MostRecentCompatible = mostRecentCompatible; + MostRecentCompatibleNonPreview = mostRecentCompatibleNonPreview; + } + } + + private string GetMostCompatibleSdk(string dotnetExeDirectory, Version msbuildVersion) + { + CompatibleSdkValue sdks = GetMostCompatibleSdks(dotnetExeDirectory, msbuildVersion); + if (_vsSettings.DisallowPrerelease()) + { + return sdks.MostRecentCompatibleNonPreview; + } + + return sdks.MostRecentCompatible; + } + + private CompatibleSdkValue GetMostCompatibleSdks(string dotnetExeDirectory, Version msbuildVersion) + { + return s_compatibleSdks.GetOrAdd( + new CompatibleSdkKey(dotnetExeDirectory, msbuildVersion), + key => + { + string mostRecent = null; + string mostRecentNonPreview = null; + + string[] availableSdks = NETCoreSdkResolver.GetAvailableSdks(key.DotnetExeDirectory); + for (int i = availableSdks.Length - 1; i >= 0; i--) + { + string netcoreSdkDir = availableSdks[i]; + string netcoreSdkVersion = new DirectoryInfo(netcoreSdkDir).Name; + Version minimumMSBuildVersion = GetMinimumMSBuildVersion(netcoreSdkDir); + + if (key.MSBuildVersion < minimumMSBuildVersion) + { + continue; + } + + if (mostRecent == null) + { + mostRecent = netcoreSdkDir; + } + + if (netcoreSdkVersion.IndexOf('-') < 0) + { + mostRecentNonPreview = netcoreSdkDir; + break; + } + } + + return new CompatibleSdkValue(mostRecent, mostRecentNonPreview); + }); + } + + private Version GetMinimumMSBuildVersion(string netcoreSdkDir) + { + return s_minimumMSBuildVersions.GetOrAdd( + netcoreSdkDir, + dir => + { + string minimumVersionFilePath = Path.Combine(netcoreSdkDir, "minimumMSBuildVersion"); + if (!File.Exists(minimumVersionFilePath)) + { + // smallest version that had resolver support and also + // greater than or equal to the version required by any + // .NET Core SDK that did not have this file. + return new Version(15, 3, 0); + } + + return Version.Parse(File.ReadLines(minimumVersionFilePath).First().Trim()); + }); } private static string GetMinimumVSDefinedSDKVersion() @@ -156,13 +295,24 @@ namespace Microsoft.DotNet.MSBuildSdkResolver return FXVersion.Compare(netCoreSdkFXVersion, minimumFXVersion) < 0; } - private string ResolveNetcoreSdkDirectory(SdkResolverContext context) + private NETCoreSdkResolver.Result ResolveNETCoreSdkDirectory(SdkResolverContext context, string dotnetExeDir) { - string exeDir = GetDotnetExeDirectory(); - string workingDir = context.SolutionFilePath ?? context.ProjectFilePath; - string netcoreSdkDir = Interop.hostfxr_resolve_sdk(exeDir, workingDir); + string globalJsonStartDir = Path.GetDirectoryName(context.SolutionFilePath ?? context.ProjectFilePath); + var result = NETCoreSdkResolver.ResolveSdk(dotnetExeDir, globalJsonStartDir, _vsSettings.DisallowPrerelease()); - return netcoreSdkDir; + if (result.ResolvedSdkDirectory != null + && result.GlobalJsonPath == null + && context.MSBuildVersion < GetMinimumMSBuildVersion(result.ResolvedSdkDirectory)) + { + string mostCompatible = GetMostCompatibleSdk(dotnetExeDir, context.MSBuildVersion); + + if (mostCompatible != null) + { + result.ResolvedSdkDirectory = mostCompatible; + } + } + + return result; } private string GetDotnetExeDirectory() @@ -176,14 +326,12 @@ namespace Microsoft.DotNet.MSBuildSdkResolver var environmentProvider = new EnvironmentProvider(_getEnvironmentVariable); var dotnetExe = environmentProvider.GetCommandPath("dotnet"); -#if NETSTANDARD2_0 if (dotnetExe != null && !Interop.RunningOnWindows) { // e.g. on Linux the 'dotnet' command from PATH is a symlink so we need to // resolve it to get the actual path to the binary - dotnetExe = Interop.realpath(dotnetExe) ?? dotnetExe; + dotnetExe = Interop.Unix.realpath(dotnetExe) ?? dotnetExe; } -#endif return Path.GetDirectoryName(dotnetExe); } diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj b/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj index 79c1f4797..894771928 100644 --- a/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj +++ b/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj @@ -16,7 +16,7 @@ - + @@ -26,6 +26,7 @@ + @@ -40,4 +41,12 @@ + + + + + true + + + diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.sln b/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.sln index 08c449079..21c171b2e 100644 --- a/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.sln +++ b/src/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.sln @@ -9,14 +9,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.MSBuildSdk EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Tools.Tests.Utilities", "..\..\test\Microsoft.DotNet.Tools.Tests.Utilities\Microsoft.DotNet.Tools.Tests.Utilities.csproj", "{E548D3D0-50E3-4485-B531-95585A5D0B85}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.TestFramework", "..\Microsoft.DotNet.TestFramework\Microsoft.DotNet.TestFramework.csproj", "{182FFFA6-AE8F-431C-9B17-2F30B2A8FE42}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestDependencies", "TestDependencies", "{0F45009E-9053-401D-91CA-8046D9EB310B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.Cli.Utils", "..\Microsoft.DotNet.Cli.Utils\Microsoft.DotNet.Cli.Utils.csproj", "{7F68DEFE-F2D3-453C-B155-51B674604D29}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Cli.Utils", "..\Microsoft.DotNet.Cli.Utils\Microsoft.DotNet.Cli.Utils.csproj", "{7F68DEFE-F2D3-453C-B155-51B674604D29}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.InternalAbstractions", "..\Microsoft.DotNet.InternalAbstractions\Microsoft.DotNet.InternalAbstractions.csproj", "{A54567A1-E8DE-4B8C-9156-D895B9D016DB}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.TestFramework", "..\..\test\Microsoft.DotNet.TestFramework\Microsoft.DotNet.TestFramework.csproj", "{47C99775-27DF-4452-A1A3-2182FFA19BF2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,10 +35,6 @@ Global {E548D3D0-50E3-4485-B531-95585A5D0B85}.Debug|Any CPU.Build.0 = Debug|Any CPU {E548D3D0-50E3-4485-B531-95585A5D0B85}.Release|Any CPU.ActiveCfg = Release|Any CPU {E548D3D0-50E3-4485-B531-95585A5D0B85}.Release|Any CPU.Build.0 = Release|Any CPU - {182FFFA6-AE8F-431C-9B17-2F30B2A8FE42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {182FFFA6-AE8F-431C-9B17-2F30B2A8FE42}.Debug|Any CPU.Build.0 = Debug|Any CPU - {182FFFA6-AE8F-431C-9B17-2F30B2A8FE42}.Release|Any CPU.ActiveCfg = Release|Any CPU - {182FFFA6-AE8F-431C-9B17-2F30B2A8FE42}.Release|Any CPU.Build.0 = Release|Any CPU {7F68DEFE-F2D3-453C-B155-51B674604D29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7F68DEFE-F2D3-453C-B155-51B674604D29}.Debug|Any CPU.Build.0 = Debug|Any CPU {7F68DEFE-F2D3-453C-B155-51B674604D29}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -47,14 +43,21 @@ Global {A54567A1-E8DE-4B8C-9156-D895B9D016DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {A54567A1-E8DE-4B8C-9156-D895B9D016DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {A54567A1-E8DE-4B8C-9156-D895B9D016DB}.Release|Any CPU.Build.0 = Release|Any CPU + {47C99775-27DF-4452-A1A3-2182FFA19BF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47C99775-27DF-4452-A1A3-2182FFA19BF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47C99775-27DF-4452-A1A3-2182FFA19BF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47C99775-27DF-4452-A1A3-2182FFA19BF2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {E548D3D0-50E3-4485-B531-95585A5D0B85} = {0F45009E-9053-401D-91CA-8046D9EB310B} - {182FFFA6-AE8F-431C-9B17-2F30B2A8FE42} = {0F45009E-9053-401D-91CA-8046D9EB310B} {7F68DEFE-F2D3-453C-B155-51B674604D29} = {0F45009E-9053-401D-91CA-8046D9EB310B} {A54567A1-E8DE-4B8C-9156-D895B9D016DB} = {0F45009E-9053-401D-91CA-8046D9EB310B} + {47C99775-27DF-4452-A1A3-2182FFA19BF2} = {0F45009E-9053-401D-91CA-8046D9EB310B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DAE5F0E8-9018-4F6B-BB7F-541CA5E0E98B} EndGlobalSection EndGlobal diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/NETCoreSdkResolver.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/NETCoreSdkResolver.cs new file mode 100644 index 000000000..e00c618d9 --- /dev/null +++ b/src/Microsoft.DotNet.MSBuildSdkResolver/NETCoreSdkResolver.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics; + +namespace Microsoft.DotNet.MSBuildSdkResolver +{ + internal static class NETCoreSdkResolver + { + public sealed class Result + { + /// + /// Path to .NET Core SDK selected by hostfxr (e.g. C:\Program Files\dotnet\sdk\2.1.300). + /// + public string ResolvedSdkDirectory; + + /// + /// Path to global.json file that impacted resolution. + /// + public string GlobalJsonPath; + + public void Initialize(Interop.hostfxr_resolve_sdk2_result_key_t key, string value) + { + switch (key) + { + case Interop.hostfxr_resolve_sdk2_result_key_t.resolved_sdk_dir: + ResolvedSdkDirectory = value; + break; + case Interop.hostfxr_resolve_sdk2_result_key_t.global_json_path: + GlobalJsonPath = value; + break; + } + } + } + + public static Result ResolveSdk( + string dotnetExeDirectory, + string globalJsonStartDirectory, + bool disallowPrerelease = false) + { + var result = new Result(); + var flags = disallowPrerelease ? Interop.hostfxr_resolve_sdk2_flags_t.disallow_prerelease : 0; + + int errorCode = Interop.RunningOnWindows + ? Interop.Windows.hostfxr_resolve_sdk2(dotnetExeDirectory, globalJsonStartDirectory, flags, result.Initialize) + : Interop.Unix.hostfxr_resolve_sdk2(dotnetExeDirectory, globalJsonStartDirectory, flags, result.Initialize); + + Debug.Assert((errorCode == 0) == (result.ResolvedSdkDirectory != null)); + return result; + } + + private sealed class SdkList + { + public string[] Entries; + + public void Initialize(int count, string[] entries) + { + entries = entries ?? Array.Empty(); + Debug.Assert(count == entries.Length); + Entries = entries; + } + } + + public static string[] GetAvailableSdks(string dotnetExeDirectory) + { + var list = new SdkList(); + + int errorCode = Interop.RunningOnWindows + ? Interop.Windows.hostfxr_get_available_sdks(dotnetExeDirectory, list.Initialize) + : Interop.Unix.hostfxr_get_available_sdks(dotnetExeDirectory, list.Initialize); + + return list.Entries; + } + } +} diff --git a/src/Microsoft.DotNet.MSBuildSdkResolver/VSSettings.cs b/src/Microsoft.DotNet.MSBuildSdkResolver/VSSettings.cs new file mode 100644 index 000000000..46dfda2e6 --- /dev/null +++ b/src/Microsoft.DotNet.MSBuildSdkResolver/VSSettings.cs @@ -0,0 +1,155 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +#if NET46 +using Microsoft.VisualStudio.Setup.Configuration; +#endif + +namespace Microsoft.DotNet.MSBuildSdkResolver +{ + internal sealed class VSSettings + { + private readonly object _lock = new object(); + private readonly string _settingsFilePath; + private readonly bool _disallowPrereleaseByDefault; + private FileInfo _settingsFile; + private bool _disallowPrerelease; + + // In the product, this singleton is used. It must be safe to use in parallel on multiple threads. + // In tests, mock instances can be created with the test constructor below. + public static readonly VSSettings Ambient = new VSSettings(); + + private VSSettings() + { +#if NET46 + if (!Interop.RunningOnWindows) + { + return; + } + + string instanceId; + string installationVersion; + bool isPrerelease; + + try + { + var configuration = new SetupConfiguration(); + var instance = configuration.GetInstanceForCurrentProcess(); + + instanceId = instance.GetInstanceId(); + installationVersion = instance.GetInstallationVersion(); + isPrerelease = ((ISetupInstanceCatalog)instance).IsPrerelease(); + } + catch (COMException) + { + return; + } + + var version = Version.Parse(installationVersion); + + _settingsFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "VisualStudio", + version.Major + ".0_" + instanceId, + "sdk.txt"); + + _disallowPrereleaseByDefault = !isPrerelease; + _disallowPrerelease = _disallowPrereleaseByDefault; +#endif + } + + // Test constructor + public VSSettings(string settingsFilePath, bool disallowPrereleaseByDefault) + { + _settingsFilePath = settingsFilePath; + _disallowPrereleaseByDefault = disallowPrereleaseByDefault; + _disallowPrerelease = _disallowPrereleaseByDefault; + } + + public bool DisallowPrerelease() + { + if (_settingsFilePath != null) + { + Refresh(); + } + + return _disallowPrerelease; + } + + private void Refresh() + { + Debug.Assert(_settingsFilePath != null); + + var file = new FileInfo(_settingsFilePath); + + // NB: All calls to Exists and LastWriteTimeUtc below will not hit the disk + // They will return data obtained during Refresh() here. + file.Refresh(); + + lock (_lock) + { + // File does not exist -> use default. + if (!file.Exists) + { + _disallowPrerelease = _disallowPrereleaseByDefault; + _settingsFile = file; + return; + } + + // File has not changed -> reuse prior read. + if (_settingsFile?.Exists == true && file.LastWriteTimeUtc <= _settingsFile.LastWriteTimeUtc) + { + return; + } + + // File has changed -> read from disk + // If we encounter an I/O exception, assume writer is in the process of updating file, + // ignore the exception, and use stale settings until the next resolution. + try + { + ReadFromDisk(); + _settingsFile = file; + return; + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private void ReadFromDisk() + { + using (var reader = new StreamReader(_settingsFilePath)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + int indexOfEquals = line.IndexOf('='); + if (indexOfEquals < 0 || indexOfEquals == (line.Length - 1)) + { + continue; + } + + string key = line.Substring(0, indexOfEquals).Trim(); + string value = line.Substring(indexOfEquals + 1).Trim(); + + if (key.Equals("UsePreviews", StringComparison.OrdinalIgnoreCase) + && bool.TryParse(value, out bool usePreviews)) + { + _disallowPrerelease = !usePreviews; + return; + } + } + } + + // File does not have UsePreviews entry -> use default + _disallowPrerelease = _disallowPrereleaseByDefault; + } + } +} + diff --git a/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs b/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs index bea3dde7e..55cac8275 100644 --- a/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs +++ b/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs @@ -48,7 +48,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests var resolver = environment.CreateResolver(); var result = (MockResult)resolver.Resolve( new SdkReference("Some.Test.Sdk", null, null), - new MockContext { ProjectFilePath = environment.TestDirectory.FullName }, + new MockContext { ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); result.Success.Should().BeTrue(); @@ -68,7 +68,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests var resolver = environment.CreateResolver(); var result = (MockResult)resolver.Resolve( new SdkReference("Some.Test.Sdk", null, "999.99.99"), - new MockContext { ProjectFilePath = environment.TestDirectory.FullName }, + new MockContext { ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); result.Success.Should().BeFalse(); @@ -81,7 +81,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests } [Fact] - public void ItReturnsNullWhenTheSDKRequiresAHigherVersionOfMSBuildThanTheOneAvailable() + public void ItReturnsNullWhenTheSDKRequiresAHigherVersionOfMSBuildThanAnyOneAvailable() { var environment = new TestEnvironment(); var expected = @@ -94,7 +94,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests new MockContext { MSBuildVersion = new Version(1, 0), - ProjectFilePath = environment.TestDirectory.FullName + ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); @@ -107,6 +107,73 @@ namespace Microsoft.DotNet.Cli.Utils.Tests + " version that requires the MSBuild version currently available."); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItReturnsHighestSdkAvailableThatIsCompatibleWithMSBuild(bool disallowPreviews) + { + var environment = new TestEnvironment(identifier: disallowPreviews.ToString()) + { + DisallowPrereleaseByDefault = disallowPreviews + }; + + var compatibleRtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "98.98.98", new Version(19, 0, 0, 0)); + var compatiblePreview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "99.99.99-preview", new Version(20, 0, 0, 0)); + var incompatible = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "100.100.100", new Version(21, 0, 0, 0)); + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + + var resolver = environment.CreateResolver(); + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext + { + MSBuildVersion = new Version(20, 0, 0, 0), + ProjectFileDirectory = environment.TestDirectory, + }, + new MockFactory()); + + result.Success.Should().BeTrue(); + result.Path.Should().Be((disallowPreviews ? compatibleRtm : compatiblePreview).FullName); + result.Version.Should().Be(disallowPreviews ? "98.98.98" : "99.99.99-preview"); + result.Warnings.Should().BeNullOrEmpty(); + result.Errors.Should().BeNullOrEmpty(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItDoesNotReturnHighestSdkAvailableThatIsCompatibleWithMSBuildWhenVersionInGlobalJsonCannotBeFound(bool disallowPreviews) + { + var environment = new TestEnvironment(callingMethod: "ItDoesNotReturnHighest___", identifier: disallowPreviews.ToString()) + { + DisallowPrereleaseByDefault = disallowPreviews + }; + + var compatibleRtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "98.98.98", new Version(19, 0, 0, 0)); + var compatiblePreview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "99.99.99-preview", new Version(20, 0, 0, 0)); + var incompatible = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "100.100.100", new Version(21, 0, 0, 0)); + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + environment.CreateGlobalJson(environment.TestDirectory, "1.2.3"); + + var resolver = environment.CreateResolver(); + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext + { + MSBuildVersion = new Version(20, 0, 0, 0), + ProjectFileDirectory = environment.TestDirectory, + }, + new MockFactory()); + + result.Success.Should().BeFalse(); + result.Path.Should().BeNull(); + result.Version.Should().BeNull();; + result.Warnings.Should().BeNullOrEmpty(); + result.Errors.Should().NotBeEmpty(); + } + [Fact] public void ItReturnsNullWhenTheDefaultVSRequiredSDKVersionIsHigherThanTheSDKVersionAvailable() { @@ -118,7 +185,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests var resolver = environment.CreateResolver(); var result = (MockResult)resolver.Resolve( new SdkReference("Some.Test.Sdk", null, "1.0.0"), - new MockContext { ProjectFilePath = environment.TestDirectory.FullName }, + new MockContext { ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); result.Success.Should().BeFalse(); @@ -142,7 +209,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests var resolver = environment.CreateResolver(); var result = (MockResult)resolver.Resolve( new SdkReference("Some.Test.Sdk", null, "1.0.0"), - new MockContext { ProjectFilePath = environment.TestDirectory.FullName }, + new MockContext { ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); result.Success.Should().BeFalse(); @@ -165,7 +232,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests var resolver = environment.CreateResolver(); var result = (MockResult)resolver.Resolve( new SdkReference("Some.Test.Sdk", null, "99.99.99"), - new MockContext { ProjectFilePath = environment.TestDirectory.FullName }, + new MockContext { ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); result.Success.Should().BeTrue(); @@ -186,7 +253,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests var resolver = environment.CreateResolver(); var result = (MockResult)resolver.Resolve( new SdkReference("Some.Test.Sdk", null, "99.99.99"), - new MockContext { ProjectFilePath = environment.TestDirectory.FullName }, + new MockContext { ProjectFileDirectory = environment.TestDirectory }, new MockFactory()); result.Success.Should().BeTrue(); @@ -196,6 +263,159 @@ namespace Microsoft.DotNet.Cli.Utils.Tests result.Errors.Should().BeNullOrEmpty(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItDisallowsPreviewsBasedOnDefault(bool disallowPreviewsByDefault) + { + var environment = new TestEnvironment(identifier: disallowPreviewsByDefault.ToString()); + var rtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "10.0.0"); + var preview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "11.0.0-preview1"); + var expected = disallowPreviewsByDefault ? rtm : preview; + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + environment.DisallowPrereleaseByDefault = disallowPreviewsByDefault; + + var resolver = environment.CreateResolver(); + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext { ProjectFileDirectory = environment.TestDirectory }, + new MockFactory()); + + result.Success.Should().BeTrue(); + result.Path.Should().Be(expected.FullName); + result.Version.Should().Be(disallowPreviewsByDefault ? "10.0.0" : "11.0.0-preview1"); + result.Warnings.Should().BeNullOrEmpty(); + result.Errors.Should().BeNullOrEmpty(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItDisallowsPreviewsBasedOnFile(bool disallowPreviews) + { + var environment = new TestEnvironment(identifier: disallowPreviews.ToString()); + var rtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "10.0.0"); + var preview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "11.0.0-preview1"); + var expected = disallowPreviews ? rtm : preview; + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + environment.DisallowPrereleaseByDefault = !disallowPreviews; + environment.CreateVSSettingsFile(disallowPreviews); + + var resolver = environment.CreateResolver(); + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext { ProjectFileDirectory = environment.TestDirectory }, + new MockFactory()); + + result.Success.Should().BeTrue(); + result.Path.Should().Be(expected.FullName); + result.Version.Should().Be(disallowPreviews ? "10.0.0" : "11.0.0-preview1"); + result.Warnings.Should().BeNullOrEmpty(); + result.Errors.Should().BeNullOrEmpty(); + } + + [Fact] + public void ItObservesChangesToVSSettingsFile() + { + var environment = new TestEnvironment(); + var rtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "10.0.0"); + var preview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "11.0.0-preview1"); + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + environment.CreateVSSettingsFile(disallowPreviews: true); + var resolver = environment.CreateResolver(); + + void Check(bool disallowPreviews, string message) + { + // check twice because file-up-to-date is a separate code path + for (int i = 0; i < 2; i++) + { + + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext { ProjectFileDirectory = environment.TestDirectory }, + new MockFactory()); + + string m = $"{message} ({i})"; + var expected = disallowPreviews ? rtm : preview; + result.Success.Should().BeTrue(m); + result.Path.Should().Be(expected.FullName, m); + result.Version.Should().Be(disallowPreviews ? "10.0.0" : "11.0.0-preview1", m); + result.Warnings.Should().BeNullOrEmpty(m); + result.Errors.Should().BeNullOrEmpty(m); + } + } + + environment.DeleteVSSettingsFile(); + Check(disallowPreviews: false, message: "default with no file"); + + environment.CreateVSSettingsFile(disallowPreviews: true); + Check(disallowPreviews: true, message: "file changed to disallow previews"); + + environment.CreateVSSettingsFile(disallowPreviews: false); + Check(disallowPreviews: false, message: "file changed to not disallow previews"); + + environment.CreateVSSettingsFile(disallowPreviews: true); + Check(disallowPreviews: true, message: "file changed back to disallow previews"); + + environment.DeleteVSSettingsFile(); + Check(disallowPreviews: false, message: "file deleted to return to default"); + } + + [Fact] + public void ItAllowsPreviewWhenGlobalJsonHasPreviewIrrespectiveOfSetting() + { + var environment = new TestEnvironment(); + var rtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "10.0.0"); + var preview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "11.0.0-preview1"); + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + environment.DisallowPrereleaseByDefault = true; + environment.CreateGlobalJson(environment.TestDirectory, "11.0.0-preview1"); + + var resolver = environment.CreateResolver(); + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext { ProjectFileDirectory = environment.TestDirectory }, + new MockFactory()); + + result.Success.Should().BeTrue(); + result.Path.Should().Be(preview.FullName); + result.Version.Should().Be("11.0.0-preview1"); + result.Warnings.Should().BeNullOrEmpty(); + result.Errors.Should().BeNullOrEmpty(); + } + + [Fact] + public void ItRespectsAmbientVSSettings() + { + // When run in test explorer in VS, this will actually locate the settings for the current VS instance + // based on location of testhost executable. This gives us some coverage threw that path but we cannot + // fix our expectations since the behavior will vary (by design) based on the current VS instance's settings. + var vsSettings = VSSettings.Ambient; + + var environment = new TestEnvironment(); + var rtm = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "10.0.0"); + var preview = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "11.0.0-preview1"); + var expected = vsSettings.DisallowPrerelease() ? rtm : preview; + + environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + + var resolver = environment.CreateResolver(useAmbientSettings: true); + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + new MockContext { ProjectFileDirectory = environment.TestDirectory }, + new MockFactory()); + + result.Success.Should().BeTrue(); + result.Path.Should().Be(expected.FullName); + result.Version.Should().Be(vsSettings.DisallowPrerelease() ? "10.0.0" : "11.0.0-preview1"); + result.Warnings.Should().BeNullOrEmpty(); + result.Errors.Should().BeNullOrEmpty(); + } + private enum ProgramFiles { X64, @@ -210,6 +430,8 @@ namespace Microsoft.DotNet.Cli.Utils.Tests public string PathEnvironmentVariable { get; set; } public DirectoryInfo TestDirectory { get; } + public FileInfo VSSettingsFile { get; set; } + public bool DisallowPrereleaseByDefault { get; set; } public TestEnvironment(string identifier = "", [CallerMemberName] string callingMethod = "") { @@ -223,8 +445,12 @@ namespace Microsoft.DotNet.Cli.Utils.Tests PathEnvironmentVariable = string.Empty; } - public SdkResolver CreateResolver() - => new DotNetMSBuildSdkResolver(GetEnvironmentVariable); + public SdkResolver CreateResolver(bool useAmbientSettings = false) + => new DotNetMSBuildSdkResolver( + GetEnvironmentVariable, + useAmbientSettings + ? VSSettings.Ambient + : new VSSettings(VSSettingsFile?.FullName, DisallowPrereleaseByDefault)); public DirectoryInfo GetSdkDirectory(ProgramFiles programFiles, string sdkName, string sdkVersion) => TestDirectory.GetDirectory( @@ -317,6 +543,34 @@ namespace Microsoft.DotNet.Cli.Utils.Tests string baseDirectory = AppContext.BaseDirectory; return Path.Combine(baseDirectory, "minimumVSDefinedSDKVersion"); } + + public void CreateVSSettingsFile(bool disallowPreviews) + { + VSSettingsFile = TestDirectory.GetFile("sdk.txt"); + + // Guard against tests writing too fast for the up-to-date check + // It happens more often on Unix due to https://github.com/dotnet/corefx/issues/12403 + var lastWriteTimeUtc = VSSettingsFile.Exists ? VSSettingsFile.LastWriteTimeUtc : DateTime.MinValue; + for (int sleep = 10; sleep < 3000; sleep *= 2) + { + File.WriteAllText(VSSettingsFile.FullName, $"UsePreviews={!disallowPreviews}"); + VSSettingsFile.Refresh(); + + if (VSSettingsFile.LastWriteTimeUtc > lastWriteTimeUtc) + { + return; + } + + System.Threading.Thread.Sleep(sleep); + } + + throw new InvalidOperationException("LastWriteTime is not changing."); + } + + public void DeleteVSSettingsFile() + { + VSSettingsFile.Delete(); + } } private sealed class MockContext : SdkResolverContext @@ -325,6 +579,12 @@ namespace Microsoft.DotNet.Cli.Utils.Tests public new string SolutionFilePath { get => base.SolutionFilePath; set => base.SolutionFilePath = value; } public new Version MSBuildVersion { get => base.MSBuildVersion; set => base.MSBuildVersion = value; } + public DirectoryInfo ProjectFileDirectory + { + get => new DirectoryInfo(Path.GetDirectoryName(ProjectFilePath)); + set => ProjectFilePath = value.GetFile("test.csproj").FullName; + } + public MockContext() { MSBuildVersion = new Version(15, 3, 0); @@ -352,12 +612,7 @@ namespace Microsoft.DotNet.Cli.Utils.Tests Errors = errors; } - public new bool Success - { - get => base.Success; - private set => base.Success = value; - } - + public override bool Success { get; protected set; } public override string Version { get; protected set; } public override string Path { get; protected set; } public IEnumerable Errors { get; } diff --git a/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/Microsoft.DotNet.MSBuildSdkResolver.Tests.csproj b/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/Microsoft.DotNet.MSBuildSdkResolver.Tests.csproj index b480a818f..1b0636beb 100644 --- a/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/Microsoft.DotNet.MSBuildSdkResolver.Tests.csproj +++ b/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/Microsoft.DotNet.MSBuildSdkResolver.Tests.csproj @@ -2,6 +2,14 @@ net461;$(CliTargetFramework) $(CliTargetFramework) + + + net461 + $(MicrosoftNETCoreAppPackageVersion) Exe ../../tools/Key.snk @@ -19,9 +27,9 @@ - - - + + + @@ -32,7 +40,10 @@ - - + + +