From de951fdd171ae8d44db25144b5dcf083c7edfb19 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 10:44:59 -0700 Subject: [PATCH 01/15] Add concurrency test + Installation Validation This implements validation of installs using hostfxr apis to ensure the install actually works and not just that the manifest is tracking the installs correctly in e2e tests. It also adds a test to show that we can do multiple installs in the same directory without failing. It also improves the existing test logic to not assume a hard-coded debug value for the dnup process. --- .../Telemetry/EnvironmentDetectionRule.cs | 8 +- .../Internal/ArchiveDotnetExtractor.cs | 4 +- .../dnup/ArchiveInstallationValidator.cs | 138 +++++++++++++++++- ...ironmentVariableMockReleaseInfoProvider.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 8 +- src/Installer/dnup/DnupManifestJsonContext.cs | 2 +- src/Installer/dnup/DnupSharedManifest.cs | 2 +- src/Installer/dnup/DotnetInstallManager.cs | 2 +- src/Installer/dnup/dnup.csproj | 1 + test/dnup.Tests/DnupE2Etest.cs | 111 ++++++++++++-- test/dnup.Tests/ReleaseManifestTests.cs | 3 +- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 32 +++- 12 files changed, 281 insertions(+), 32 deletions(-) diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs index 5cd73f53abb8..5f1aab066131 100644 --- a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs +++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs @@ -33,7 +33,7 @@ public BooleanEnvironmentRule(params string[] variables) public override bool IsMatch() { - return _variables.Any(variable => + return _variables.Any(variable => bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value); } } @@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables) /// The result value if the rule matches; otherwise, null. public T? GetResult() { - return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) - ? _result + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) + ? _result : null; } -} \ No newline at end of file +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs index 13ac693f2096..3c6513209d64 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs @@ -115,14 +115,14 @@ public void Commit(IEnumerable existingSdkVersions) { // When no-progress is enabled, install without progress display Console.WriteLine($"Installing .NET SDK {_resolvedVersion}..."); - + // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); if (extractResult != null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } - + Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete."); } else diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index 8367577ed77e..ca3bb0084108 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -1,17 +1,153 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; using Microsoft.Dotnet.Installation.Internal; +using System.Runtime.InteropServices; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.NativeWrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveInstallationValidator : IInstallationValidator { + private const string HostFxrRuntimeProperty = "HOSTFXR_PATH"; + private static readonly Dictionary RuntimeMonikerByComponent = new() + { + [InstallComponent.Runtime] = "Microsoft.NETCore.App", + [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App", + [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App" + }; + public bool Validate(DotnetInstall install) { - // TODO: Implement validation logic + string? installRoot = install.InstallRoot.Path; + if (string.IsNullOrEmpty(installRoot)) + { + return false; + } + + string dotnetMuxerPath = Path.Combine(installRoot, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetMuxerPath)) + { + return false; + } + + string resolvedVersion = install.Version.ToString(); + if (!ValidateComponentLayout(installRoot, resolvedVersion, install.Component)) + { + return false; + } + + if (!ValidateWithHostFxr(installRoot, install.Version, install.Component)) + { + return false; + } + + // We should also validate whether the host is the maximum version or higher than all installed versions. + return true; } + + private static bool ValidateComponentLayout(string installRoot, string resolvedVersion, InstallComponent component) + { + if (component == InstallComponent.SDK) + { + string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion); + return Directory.Exists(sdkDirectory); + } + + if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion); + return Directory.Exists(runtimeDirectory); + } + + return false; + } + + private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component) + { + try + { + ConfigureHostFxrResolution(installRoot); + + var bundleProvider = new NETBundlesNativeWrapper(); + NetEnvironmentInfo environmentInfo = bundleProvider.GetDotnetEnvironmentInfo(installRoot); + + if (component == InstallComponent.SDK) + { + string expectedPath = Path.Combine(installRoot, "sdk", resolvedVersion.ToString()); + return environmentInfo.SdkInfo.Any(sdk => + string.Equals(sdk.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(sdk.Path, expectedPath)); + } + + if (!RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + return false; + } + + string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString()); + return environmentInfo.RuntimeInfo.Any(runtime => + string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) && + string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(runtime.Path, expectedRuntimePath)); + } + catch + { + return false; + } + } + + private static void ConfigureHostFxrResolution(string installRoot) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + if (AppContext.GetData(HostFxrRuntimeProperty) != null) + { + return; + } + + string? hostFxrPath = FindHostFxrLibrary(installRoot); + if (hostFxrPath != null) + { + AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); + } + } + + private static string? FindHostFxrLibrary(string installRoot) + { + string hostFxrDirectory = Path.Combine(installRoot, "host", "fxr"); + if (!Directory.Exists(hostFxrDirectory)) + { + return null; + } + + string libraryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "hostfxr.dll" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "libhostfxr.dylib" + : "libhostfxr.so"; + + return Directory.EnumerateFiles(hostFxrDirectory, libraryName, SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + private static void TraceValidation(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("DNUP_VALIDATOR_TRACE"), "1", StringComparison.Ordinal)) + { + Console.WriteLine($"[ArchiveInstallationValidator] {message}"); + } + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs index 29c71f70f56e..78a2b9a819dd 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -56,6 +56,6 @@ public ReleaseVersion GetLatestVersion(InstallComponent component, string channe } public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); - + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 882488a1e359..c4481b4bc247 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -3,14 +3,12 @@ using System.CommandLine; using System.Net.Http; +using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Spectre.Console; - - using SpectreAnsiConsole = Spectre.Console.AnsiConsole; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; -using System.Runtime.InteropServices; -using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 40fd57b79d95..0c7809151c1f 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -1,9 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json.Serialization; using System.Collections.Generic; using Microsoft.Dotnet.Installation.Internal; +using System.Text.Json.Serialization; namespace Microsoft.DotNet.Tools.Bootstrapper { diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 0d221388b652..cb075a5e2807 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.IO; +using System.Linq; using System.Text.Json; using System.Threading; using Microsoft.Dotnet.Installation.Internal; diff --git a/src/Installer/dnup/DotnetInstallManager.cs b/src/Installer/dnup/DotnetInstallManager.cs index bece54f44599..67e1652f37b4 100644 --- a/src/Installer/dnup/DotnetInstallManager.cs +++ b/src/Installer/dnup/DotnetInstallManager.cs @@ -30,7 +30,7 @@ public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) } string installDir = Path.GetDirectoryName(foundDotnet)!; - + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index b0a3cbc30fb3..a272529118cb 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -40,6 +40,7 @@ + diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 7ddea9f7f669..d225ded86104 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -5,11 +5,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using FluentAssertions; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; -using Microsoft.Dotnet.Installation; using Xunit; using Microsoft.Dotnet.Installation.Internal; @@ -59,9 +60,9 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); // Execute the command with explicit manifest path as a separate process - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); @@ -80,7 +81,10 @@ public void Test(string channel) matchingInstalls.Should().ContainSingle(); var install = matchingInstalls[0]; - install.Component.Should().Be(Microsoft.Dotnet.Installation.InstallComponent.SDK); + install.Component.Should().Be(InstallComponent.SDK); + + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for installed version {install.Version} at {testEnv.InstallPath}"); // Verify the installed version matches what the resolver predicted if (!updateChannel.IsFullySpecifiedVersion()) @@ -120,8 +124,8 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); + (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); List firstDnupInstalls = new(); // Verify the installation was successful @@ -131,12 +135,14 @@ public void TestReusesExistingInstall() firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } - firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); + var firstInstallRecord = firstDnupInstalls.Single(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)); + DnupTestUtilities.ValidateInstall(firstInstallRecord).Should().BeTrue( + $"ArchiveInstallationValidator failed for initial install of {channel} at {testEnv.InstallPath}"); // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); // Verify the output contains a message indicating the SDK is already installed output.Should().Contain("is already installed, skipping installation", @@ -159,5 +165,90 @@ public void TestReusesExistingInstall() matchingInstalls.Should().ContainSingle(); // And it should be for the specified version matchingInstalls[0].Version.ToString().Should().Be(channel); + DnupTestUtilities.ValidateInstall(matchingInstalls[0]).Should().BeTrue( + $"ArchiveInstallationValidator failed after reinstall attempt for {channel} at {testEnv.InstallPath}"); + } +} + +/// +/// Tests that cover concurrent installs targeting the same install root and manifest. +/// +[Collection("DnupConcurrencyCollection")] +public class ConcurrentInstallationTests +{ + public static IEnumerable ConcurrentInstallChannels => new List + { + new object[] { "9.0.103", "9.0.103", false }, + new object[] { "9.0.103", "preview", true } + }; + + [Theory] + [MemberData(nameof(ConcurrentInstallChannels))] + public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, string secondChannel, bool expectDistinct) + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + var resolver = new ManifestChannelVersionResolver(); + ReleaseVersion? firstResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(firstChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + ReleaseVersion? secondResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(secondChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + + firstResolved.Should().NotBeNull($"Channel {firstChannel} should resolve to a version"); + secondResolved.Should().NotBeNull($"Channel {secondChannel} should resolve to a version"); + + if (expectDistinct && string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Skipping concurrent distinct-version scenario because both channels resolved to {firstResolved}"); + return; + } + var args1 = DnupTestUtilities.BuildArguments(firstChannel, testEnv.InstallPath, testEnv.ManifestPath); + var args2 = DnupTestUtilities.BuildArguments(secondChannel, testEnv.InstallPath, testEnv.ManifestPath); + + var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); + var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); + + var results = await Task.WhenAll(installTask1, installTask2); + + results[0].exitCode.Should().Be(0, + $"First concurrent install failed with exit code {results[0].exitCode}. Output:\n{results[0].output}"); + results[1].exitCode.Should().Be(0, + $"Second concurrent install failed with exit code {results[1].exitCode}. Output:\n{results[1].output}"); + + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + var installs = manifest.GetInstalledVersions() + .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) + .ToList(); + + int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; + installs.Should().HaveCount(expectedInstallCount); + + var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + firstResolved.ToString()!, + secondResolved!.ToString()! + }; + + foreach (var install in installs) + { + install.Component.Should().Be(InstallComponent.SDK); + expectedVersions.Should().Contain(install.Version.ToString()); + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); + } + + var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + actualVersions.Should().BeEquivalentTo(expectedVersions); + } } } diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 85e7cfcf8868..63dab25f79a3 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -1,8 +1,7 @@ using System; -using Xunit; -using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; +using Xunit; namespace Microsoft.DotNet.Tools.Dnup.Tests { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 0df8b5c6295f..7f25c2393d6b 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -137,6 +137,12 @@ private static string GetRepositoryRoot() throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); } + public static bool ValidateInstall(DotnetInstall install) + { + var validator = new ArchiveInstallationValidator(); + return validator.Validate(install); + } + private static string LocateDnupAssembly(string repoRoot) { string artifactsRoot = Path.Combine(repoRoot, "artifacts", "bin", "dnup"); @@ -149,12 +155,16 @@ private static string LocateDnupAssembly(string repoRoot) string? tfm = testAssemblyDirectory.Name; string? configuration = testAssemblyDirectory.Parent?.Name; - if (!string.IsNullOrEmpty(configuration) && !string.IsNullOrEmpty(tfm)) + if (!string.IsNullOrEmpty(tfm)) { - string candidate = Path.Combine(artifactsRoot, configuration, tfm, "dnup.dll"); - if (File.Exists(candidate)) + IEnumerable configurationCandidates = BuildConfigurationCandidates(configuration); + foreach (string candidateConfig in configurationCandidates) { - return candidate; + string candidate = Path.Combine(artifactsRoot, candidateConfig, tfm, "dnup.dll"); + if (File.Exists(candidate)) + { + return candidate; + } } } @@ -170,6 +180,20 @@ private static string LocateDnupAssembly(string repoRoot) throw new FileNotFoundException($"dnup executable not found under {artifactsRoot}. Ensure the dnup project is built before running tests."); } + private static IEnumerable BuildConfigurationCandidates(string? configuration) + { + var candidates = new List(); + if (!string.IsNullOrEmpty(configuration)) + { + candidates.Add(configuration); + } + + candidates.Add("Debug"); + candidates.Add("Release"); + + return candidates.Distinct(StringComparer.OrdinalIgnoreCase); + } + /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From ca115a42075c647bb082161e7d0358153071103c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 12:56:13 -0700 Subject: [PATCH 02/15] remove extra GCP fnct --- src/Installer/dnup/ArchiveInstallationValidator.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index ca3bb0084108..9a8af2a0733b 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -142,12 +142,4 @@ private static void ConfigureHostFxrResolution(string installRoot) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); } - - private static void TraceValidation(string message) - { - if (string.Equals(Environment.GetEnvironmentVariable("DNUP_VALIDATOR_TRACE"), "1", StringComparison.Ordinal)) - { - Console.WriteLine($"[ArchiveInstallationValidator] {message}"); - } - } } From 515508d131a0b8873db7942ae7af8a5d042e517a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 13:26:41 -0700 Subject: [PATCH 03/15] Fix new references --- test/dnup.Tests/DnupE2Etest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index d225ded86104..2ff41837d162 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -191,13 +191,13 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, var resolver = new ManifestChannelVersionResolver(); ReleaseVersion? firstResolved = resolver.Resolve( new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), new UpdateChannel(firstChannel), InstallComponent.SDK, new InstallRequestOptions())); ReleaseVersion? secondResolved = resolver.Resolve( new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), new UpdateChannel(secondChannel), InstallComponent.SDK, new InstallRequestOptions())); From d5844ab01ffd6ffac2f86f570714b8d1c6c35d50 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 14:11:27 -0700 Subject: [PATCH 04/15] Enable debugging into dnup process in test --- src/Installer/dnup/DnupDebugHelper.cs | 35 +++++++++++++++++++ src/Installer/dnup/Program.cs | 4 +++ .../src/RulesMissingDocumentation.md | 4 --- test/dnup.Tests/DnupE2Etest.cs | 1 + .../dnup.Tests/Utilities/DnupTestUtilities.cs | 31 ++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/Installer/dnup/DnupDebugHelper.cs diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs new file mode 100644 index 000000000000..07d00f0d86e7 --- /dev/null +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +// Copy of DebugHelper.cs in the SDK - port eventually. +internal static class DnupDebugHelper +{ + [Conditional("DEBUG")] + public static void HandleDebugSwitch(ref string[] args) + { + if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) + { + args = args.Skip(1).ToArray(); + WaitForDebugger(); + } + } + + public static void WaitForDebugger() + { +#if NET5_0_OR_GREATER + int processId = Environment.ProcessId; +#else + int processId = Process.GetCurrentProcess().Id; +#endif + + Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue"); + Console.WriteLine($"Process ID: {processId}"); + Console.ReadLine(); + } +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs index ee656bfa6003..4969b276d7ef 100644 --- a/src/Installer/dnup/Program.cs +++ b/src/Installer/dnup/Program.cs @@ -7,6 +7,10 @@ internal class DnupProgram { public static int Main(string[] args) { + // Handle --debug flag using the standard .NET SDK pattern + // This is DEBUG-only and removes the --debug flag from args + DnupDebugHelper.HandleDebugSwitch(ref args); + var parseResult = Parser.Parse(args); return Parser.Invoke(parseResult); } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md index c5820fde91ae..75e0f77588e2 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md @@ -2,7 +2,3 @@ Rule ID | Missing Help Link | Title | --------|-------------------|-------| -CA1873 | | Avoid potentially expensive logging | -CA1874 | | Use 'Regex.IsMatch' | -CA1875 | | Use 'Regex.Count' | -CA2023 | | Invalid braces in message template | diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 2ff41837d162..1fa6c46465bd 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -61,6 +61,7 @@ public void Test(string channel) // Execute the command with explicit manifest path as a separate process var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 7f25c2393d6b..ed244672862d 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -77,6 +77,14 @@ public static string[] BuildArguments(string channel, string installPath, string /// A tuple with exit code and captured output (if requested) public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { + // In DEBUG builds, automatically add --debug flag for easier debugging +#if DEBUG + if (!args.Contains("--debug")) + { + args = new[] { "--debug" }.Concat(args).ToArray(); + } +#endif + string repoRoot = GetRepositoryRoot(); string dnupPath = LocateDnupAssembly(repoRoot); @@ -121,6 +129,29 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c return (process.ExitCode, outputBuilder.ToString()); } + /// + /// Runs dnup process with debugging support - waits for debugger attachment + /// Note: This only works in DEBUG builds of dnup + /// + /// Command line arguments for dnup + /// Whether to capture and return the output + /// Working directory for the process + /// A tuple with exit code and captured output (if requested) + public static (int exitCode, string output) RunDnupProcessWithDebugger(string[] args, bool captureOutput = false, string? workingDirectory = null) + { + // Add --debug flag to enable debugger waiting (only works in DEBUG builds) + var debugArgs = new[] { "--debug" }.Concat(args).ToArray(); + + Console.WriteLine("Starting dnup process in debug mode..."); + Console.WriteLine("Note: --debug flag only works in DEBUG builds of dnup"); + Console.WriteLine("To attach debugger:"); + Console.WriteLine("1. In Visual Studio: Debug -> Attach to Process"); + Console.WriteLine("2. Find the dotnet.exe process running dnup"); + Console.WriteLine("3. Attach to it, then press Enter in the console"); + + return RunDnupProcess(debugArgs, captureOutput, workingDirectory); + } + private static string GetRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); From ec64dc990b574e978596cf1abeac1726a4434977 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 14:18:04 -0700 Subject: [PATCH 05/15] Create a window if we are in debug mode so we can press enter. --- test/dnup.Tests/Utilities/DnupTestUtilities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index ed244672862d..6b1a8cde375f 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -93,7 +93,7 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = true; + process.StartInfo.CreateNoWindow = !args.Contains("--debug"); process.StartInfo.RedirectStandardOutput = captureOutput; process.StartInfo.RedirectStandardError = captureOutput; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; From deed5e288fe1ccf651005b30ddcc6716358c7934 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 15:35:50 -0700 Subject: [PATCH 06/15] Can properly attach to test debug process though now it always expects an attacher --- test/dnup.Tests/DnupE2Etest.cs | 38 +++---- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 98 ++++++++++++------- 2 files changed, 86 insertions(+), 50 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 1fa6c46465bd..0de472f98eda 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -62,8 +62,9 @@ public void Test(string channel) // Execute the command with explicit manifest path as a separate process var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); + DnupProcessResult result = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + result.ExitCode.Should().Be(0, + $"dnup exited with code {result.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(result)}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); @@ -125,8 +126,9 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); + DnupProcessResult firstInstall = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + firstInstall.ExitCode.Should().Be(0, + $"First installation failed with exit code {firstInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(firstInstall)}"); List firstDnupInstalls = new(); // Verify the installation was successful @@ -142,16 +144,18 @@ public void TestReusesExistingInstall() // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); + DnupProcessResult secondInstall = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + secondInstall.ExitCode.Should().Be(0, + $"Second installation failed with exit code {secondInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(secondInstall)}"); - // Verify the output contains a message indicating the SDK is already installed - output.Should().Contain("is already installed, skipping installation", - "dnup should detect that the SDK is already installed and skip the installation"); + DnupTestUtilities.AssertOutput(secondInstall, output => + { + output.Should().Contain("is already installed, skipping installation", + "dnup should detect that the SDK is already installed and skip the installation"); - // The output should not contain download progress - output.Should().NotContain("Downloading .NET SDK", - "dnup should not attempt to download the SDK again"); + output.Should().NotContain("Downloading .NET SDK", + "dnup should not attempt to download the SDK again"); + }); List matchingInstalls = new(); // Verify the installation record in the manifest hasn't changed @@ -217,12 +221,12 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); - var results = await Task.WhenAll(installTask1, installTask2); + DnupProcessResult[] results = await Task.WhenAll(installTask1, installTask2); - results[0].exitCode.Should().Be(0, - $"First concurrent install failed with exit code {results[0].exitCode}. Output:\n{results[0].output}"); - results[1].exitCode.Should().Be(0, - $"Second concurrent install failed with exit code {results[1].exitCode}. Output:\n{results[1].output}"); + results[0].ExitCode.Should().Be(0, + $"First concurrent install failed with exit code {results[0].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[0])}"); + results[1].ExitCode.Should().Be(0, + $"Second concurrent install failed with exit code {results[1].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[1])}"); using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 6b1a8cde375f..31680cf2d51d 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -50,8 +50,10 @@ public static string[] BuildArguments(string channel, string installPath, string args.Add("--install-path"); args.Add(installPath); - args.Add("--interactive"); - args.Add("false"); +#if !DEBUG + args.Add("--interactive"); + args.Add("false"); +#endif // Add manifest path option if specified for test isolation if (!string.IsNullOrEmpty(manifestPath)) @@ -70,12 +72,12 @@ public static string[] BuildArguments(string channel, string installPath, string } /// - /// Runs the dnup executable as a separate process + /// Runs the dnup executable as a separate process. /// - /// Command line arguments for dnup - /// Whether to capture and return the output - /// A tuple with exit code and captured output (if requested) - public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) + /// Command line arguments for dnup. + /// Whether to capture and return the output. + /// Process result including exit code and output (if captured). + public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { // In DEBUG builds, automatically add --debug flag for easier debugging #if DEBUG @@ -92,23 +94,29 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c string repoDotnet = Path.Combine(repoRoot, ".dotnet", DnupUtilities.GetDotnetExeName()); process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; - process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = !args.Contains("--debug"); - process.StartInfo.RedirectStandardOutput = captureOutput; - process.StartInfo.RedirectStandardError = captureOutput; + + bool isDebugMode = args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase)); + bool useShellExecute = isDebugMode && OperatingSystem.IsWindows(); + + process.StartInfo.UseShellExecute = useShellExecute; + process.StartInfo.CreateNoWindow = !useShellExecute; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; + bool shouldCaptureOutput = captureOutput && !useShellExecute; + StringBuilder outputBuilder = new(); - if (captureOutput) + if (shouldCaptureOutput) { - process.OutputDataReceived += (sender, e) => + process.StartInfo.RedirectStandardOutput = shouldCaptureOutput; + process.StartInfo.RedirectStandardError = shouldCaptureOutput; + process.OutputDataReceived += (_, e) => { if (e.Data != null) { outputBuilder.AppendLine(e.Data); } }; - process.ErrorDataReceived += (sender, e) => + process.ErrorDataReceived += (_, e) => { if (e.Data != null) { @@ -119,39 +127,47 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c process.Start(); - if (captureOutput) + if (shouldCaptureOutput) { process.BeginOutputReadLine(); process.BeginErrorReadLine(); } + if (isDebugMode) + { + Console.WriteLine($"Started dnup process with PID: {process.Id}"); + Console.WriteLine(useShellExecute + ? "Interactive console window launched for debugger attachment." + : "Process is sharing the current console for debugger attachment."); + Console.WriteLine("To attach debugger: Debug -> Attach to Process -> Select the dotnet.exe process"); + } + process.WaitForExit(); - return (process.ExitCode, outputBuilder.ToString()); + + string output = shouldCaptureOutput ? outputBuilder.ToString() : string.Empty; + return new DnupProcessResult(process.ExitCode, output, shouldCaptureOutput); } /// - /// Runs dnup process with debugging support - waits for debugger attachment - /// Note: This only works in DEBUG builds of dnup + /// Executes output assertions only when dnup output was captured. /// - /// Command line arguments for dnup - /// Whether to capture and return the output - /// Working directory for the process - /// A tuple with exit code and captured output (if requested) - public static (int exitCode, string output) RunDnupProcessWithDebugger(string[] args, bool captureOutput = false, string? workingDirectory = null) + public static void AssertOutput(DnupProcessResult result, Action assertion) { - // Add --debug flag to enable debugger waiting (only works in DEBUG builds) - var debugArgs = new[] { "--debug" }.Concat(args).ToArray(); - - Console.WriteLine("Starting dnup process in debug mode..."); - Console.WriteLine("Note: --debug flag only works in DEBUG builds of dnup"); - Console.WriteLine("To attach debugger:"); - Console.WriteLine("1. In Visual Studio: Debug -> Attach to Process"); - Console.WriteLine("2. Find the dotnet.exe process running dnup"); - Console.WriteLine("3. Attach to it, then press Enter in the console"); + if (!result.OutputCaptured) + { + Console.WriteLine("Skipping output assertions because dnup output was not captured (debug mode with ShellExecute)."); + return; + } - return RunDnupProcess(debugArgs, captureOutput, workingDirectory); + assertion(result.Output); } + /// + /// Formats dnup output for inclusion in assertion messages. + /// + public static string FormatOutputForAssertions(DnupProcessResult result) => + result.OutputCaptured ? result.Output : "[dnup output not captured; run without --debug to capture output]"; + private static string GetRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); @@ -231,3 +247,19 @@ private static IEnumerable BuildConfigurationCandidates(string? configur public static InstallArchitecture MapArchitecture(Architecture architecture) => InstallerUtilities.GetInstallArchitecture(architecture); } + +internal readonly record struct DnupProcessResult(int ExitCode, string Output, bool OutputCaptured) +{ + public void Deconstruct(out int exitCode, out string output) + { + exitCode = ExitCode; + output = Output; + } + + public void Deconstruct(out int exitCode, out string output, out bool outputCaptured) + { + exitCode = ExitCode; + output = Output; + outputCaptured = OutputCaptured; + } +} From 5306a16e4cc49b4ff52ec81b3e44654f8d8355d2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 16:19:30 -0700 Subject: [PATCH 07/15] Switch to environment variable that enables debug launching into the test. --- src/Installer/dnup/DnupDebugHelper.cs | 2 +- src/Installer/installer.code-workspace | 1 + .../dnup.Tests/Utilities/DnupTestUtilities.cs | 60 ++++++++++++++----- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs index 07d00f0d86e7..4013838a227d 100644 --- a/src/Installer/dnup/DnupDebugHelper.cs +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -15,7 +15,7 @@ public static void HandleDebugSwitch(ref string[] args) { if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) { - args = args.Skip(1).ToArray(); + args = [.. args.Skip(1)]; WaitForDebugger(); } } diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index 2d4e9f997dfb..57355ef96c7f 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -45,6 +45,7 @@ "request": "launch", "preLaunchTask": "build", "program": "dotnet", + "args": [ "args": [ "test", "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 31680cf2d51d..5c1dcb83c42d 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -45,15 +45,16 @@ public static string[] BuildArguments(string channel, string installPath, string { "sdk", "install", - channel + channel, + "--install-path", + installPath }; - args.Add("--install-path"); - args.Add(installPath); -#if !DEBUG + if (!ShouldForceDebug()) + { args.Add("--interactive"); args.Add("false"); -#endif + } // Add manifest path option if specified for test isolation if (!string.IsNullOrEmpty(manifestPath)) @@ -79,13 +80,10 @@ public static string[] BuildArguments(string channel, string installPath, string /// Process result including exit code and output (if captured). public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { - // In DEBUG builds, automatically add --debug flag for easier debugging -#if DEBUG - if (!args.Contains("--debug")) + if (ShouldForceDebug()) { - args = new[] { "--debug" }.Concat(args).ToArray(); + args = EnsureDebugFlag(args); } -#endif string repoRoot = GetRepositoryRoot(); string dnupPath = LocateDnupAssembly(repoRoot); @@ -95,9 +93,7 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; - bool isDebugMode = args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase)); - bool useShellExecute = isDebugMode && OperatingSystem.IsWindows(); - + bool useShellExecute = ShouldForceDebug(); process.StartInfo.UseShellExecute = useShellExecute; process.StartInfo.CreateNoWindow = !useShellExecute; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; @@ -133,7 +129,7 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput process.BeginErrorReadLine(); } - if (isDebugMode) + if (ShouldForceDebug()) { Console.WriteLine($"Started dnup process with PID: {process.Id}"); Console.WriteLine(useShellExecute @@ -148,6 +144,31 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput return new DnupProcessResult(process.ExitCode, output, shouldCaptureOutput); } + private static string[] EnsureDebugFlag(string[] args) + { + if (args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase))) + { + return args; + } + + string[] updated = new string[args.Length + 1]; + updated[0] = "--debug"; + Array.Copy(args, 0, updated, 1, args.Length); + return updated; + } + + private static bool ShouldForceDebug() + { + string? value = Environment.GetEnvironmentVariable("DNUP_TEST_DEBUG"); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + /// /// Executes output assertions only when dnup output was captured. /// @@ -235,8 +256,15 @@ private static IEnumerable BuildConfigurationCandidates(string? configur candidates.Add(configuration); } - candidates.Add("Debug"); - candidates.Add("Release"); + if (!candidates.Contains("Release", StringComparer.OrdinalIgnoreCase)) + { + candidates.Insert(0, "Release"); + } + + if (!candidates.Contains("Debug", StringComparer.OrdinalIgnoreCase)) + { + candidates.Add("Debug"); + } return candidates.Distinct(StringComparer.OrdinalIgnoreCase); } From f919aa224746c4f1235d9968a8e73ed97d0173dc Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 16:19:59 -0700 Subject: [PATCH 08/15] Workspace should enable debugging singular test --- src/Installer/installer.code-workspace | 44 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index 57355ef96c7f..b7da3fab1e10 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -46,10 +46,29 @@ "preLaunchTask": "build", "program": "dotnet", "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "/p:ContinuousIntegrationBuild=false", + ], + "cwd": "${workspaceFolder:dnup.Tests}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DNUP_TEST_DEBUG": "0" + } + }, + { + "name": "Debug dnup test", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "dotnet", "args": [ "test", "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", "--no-build", + "--filter", + "FullyQualifiedName~${input:testName}", "/p:ContinuousIntegrationBuild=false", "/p:UseSharedCompilation=true", "/p:Deterministic=false", @@ -58,10 +77,29 @@ ], "cwd": "${workspaceFolder:dnup.Tests}", "console": "integratedTerminal", - "stopAtEntry": false + "stopAtEntry": false, + "logging": { + "moduleLoad": false + }, + "env": { + "DNUP_TEST_DEBUG": "1" + } } ], - "compounds": [] + "compounds": [], + "inputs": [ + { + "id": "testName", + "type": "promptString", + "description": "Enter test name or partial test name to debug (e.g., 'InstallChannelVersionTest', 'Install', etc.)" + }, + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments for dnup (e.g., 'sdk install 9.0', 'runtime install lts', '--help')", + "default": "sdk install" + } + ] }, "tasks": { "version": "2.0.0", @@ -128,4 +166,4 @@ }, ] } -} \ No newline at end of file +} From ab7a47f25b0f9da7dc104d203083f01c2dfe2846 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 12:13:15 -0700 Subject: [PATCH 09/15] Fix Version Parsing issues in ReleaseManifest --- .../DownloadProgress.cs | 31 +++ .../Internal/ArchiveDotnetExtractor.cs | 10 +- .../Internal/ReleaseManifest.cs | 239 +++--------------- .../SpectreDownloadProgressReporter.cs | 1 + .../dnup/ArchiveInstallationValidator.cs | 4 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 16 +- src/Installer/dnup/DnupSharedManifest.cs | 14 +- src/Installer/dnup/IDotnetInstallManager.cs | 2 +- src/Installer/installer.code-workspace | 2 +- test/dnup.Tests/DnupE2Etest.cs | 2 +- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 8 +- 11 files changed, 94 insertions(+), 235 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs new file mode 100644 index 000000000000..6afe6cc0756a --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs @@ -0,0 +1,31 @@ +using System; + +namespace Microsoft.Dotnet.Installation +{ + /// + /// Represents download progress information. + /// + public readonly struct DownloadProgress + { + /// + /// Gets the number of bytes downloaded. + /// + public long BytesDownloaded { get; } + + /// + /// Gets the total number of bytes to download, if known. + /// + public long? TotalBytes { get; } + + /// + /// Gets the percentage of download completed, if total size is known. + /// + public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; + + public DownloadProgress(long bytesDownloaded, long? totalBytes) + { + BytesDownloaded = bytesDownloaded; + TotalBytes = totalBytes; + } + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs index 3c6513209d64..83b29ea5d807 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs @@ -118,7 +118,7 @@ public void Commit(IEnumerable existingSdkVersions) // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); - if (extractResult != null) + if (extractResult is not null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } @@ -135,7 +135,7 @@ public void Commit(IEnumerable existingSdkVersions) // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); - if (extractResult != null) + if (extractResult is not null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } @@ -204,7 +204,7 @@ private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable e long totalFiles = CountTarEntries(decompressedPath); // Set progress maximum - if (installTask != null) + if (installTask is not null) { installTask.MaxValue = totalFiles > 0 ? totalFiles : 1; } @@ -255,7 +255,7 @@ private long CountTarEntries(string tarPath) long totalFiles = 0; using var tarStream = File.OpenRead(tarPath); var tarReader = new TarReader(tarStream); - while (tarReader.GetNextEntry() != null) + while (tarReader.GetNextEntry() is not null) { totalFiles++; } @@ -271,7 +271,7 @@ private void ExtractTarContents(string tarPath, string targetDir, MuxerHandlingC var tarReader = new TarReader(tarStream); TarEntry? entry; - while ((entry = tarReader.GetNextEntry()) != null) + while ((entry = tarReader.GetNextEntry()) is not null) { if (entry.EntryType == TarEntryType.RegularFile) { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 162c2b8e80b2..f2defae58c3f 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -11,13 +11,14 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; namespace Microsoft.Dotnet.Installation.Internal; /// /// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation. /// -internal class ReleaseManifest : IDisposable +internal class ReleaseManifest(HttpClient httpClient) : IDisposable { /// /// Parses a version channel string into its components. @@ -230,8 +231,6 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return GetLatestStableVersion(productIndex, component); } - // Parse the channel string into components - var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); // If major is invalid, return null @@ -262,7 +261,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } // Case 3: Feature band version (e.g., "9.0.1xx") - if (minor >= 0 && featureBand != null) + if (minor >= 0 && featureBand is not null) { return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); } @@ -278,7 +277,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Get products matching the major version var matchingProducts = GetProductsForMajorVersion(index, major); - if (!matchingProducts.Any()) + if (matchingProducts.Count == 0) { return null; } @@ -563,32 +562,15 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } } - private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; - private const string ReleaseCacheMutexName = "Global\\DotNetReleaseCache"; - private readonly HttpClient _httpClient; - private readonly string _cacheDirectory; + private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); private ProductCollection? _productCollection; public ReleaseManifest() - : this(CreateDefaultHttpClient(), GetDefaultCacheDirectory()) - { - } - - public ReleaseManifest(HttpClient httpClient) - : this(httpClient, GetDefaultCacheDirectory()) - { - } - - public ReleaseManifest(HttpClient httpClient, string cacheDirectory) + : this(CreateDefaultHttpClient()) { - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _cacheDirectory = cacheDirectory ?? throw new ArgumentNullException(nameof(cacheDirectory)); - - // Ensure cache directory exists - Directory.CreateDirectory(_cacheDirectory); } /// @@ -598,39 +580,24 @@ private static HttpClient CreateDefaultHttpClient() { var handler = new HttpClientHandler() { - // Use system proxy settings by default UseProxy = true, - // Use default credentials for proxy authentication if needed UseDefaultCredentials = true, - // Handle redirects automatically AllowAutoRedirect = true, - // Set maximum number of redirects to prevent infinite loops MaxAutomaticRedirections = 10, - // Enable decompression for better performance AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }; var client = new HttpClient(handler) { - // Set a reasonable timeout for downloads Timeout = TimeSpan.FromMinutes(10) }; - // Set user agent to identify the client + // Set user-agent to identify dnup in telemetry client.DefaultRequestHeaders.UserAgent.ParseAdd("dnup-dotnet-installer"); return client; } - /// - /// Gets the default cache directory path. - /// - private static string GetDefaultCacheDirectory() - { - var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(baseDir, "dnup", CacheSubdirectory); - } - /// /// Downloads the releases.json manifest and finds the download URL for the specified installation. /// @@ -827,59 +794,13 @@ public bool DownloadArchiveWithVerification(DotnetInstallRequest installRequest, /// private ProductCollection GetProductCollection() { - if (_productCollection != null) + if (_productCollection is not null) { return _productCollection; } - // Use ScopedMutex for cross-process locking - using var mutex = new ScopedMutex(ReleaseCacheMutexName); - - // Always use the index manifest for ProductCollection - for (int attempt = 1; attempt <= MaxRetryCount; attempt++) - { - try - { - _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return _productCollection; - } - catch - { - if (attempt == MaxRetryCount) - { - throw; - } - Thread.Sleep(RetryDelayMilliseconds * attempt); // Exponential backoff - } - } - - // This shouldn't be reached due to throw above, but compiler doesn't know that - throw new InvalidOperationException("Failed to fetch .NET releases data"); - } - - /// - /// Serializes a ProductCollection to JSON. - /// - private static string SerializeProductCollection(ProductCollection collection) - { - // Use options that indicate we've verified AOT compatibility - var options = new System.Text.Json.JsonSerializerOptions(); -#pragma warning disable IL2026, IL3050 - return System.Text.Json.JsonSerializer.Serialize(collection, options); -#pragma warning restore IL2026, IL3050 - } - - /// - /// Deserializes a ProductCollection from JSON. - /// - private static ProductCollection DeserializeProductCollection(string json) - { - // Use options that indicate we've verified AOT compatibility - var options = new System.Text.Json.JsonSerializerOptions(); -#pragma warning disable IL2026, IL3050 - return System.Text.Json.JsonSerializer.Deserialize(json, options) - ?? throw new InvalidOperationException("Failed to deserialize ProductCollection from JSON"); -#pragma warning restore IL2026, IL3050 + _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return _productCollection; } /// @@ -894,80 +815,42 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the specific release for the given version. /// - private static ProductRelease? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component) + private static ReleaseComponent? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component) { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Get all releases - var allReleases = releases.ToList(); - - // First try to find the exact version in the original release list - var exactReleaseMatch = allReleases.FirstOrDefault(r => r.Version.Equals(resolvedVersion)); - if (exactReleaseMatch != null) - { - return exactReleaseMatch; - } + var releases = product.GetReleasesAsync().GetAwaiter().GetResult().ToList(); - // Now check through the releases to find matching components - foreach (var release in allReleases) + foreach (var release in releases) { - bool foundMatch = false; - - // Check the appropriate collection based on the mode if (component == InstallComponent.SDK) { foreach (var sdk in release.Sdks) { - // Check for exact match if (sdk.Version.Equals(resolvedVersion)) { - foundMatch = true; - break; + return sdk; } - - // Not sure what the point of the below logic was - //// Check for match on major, minor, patch - //if (sdk.Version.Major == targetReleaseVersion.Major && - // sdk.Version.Minor == targetReleaseVersion.Minor && - // sdk.Version.Patch == targetReleaseVersion.Patch) - //{ - // foundMatch = true; - // break; - //} } } - else // Runtime mode + else { - // Get the appropriate runtime components based on the file patterns - var filteredRuntimes = release.Runtimes; - - // Use the type information from the file names to filter runtime components - // This will prioritize matching the exact runtime type the user is looking for - - foreach (var runtime in filteredRuntimes) + var runtimesQuery = component switch + { + InstallComponent.ASPNETCore => release.Runtimes + .Where(r => r.Name.Contains("ASP", StringComparison.OrdinalIgnoreCase)), + InstallComponent.WindowsDesktop => release.Runtimes + .Where(r => r.Name.Contains("Desktop", StringComparison.OrdinalIgnoreCase)), + _ => release.Runtimes + .Where(r => r.Name.Contains(".NET Runtime", StringComparison.OrdinalIgnoreCase) || + r.Name.Contains(".NET Core Runtime", StringComparison.OrdinalIgnoreCase)), + }; + foreach (var runtime in runtimesQuery) { - // Check for exact match if (runtime.Version.Equals(resolvedVersion)) { - foundMatch = true; - break; + return runtime; } - - //// Check for match on major, minor, patch - //if (runtime.Version.Major == targetReleaseVersion.Major && - // runtime.Version.Minor == targetReleaseVersion.Minor && - // runtime.Version.Patch == targetReleaseVersion.Patch) - //{ - // foundMatch = true; - // break; - //} } } - - if (foundMatch) - { - return release; - } } return null; @@ -976,53 +859,22 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the matching file in the release for the given installation requirements. /// - private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + private static ReleaseFile? FindMatchingFile(ReleaseComponent release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) { var rid = DnupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture); var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); - // Determine the component type pattern to look for in file names - string componentTypePattern; - if (installRequest.Component == InstallComponent.SDK) - { - componentTypePattern = "sdk"; - } - else // Runtime mode - { - // Determine the specific runtime type based on the release's file patterns - // Default to "runtime" if can't determine more specifically - componentTypePattern = "runtime"; - - // Check if this is specifically an ASP.NET Core runtime - if (installRequest.Component == InstallComponent.ASPNETCore) - { - componentTypePattern = "aspnetcore"; - } - // Check if this is specifically a Windows Desktop runtime - else if (installRequest.Component == InstallComponent.WindowsDesktop) - { - componentTypePattern = "windowsdesktop"; - } - } - - // Filter files based on runtime identifier, component type, and file extension var matchingFiles = release.Files - .Where(f => f.Rid == rid) - .Where(f => f.Name.Contains(componentTypePattern, StringComparison.OrdinalIgnoreCase)) - .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) - .ToList(); + .Where(f => f.Rid == rid) // TODO: Do we support musl here? + .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) + .ToList(); if (matchingFiles.Count == 0) { return null; } - // If we have multiple matching files, prefer the one with the full version in the name - var versionString = resolvedVersion.ToString(); - var bestMatch = matchingFiles.FirstOrDefault(f => f.Name.Contains(versionString, StringComparison.OrdinalIgnoreCase)); - - // If no file has the exact version string, return the first match - return bestMatch ?? matchingFiles.First(); + return matchingFiles.First(); } /// @@ -1071,30 +923,3 @@ public void Dispose() _httpClient?.Dispose(); } } - -/// -/// Represents download progress information. -/// -public readonly struct DownloadProgress -{ - /// - /// Gets the number of bytes downloaded. - /// - public long BytesDownloaded { get; } - - /// - /// Gets the total number of bytes to download, if known. - /// - public long? TotalBytes { get; } - - /// - /// Gets the percentage of download completed, if total size is known. - /// - public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; - - public DownloadProgress(long bytesDownloaded, long? totalBytes) - { - BytesDownloaded = bytesDownloaded; - TotalBytes = totalBytes; - } -} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs index 7301ffb16dc6..9a0fa920ad5d 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs @@ -1,5 +1,6 @@ using System; using Spectre.Console; +using Microsoft.Dotnet.Installation; namespace Microsoft.Dotnet.Installation.Internal { diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index 9a8af2a0733b..f6909756300d 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -112,13 +112,13 @@ private static void ConfigureHostFxrResolution(string installRoot) return; } - if (AppContext.GetData(HostFxrRuntimeProperty) != null) + if (AppContext.GetData(HostFxrRuntimeProperty) is not null) { return; } string? hostFxrPath = FindHostFxrLibrary(installRoot); - if (hostFxrPath != null) + if (hostFxrPath is not null) { AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index c4481b4bc247..e1cb04ec401d 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -35,11 +35,11 @@ public override int Execute() string? resolvedInstallPath = null; string? installPathFromGlobalJson = null; - if (globalJsonInfo?.GlobalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath is not null) { installPathFromGlobalJson = globalJsonInfo.SdkPath; - if (installPathFromGlobalJson != null && _installPath != null && + if (installPathFromGlobalJson is not null && _installPath is not null && !DnupUtilities.PathsEqual(installPathFromGlobalJson, _installPath)) { // TODO: Add parameter to override error @@ -55,7 +55,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && currentDotnetInstallRoot != null && currentDotnetInstallRoot.InstallType == InstallType.User) + if (resolvedInstallPath == null && currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentDotnetInstallRoot.Path; @@ -77,14 +77,14 @@ public override int Execute() } string? channelFromGlobalJson = null; - if (globalJsonInfo?.GlobalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath is not null) { channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.GlobalJsonPath); } bool? resolvedUpdateGlobalJson = null; - if (channelFromGlobalJson != null && _versionOrChannel != null && + if (channelFromGlobalJson is not null && _versionOrChannel is not null && // TODO: Should channel comparison be case-sensitive? !channelFromGlobalJson.Equals(_versionOrChannel, StringComparison.OrdinalIgnoreCase)) { @@ -98,13 +98,13 @@ public override int Execute() string? resolvedChannel = null; - if (channelFromGlobalJson != null) + if (channelFromGlobalJson is not null) { SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; } - else if (_versionOrChannel != null) + else if (_versionOrChannel is not null) { resolvedChannel = _versionOrChannel; } @@ -189,7 +189,7 @@ public override int Execute() if (_interactive) { var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); - if (latestAdminVersion != null && resolvedVersion < new ReleaseVersion(latestAdminVersion)) + if (latestAdminVersion is not null && resolvedVersion < new ReleaseVersion(latestAdminVersion)) { SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index cb075a5e2807..b3b31d9ee354 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -74,9 +74,9 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v try { var installs = JsonSerializer.Deserialize(json, DnupManifestJsonContext.Default.ListDotnetInstall); - var validInstalls = installs ?? new List(); + var validInstalls = installs ?? []; - if (validator != null) + if (validator is not null) { var invalids = validInstalls.Where(i => !validator.Validate(i)).ToList(); if (invalids.Count > 0) @@ -102,10 +102,12 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v /// Installations that match the specified directory public IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null) { - return GetInstalledVersions(validator) - .Where(install => DnupUtilities.PathsEqual( - Path.GetFullPath(install.InstallRoot.Path!), - Path.GetFullPath(installRoot.Path!))); + // TODO: Manifest read operations should protect against data structure changes and be able to reformat an old manifest version. + var installedVersions = GetInstalledVersions(validator); + var expectedInstallRootPath = Path.GetFullPath(installRoot.Path); + var installedVersionsInRoot = installedVersions + .Where(install => DnupUtilities.PathsEqual(Path.GetFullPath(install.InstallRoot.Path!), expectedInstallRootPath)); + return installedVersionsInRoot; } public void AddInstalledVersion(DotnetInstall version) diff --git a/src/Installer/dnup/IDotnetInstallManager.cs b/src/Installer/dnup/IDotnetInstallManager.cs index 9ae89598aa86..4b8c742dbb7c 100644 --- a/src/Installer/dnup/IDotnetInstallManager.cs +++ b/src/Installer/dnup/IDotnetInstallManager.cs @@ -43,7 +43,7 @@ public class GlobalJsonInfo public string? SdkVersion => GlobalJsonContents?.Sdk?.Version; public bool? AllowPrerelease => GlobalJsonContents?.Sdk?.AllowPrerelease; public string? RollForward => GlobalJsonContents?.Sdk?.RollForward; - public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths != null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; + public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths is not null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; } public record DotnetInstallRootConfiguration( diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index b7da3fab1e10..a38782dc9f20 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -166,4 +166,4 @@ }, ] } -} +} \ No newline at end of file diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 0de472f98eda..aee8e4bd3092 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -130,7 +130,7 @@ public void TestReusesExistingInstall() firstInstall.ExitCode.Should().Be(0, $"First installation failed with exit code {firstInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(firstInstall)}"); - List firstDnupInstalls = new(); + List firstDnupInstalls = []; // Verify the installation was successful using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 5c1dcb83c42d..a0d7f9d7b042 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -107,14 +107,14 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput process.StartInfo.RedirectStandardError = shouldCaptureOutput; process.OutputDataReceived += (_, e) => { - if (e.Data != null) + if (e.Data is not null) { outputBuilder.AppendLine(e.Data); } }; process.ErrorDataReceived += (_, e) => { - if (e.Data != null) + if (e.Data is not null) { outputBuilder.AppendLine(e.Data); } @@ -192,7 +192,7 @@ public static string FormatOutputForAssertions(DnupProcessResult result) => private static string GetRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); - while (currentDirectory != null) + while (currentDirectory is not null) { if (File.Exists(Path.Combine(currentDirectory.FullName, "sdk.slnx"))) { @@ -240,7 +240,7 @@ private static string LocateDnupAssembly(string repoRoot) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); - if (fallback != null) + if (fallback is not null) { return fallback; } From 84827edb7dd945712511e243fc9ea7dfa07312fd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 12:27:06 -0700 Subject: [PATCH 10/15] Test does not need to hold onto mutex during validation --- test/dnup.Tests/DnupE2Etest.cs | 41 ++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index aee8e4bd3092..a146af93fc65 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -200,6 +200,7 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, new UpdateChannel(firstChannel), InstallComponent.SDK, new InstallRequestOptions())); + ReleaseVersion? secondResolved = resolver.Resolve( new DotnetInstallRequest( new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), @@ -228,32 +229,34 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, results[1].ExitCode.Should().Be(0, $"Second concurrent install failed with exit code {results[1].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[1])}"); + var installs = new List(); + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(testEnv.ManifestPath); var installs = manifest.GetInstalledVersions() .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) .ToList(); + } + + int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; + installs.Should().HaveCount(expectedInstallCount); - int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; - installs.Should().HaveCount(expectedInstallCount); - - var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - firstResolved.ToString()!, - secondResolved!.ToString()! - }; - - foreach (var install in installs) - { - install.Component.Should().Be(InstallComponent.SDK); - expectedVersions.Should().Contain(install.Version.ToString()); - DnupTestUtilities.ValidateInstall(install).Should().BeTrue( - $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); - } - - var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); - actualVersions.Should().BeEquivalentTo(expectedVersions); + var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + firstResolved.ToString()!, + secondResolved!.ToString()! + }; + + foreach (var install in installs) + { + install.Component.Should().Be(InstallComponent.SDK); + expectedVersions.Should().Contain(install.Version.ToString()); + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); } + + var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + actualVersions.Should().BeEquivalentTo(expectedVersions); } } From 46128513031f62eb303ce4f13299e540771f6991 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 12:35:10 -0700 Subject: [PATCH 11/15] Simplify release manifest logic --- .../Internal/ReleaseManifest.cs | 40 ++++--------------- test/dnup.Tests/DnupE2Etest.cs | 2 +- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index f2defae58c3f..7a683b3f2de9 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -598,17 +598,6 @@ private static HttpClient CreateDefaultHttpClient() return client; } - /// - /// Downloads the releases.json manifest and finds the download URL for the specified installation. - /// - /// The .NET installation details - /// The download URL for the installer/archive, or null if not found - public string? GetDownloadUrl(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) - { - var targetFile = FindReleaseFile(installRequest, resolvedVersion); - return targetFile?.Address.ToString(); - } - /// /// Downloads the archive from the specified URL to the destination path with progress reporting. /// @@ -616,7 +605,7 @@ private static HttpClient CreateDefaultHttpClient() /// The local path to save the downloaded file /// Optional progress reporting /// True if download was successful, false otherwise - public async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) + protected async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) { // Create temp file path in same directory for atomic move when complete string tempPath = $"{destinationPath}.download"; @@ -734,7 +723,7 @@ public async Task DownloadArchiveAsync(string downloadUrl, string destinat /// The local path to save the downloaded file /// Optional progress reporting /// True if download was successful, false otherwise - public bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) + protected bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) { return DownloadArchiveAsync(downloadUrl, destinationPath, progress).GetAwaiter().GetResult(); } @@ -748,15 +737,11 @@ public bool DownloadArchive(string downloadUrl, string destinationPath, IProgres /// True if download and verification were successful, false otherwise public bool DownloadArchiveWithVerification(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion, string destinationPath, IProgress? progress = null) { - // Get the download URL and expected hash - string? downloadUrl = GetDownloadUrl(installRequest, resolvedVersion); - if (string.IsNullOrEmpty(downloadUrl)) - { - return false; - } + var targetFile = FindReleaseFile(installRequest, resolvedVersion); + string? downloadUrl = targetFile?.Address.ToString(); + string? expectedHash = targetFile?.Hash.ToString(); - string? expectedHash = GetArchiveHash(installRequest, resolvedVersion); - if (string.IsNullOrEmpty(expectedHash)) + if (string.IsNullOrEmpty(expectedHash) || string.IsNullOrEmpty(downloadUrl)) { return false; } @@ -877,17 +862,6 @@ private ProductCollection GetProductCollection() return matchingFiles.First(); } - /// - /// Gets the SHA512 hash of the archive for the specified installation. - /// - /// The .NET installation details - /// The SHA512 hash string of the installer/archive, or null if not found - public string? GetArchiveHash(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) - { - var targetFile = FindReleaseFile(installRequest, resolvedVersion); - return targetFile?.Hash; - } - /// /// Computes the SHA512 hash of a file. /// @@ -896,6 +870,8 @@ private ProductCollection GetProductCollection() public static string ComputeFileHash(string filePath) { using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + // TODO: Older runtime versions use a different SHA algorithm. + // Eventually the manifest should indicate which algorithm to use. using var sha512 = SHA512.Create(); byte[] hashBytes = sha512.ComputeHash(fileStream); return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index a146af93fc65..01790ded49f4 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -234,7 +234,7 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(testEnv.ManifestPath); - var installs = manifest.GetInstalledVersions() + installs = manifest.GetInstalledVersions() .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) .ToList(); } From ace55a5f6f6d0dc003d314e9162b7a45ecc9b6ce Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 13:34:13 -0700 Subject: [PATCH 12/15] Extensively simplify product version filtering logic --- .../Internal/ReleaseManifest.cs | 269 +++--------------- 1 file changed, 46 insertions(+), 223 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 7a683b3f2de9..30c9d1b7cf4a 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -206,29 +207,21 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Latest fully specified version string, or null if not found public ReleaseVersion? GetLatestVersionForChannel(UpdateChannel channel, InstallComponent component) { - // Check for special channel strings (case insensitive) - if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) { - // Handle LTS (Long-Term Support) channel + var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, isLts: true, component); - } - else if (string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) - { - // Handle STS (Standard-Term Support) channel - var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, isLts: false, component); + return GetLatestVersionBySupportStatus(productIndex, releaseType, component); } else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) { - // Handle Preview channel - get the latest preview version var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); return GetLatestPreviewVersion(productIndex, component); } else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) { var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestStableVersion(productIndex, component); + return GetLatestVersionBySupportPhase(productIndex, component); } var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -247,21 +240,15 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Load the index manifest var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); - - // Case 1: Major only version (e.g., "9") if (minor < 0) { - return GetLatestVersionForMajorOnly(index, major, component); + return GetLatestVersionForMajorOrMajorMinor(index, major, component); // Major Only (e.g., "9") } - - // Case 2: Major.Minor version (e.g., "9.0") - if (minor >= 0 && featureBand == null) + else if (minor >= 0 && featureBand == null) // Major.Minor (e.g., "9.0") { - return GetLatestVersionForMajorMinor(index, major, minor, component); + return GetLatestVersionForMajorOrMajorMinor(index, major, component, minor); } - - // Case 3: Feature band version (e.g., "9.0.1xx") - if (minor >= 0 && featureBand is not null) + else if (minor >= 0 && featureBand is not null) // Not Fully Qualified Feature band Version (e.g., "9.0.1xx") { return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); } @@ -272,32 +259,11 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Gets the latest version for a major-only channel (e.g., "9"). /// - private ReleaseVersion? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallComponent component) + private ReleaseVersion? GetLatestVersionForMajorOrMajorMinor(IEnumerable index, int major, InstallComponent component, int? minor = null) { - // Get products matching the major version - var matchingProducts = GetProductsForMajorVersion(index, major); - - if (matchingProducts.Count == 0) - { - return null; - } - - // Get all releases from all matching products - var allReleases = new List(); - foreach (var matchingProduct in matchingProducts) - { - allReleases.AddRange(matchingProduct.GetReleasesAsync().GetAwaiter().GetResult()); - } - - // Find the latest version based on mode - if (component == InstallComponent.SDK) - { - return GetLatestSdkVersion(allReleases, major); - } - else // Runtime mode - { - return GetLatestRuntimeVersion(allReleases, major); - } + // Assumption: The manifest is designed so that the first product for a major version will always be latest. + Product? latestProductWithMajor = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")).FirstOrDefault(); + return GetLatestReleaseVersionInProduct(latestProductWithMajor, component); } /// @@ -307,71 +273,10 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) /// InstallComponent.SDK or InstallComponent.Runtime /// Latest stable version string matching the support status, or null if none found - private ReleaseVersion? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallComponent component) + private static ReleaseVersion? GetLatestVersionBySupportStatus(IEnumerable index, ReleaseType releaseType, InstallComponent component) { - // Get all products - var allProducts = index.ToList(); - - // Use ReleaseType from manifest (dotnetreleases library) - var targetType = isLts ? ReleaseType.LTS : ReleaseType.STS; - var filteredProducts = allProducts - .Where(p => p.ReleaseType == targetType) - .OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) - { - return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); - } - return 0; - }) - .ToList(); - - // Get all releases from filtered products - foreach (var product in filteredProducts) - { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Filter out preview versions - var stableReleases = releases - .Where(r => !r.IsPreview) - .ToList(); - - if (!stableReleases.Any()) - { - continue; // No stable releases for this product, try next one - } - - // Find latest version based on mode - if (component == InstallComponent.SDK) - { - var sdks = stableReleases - .SelectMany(r => r.Sdks) - .Where(sdk => !sdk.Version.ToString().Contains("-")) // Exclude any preview/RC versions - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (sdks.Any()) - { - return sdks.First().Version; - } - } - else // Runtime mode - { - var runtimes = stableReleases - .SelectMany(r => r.Runtimes) - .Where(runtime => !runtime.Version.ToString().Contains("-")) // Exclude any preview/RC versions - .OrderByDescending(runtime => runtime.Version) - .ToList(); - - if (runtimes.Any()) - { - return runtimes.First().Version; - } - } - } - - return null; // No matching versions found + var correctPhaseProducts = index?.Where(p => p.ReleaseType == releaseType) ?? Enumerable.Empty(); + return GetLatestVersionBySupportPhase(correctPhaseProducts, component); } /// @@ -379,131 +284,49 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// The product collection to search /// InstallComponent.SDK or InstallComponent.Runtime - /// Latest preview version string, or null if none found - private ReleaseVersion? GetLatestPreviewVersion(ProductCollection index, InstallComponent component) + /// Latest preview or GoLive version string, or null if none found + private ReleaseVersion? GetLatestPreviewVersion(IEnumerable index, InstallComponent component) { - // Get all products - var allProducts = index.ToList(); - - // Order by major and minor version (descending) to get the most recent first - var sortedProducts = allProducts - .OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) - { - return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); - } - return 0; - }) - .ToList(); - - // Get all releases from products - foreach (var product in sortedProducts) + ReleaseVersion? latestPreviewVersion = GetLatestVersionBySupportPhase(index, component, [SupportPhase.Preview, SupportPhase.GoLive]); + if (latestPreviewVersion is not null) { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Filter for preview versions - var previewReleases = releases - .Where(r => r.IsPreview) - .ToList(); - - if (!previewReleases.Any()) - { - continue; // No preview releases for this product, try next one - } - - // Find latest version based on mode - if (component == InstallComponent.SDK) - { - var sdks = previewReleases - .SelectMany(r => r.Sdks) - .Where(sdk => sdk.Version.ToString().Contains("-")) // Include only preview/RC versions - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (sdks.Any()) - { - return sdks.First().Version; - } - } - else // Runtime mode - { - var runtimes = previewReleases - .SelectMany(r => r.Runtimes) - .Where(runtime => runtime.Version.ToString().Contains("-")) // Include only preview/RC versions - .OrderByDescending(runtime => runtime.Version) - .ToList(); - - if (runtimes.Any()) - { - return runtimes.First().Version; - } - } + return latestPreviewVersion; } - return null; // No preview versions found + return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); } /// - /// Gets the latest stable version across all available products. + /// Gets the latest version across all available products that matches the support phase. /// - private ReleaseVersion? GetLatestStableVersion(ProductCollection index, InstallComponent component) + private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component) { - var sortedProducts = index - .OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var major)) - { - var minor = productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) - ? minorVersion - : 0; - return major * 100 + minor; - } - return 0; - }) - .ToList(); - - foreach (var product in sortedProducts) - { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - var stableReleases = releases.Where(r => !r.IsPreview).ToList(); - - if (!stableReleases.Any()) - { - continue; - } + return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); + } + /// + /// Gets the latest version across all available products that matches the support phase. + /// + private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component, SupportPhase[] acceptedSupportPhases) + { + // A version in preview/ga/rtm support is considered Go Live and not Active. + var activeSupportProducts = index?.Where(p => acceptedSupportPhases.Contains(p.SupportPhase)); - if (component == InstallComponent.SDK) - { - var sdks = stableReleases - .SelectMany(r => r.Sdks) - .Where(sdk => !sdk.Version.ToString().Contains("-")) - .OrderByDescending(sdk => sdk.Version) - .ToList(); + // The manifest is designed so that the first product will always be latest. + Product? latestActiveSupportProduct = activeSupportProducts?.FirstOrDefault(); - if (sdks.Any()) - { - return sdks.First().Version; - } - } - else - { - var runtimes = stableReleases - .SelectMany(r => r.Runtimes) - .Where(runtime => !runtime.Version.ToString().Contains("-")) - .OrderByDescending(runtime => runtime.Version) - .ToList(); + return GetLatestReleaseVersionInProduct(latestActiveSupportProduct, component); + } - if (runtimes.Any()) - { - return runtimes.First().Version; - } - } - } + private static ReleaseVersion? GetLatestReleaseVersionInProduct(Product? product, InstallComponent component) + { + // Assumption: The latest runtime version will always be the same across runtime components. + ReleaseVersion? latestVersion = component switch + { + InstallComponent.SDK => product?.LatestSdkVersion, + _ => product?.LatestRuntimeVersion + }; - return null; + return latestVersion; } /// From 6ac1ab7e788b889f2d7d6de9db1ec7f26c2345e4 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 14:00:10 -0700 Subject: [PATCH 13/15] even further simplify the version parsing logic --- .../Internal/ReleaseManifest.cs | 215 +++--------------- 1 file changed, 29 insertions(+), 186 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 30c9d1b7cf4a..b0eadacc8efd 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -53,152 +53,6 @@ internal class ReleaseManifest(HttpClient httpClient) : IDisposable return (major, minor, featureBand, isFullySpecified); } - /// - /// Gets products from the index that match the specified major version. - /// - /// The product collection to search - /// The major version to match - /// List of matching products, ordered by minor version (descending) - private List GetProductsForMajorVersion(ProductCollection index, int major) - { - var matchingProducts = index.Where(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) - { - return productMajor == major; - } - return false; - }).ToList(); - - // Order by minor version (descending) to prioritize newer versions - return matchingProducts.OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 1 && int.TryParse(productParts[1], out var productMinor)) - { - return productMinor; - } - return 0; - }).ToList(); - } - - /// - /// Gets all SDK components from the releases and returns the latest one. - /// - /// List of releases to search - /// Optional major version filter - /// Optional minor version filter - /// Latest SDK version string, or null if none found - private ReleaseVersion? GetLatestSdkVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null) - { - var allSdks = releases - .SelectMany(r => r.Sdks) - .Where(sdk => - (!majorFilter.HasValue || sdk.Version.Major == majorFilter.Value) && - (!minorFilter.HasValue || sdk.Version.Minor == minorFilter.Value)) - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (allSdks.Any()) - { - return allSdks.First().Version; - } - - return null; - } - - /// - /// Gets all runtime components from the releases and returns the latest one. - /// - /// List of releases to search - /// Optional major version filter - /// Optional minor version filter - /// Optional runtime type filter (null for any runtime) - /// Latest runtime version string, or null if none found - private ReleaseVersion? GetLatestRuntimeVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null, string? runtimeType = null) - { - var allRuntimes = releases.SelectMany(r => r.Runtimes).ToList(); - - // Filter by version constraints if provided - if (majorFilter.HasValue) - { - allRuntimes = allRuntimes.Where(r => r.Version.Major == majorFilter.Value).ToList(); - } - - if (minorFilter.HasValue) - { - allRuntimes = allRuntimes.Where(r => r.Version.Minor == minorFilter.Value).ToList(); - } - - // Filter by runtime type if specified - if (!string.IsNullOrEmpty(runtimeType)) - { - if (string.Equals(runtimeType, "aspnetcore", StringComparison.OrdinalIgnoreCase)) - { - allRuntimes = allRuntimes - .Where(r => r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - else if (string.Equals(runtimeType, "windowsdesktop", StringComparison.OrdinalIgnoreCase)) - { - allRuntimes = allRuntimes - .Where(r => r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - else // Regular runtime - { - allRuntimes = allRuntimes - .Where(r => !r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase) && - !r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - } - - if (allRuntimes.Any()) - { - return allRuntimes.OrderByDescending(r => r.Version).First().Version; - } - - return null; - } - - /// - /// Gets the latest SDK version that matches a specific feature band pattern. - /// - /// List of releases to search - /// Major version - /// Minor version - /// Feature band prefix (e.g., "1" for "1xx") - /// Latest matching version string, or fallback format if none found - private ReleaseVersion? GetLatestFeatureBandVersion(IEnumerable releases, int major, int minor, string featureBand) - { - var allSdkComponents = releases.SelectMany(r => r.Sdks).ToList(); - - // Filter by feature band - var featureBandSdks = allSdkComponents - .Where(sdk => - { - var version = sdk.Version.ToString(); - var versionParts = version.Split('.'); - if (versionParts.Length < 3) return false; - - var patchPart = versionParts[2].Split('-')[0]; // Remove prerelease suffix - return patchPart.Length >= 3 && patchPart.StartsWith(featureBand); - }) - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (featureBandSdks.Any()) - { - // Return the exact version from the latest matching SDK - return featureBandSdks.First().Version; - } - - // Fallback if no actual release matches the feature band pattern - return null; - } - /// /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). /// @@ -256,13 +110,19 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return null; } + private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable index, int major, int? minor = null) + { + var validProducts = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")); + return validProducts; + } + /// /// Gets the latest version for a major-only channel (e.g., "9"). /// private ReleaseVersion? GetLatestVersionForMajorOrMajorMinor(IEnumerable index, int major, InstallComponent component, int? minor = null) { // Assumption: The manifest is designed so that the first product for a major version will always be latest. - Product? latestProductWithMajor = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")).FirstOrDefault(); + Product? latestProductWithMajor = GetProductsInMajorOrMajorMinor(index, major, minor).FirstOrDefault(); return GetLatestReleaseVersionInProduct(latestProductWithMajor, component); } @@ -329,60 +189,43 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return latestVersion; } - /// - /// Gets the latest version for a major.minor channel (e.g., "9.0"). - /// - private ReleaseVersion? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallComponent component) + private static string? NormalizeFeatureBandInput(string band) { - // Find the product for the requested major.minor - string channelKey = $"{major}.{minor}"; - var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); - - if (product == null) - { - return null; - } - - // Load releases from the sub-manifest for this product - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Find the latest version based on mode - if (component == InstallComponent.SDK) - { - return GetLatestSdkVersion(releases, major, minor); - } - else // Runtime mode - { - return GetLatestRuntimeVersion(releases, major, minor); - } + return band? + .Replace("X", "x") + .Replace("x", "0") + .PadRight(3, '0') + .Substring(0, 3); } + /// /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). /// private ReleaseVersion? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallComponent component) { - // Find the product for the requested major.minor - string channelKey = $"{major}.{minor}"; - var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); - - if (product == null) + if (component != InstallComponent.SDK) { return null; } - // Load releases from the sub-manifest for this product - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + var validProducts = GetProductsInMajorOrMajorMinor(index, major, minor); + var latestProduct = validProducts.FirstOrDefault(); + var releases = latestProduct?.GetReleasesAsync().GetAwaiter().GetResult().ToList() ?? new List(); + var normalizedFeatureBand = NormalizeFeatureBandInput(featureBand); - // For SDK mode, use feature band filtering - if (component == InstallComponent.SDK) - { - return GetLatestFeatureBandVersion(releases, major, minor, featureBand); - } - else // For Runtime mode, just use regular major.minor filtering + foreach (var release in releases) { - return GetLatestRuntimeVersion(releases, major, minor); + foreach (var sdk in release.Sdks) + { + if (sdk.Version.SdkFeatureBand == int.Parse(normalizedFeatureBand ?? "0")) + { + return sdk.Version; + } + } } + + return null; } private const int MaxRetryCount = 3; From c689d8f1f093c16dcebdab9b142445115c8c67ae Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 23 Oct 2025 12:31:26 -0700 Subject: [PATCH 14/15] Address Feedback --- .../Internal/ReleaseManifest.cs | 25 +++++++++++++------ src/Installer/dnup/DnupDebugHelper.cs | 4 --- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index b0eadacc8efd..243e49da0766 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -65,7 +65,7 @@ internal class ReleaseManifest(HttpClient httpClient) : IDisposable { var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, releaseType, component); + return GetLatestVersionByReleaseType(productIndex, releaseType, component); } else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) { @@ -75,7 +75,7 @@ internal class ReleaseManifest(HttpClient httpClient) : IDisposable else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) { var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportPhase(productIndex, component); + return GetLatestActiveVersion(productIndex, component); } var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -133,10 +133,10 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) /// InstallComponent.SDK or InstallComponent.Runtime /// Latest stable version string matching the support status, or null if none found - private static ReleaseVersion? GetLatestVersionBySupportStatus(IEnumerable index, ReleaseType releaseType, InstallComponent component) + private static ReleaseVersion? GetLatestVersionByReleaseType(IEnumerable index, ReleaseType releaseType, InstallComponent component) { var correctPhaseProducts = index?.Where(p => p.ReleaseType == releaseType) ?? Enumerable.Empty(); - return GetLatestVersionBySupportPhase(correctPhaseProducts, component); + return GetLatestActiveVersion(correctPhaseProducts, component); } /// @@ -159,7 +159,7 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable /// /// Gets the latest version across all available products that matches the support phase. /// - private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component) + private static ReleaseVersion? GetLatestActiveVersion(IEnumerable index, InstallComponent component) { return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); } @@ -189,13 +189,22 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable return latestVersion; } - private static string? NormalizeFeatureBandInput(string band) + /// + /// Replaces user input feature band strings into the full feature band. + /// This would convert '1xx' into '100'. + /// 100 is not necessarily the latest but it is the feature band. + /// The other number in the band is the patch. + /// + /// + /// + private static int NormalizeFeatureBandInput(string band) { - return band? + var bandString = band .Replace("X", "x") .Replace("x", "0") .PadRight(3, '0') .Substring(0, 3); + return int.Parse(bandString); } @@ -218,7 +227,7 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable { foreach (var sdk in release.Sdks) { - if (sdk.Version.SdkFeatureBand == int.Parse(normalizedFeatureBand ?? "0")) + if (sdk.Version.SdkFeatureBand == normalizedFeatureBand) { return sdk.Version; } diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs index 4013838a227d..08810333fa3c 100644 --- a/src/Installer/dnup/DnupDebugHelper.cs +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -22,11 +22,7 @@ public static void HandleDebugSwitch(ref string[] args) public static void WaitForDebugger() { -#if NET5_0_OR_GREATER int processId = Environment.ProcessId; -#else - int processId = Process.GetCurrentProcess().Id; -#endif Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue"); Console.WriteLine($"Process ID: {processId}"); From 06d440a86cf0ac0fee8a90618476e4c3b0c8a7ef Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 23 Oct 2025 12:34:29 -0700 Subject: [PATCH 15/15] add to dnup slnf --- dnup.slnf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dnup.slnf b/dnup.slnf index b38b36feec3d..8384b13f95f0 100644 --- a/dnup.slnf +++ b/dnup.slnf @@ -5,6 +5,7 @@ "src\\Installer\\dnup\\dnup.csproj", "src\\Installer\\Microsoft.Dotnet.Installation\\Microsoft.Dotnet.Installation.csproj", "test\\dnup.Tests\\dnup.Tests.csproj", + "src\\Resolvers\\Microsoft.DotNet.NativeWrapper\\Microsoft.DotNet.NativeWrapper.csproj ] } -} \ No newline at end of file +}