diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs b/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs index d0e3c58f2fab..8c16bf979319 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/FrameworkReferenceResolver.cs @@ -3,37 +3,23 @@ #nullable disable -using Microsoft.Extensions.DependencyModel.Resolution; - namespace Microsoft.NET.Build.Tasks { internal class FrameworkReferenceResolver { private readonly Func _getEnvironmentVariable; - /// - /// Creates an instance that reads environment variables from the process environment. - /// - public FrameworkReferenceResolver() - : this(Environment.GetEnvironmentVariable) - { - } - - /// - /// Creates an instance that reads environment variables via the supplied delegate. - /// Use this from MSBuild tasks to route reads through TaskEnvironment. - /// public FrameworkReferenceResolver(Func getEnvironmentVariable) { - _getEnvironmentVariable = getEnvironmentVariable ?? throw new ArgumentNullException(nameof(getEnvironmentVariable)); + _getEnvironmentVariable = getEnvironmentVariable; } public string GetDefaultReferenceAssembliesPath() { - // Allow setting the reference assemblies path via an environment variable. - // We read this directly instead of calling DotNetReferenceAssembliesPathResolver.Resolve() - // because that runtime method uses process-global Environment.GetEnvironmentVariable. - var referenceAssembliesPath = _getEnvironmentVariable(DotNetReferenceAssembliesPathResolver.DotNetReferenceAssembliesPathEnv); + // Read DOTNET_REFERENCE_ASSEMBLIES_PATH through the injected delegate + // instead of DotNetReferenceAssembliesPathResolver.Resolve(), which uses + // process-global Environment.GetEnvironmentVariable and bypasses TaskEnvironment. + var referenceAssembliesPath = _getEnvironmentVariable("DOTNET_REFERENCE_ASSEMBLIES_PATH"); if (!string.IsNullOrEmpty(referenceAssembliesPath)) { diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs index bfcc8e4c67f4..61df9b1a3966 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateBundle.cs @@ -6,8 +6,20 @@ namespace Microsoft.NET.Build.Tasks { - public class GenerateBundle : TaskBase, ICancelableTask + [MSBuildMultiThreadableTask] + public class GenerateBundle : TaskBase, ICancelableTask, IMultiThreadableTask { +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= TaskEnvironmentDefaults.Create(); + set => _taskEnvironment = value; + } +#else + public TaskEnvironment TaskEnvironment { get; set; } = null!; +#endif + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private readonly Random _jitter = #if NET @@ -78,9 +90,12 @@ private async Task ExecuteWithRetry() options |= EnableCompressionInSingleFile ? BundleOptions.EnableCompression : BundleOptions.None; Version version = new(TargetFrameworkVersion); + string absoluteOutputDir = string.IsNullOrEmpty(OutputDir) + ? TaskEnvironment.ProjectDirectory + : TaskEnvironment.GetAbsolutePath(OutputDir); var bundler = new Bundler( AppHostName, - OutputDir, + absoluteOutputDir, options, targetOS, targetArch, @@ -92,7 +107,7 @@ private async Task ExecuteWithRetry() foreach (var item in FilesToBundle) { - fileSpec.Add(new FileSpec(sourcePath: item.ItemSpec, + fileSpec.Add(new FileSpec(sourcePath: TaskEnvironment.GetAbsolutePath(item.ItemSpec), bundleRelativePath: item.GetMetadata(MetadataKeys.RelativePath))); } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs index bdc14a94ac97..5488bc7bad2f 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GenerateDepsFile.cs @@ -16,8 +16,20 @@ namespace Microsoft.NET.Build.Tasks /// /// Generates the $(project).deps.json file. /// - public class GenerateDepsFile : TaskBase + [MSBuildMultiThreadableTask] + public class GenerateDepsFile : TaskBase, IMultiThreadableTask { +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= TaskEnvironmentDefaults.Create(); + set => _taskEnvironment = value; + } +#else + public TaskEnvironment TaskEnvironment { get; set; } +#endif + [Required] public string ProjectPath { get; set; } @@ -133,13 +145,17 @@ private Dictionary GetFilteredPackages() return filteredPackages; } - private void WriteDepsFile(string depsFilePath) + private void WriteDepsFile(string depsFilePath, string projectPath, string assetsFilePath, string runtimeGraphPath) { + AbsolutePath absDepsFilePath = TaskEnvironment.GetAbsolutePath(depsFilePath); + AbsolutePath absProjectPath = TaskEnvironment.GetAbsolutePath(projectPath); + ProjectContext projectContext = null; LockFileLookup lockFileLookup = null; - if (AssetsFilePath != null) + if (!string.IsNullOrEmpty(assetsFilePath)) { - LockFile lockFile = new LockFileCache(this).GetLockFile(AssetsFilePath); + AbsolutePath absAssetsFilePath = TaskEnvironment.GetAbsolutePath(assetsFilePath); + LockFile lockFile = new LockFileCache(this).GetLockFile(absAssetsFilePath.Value); projectContext = lockFile.CreateProjectContext( TargetFramework, EffectiveRuntimeIdentifier, @@ -153,7 +169,7 @@ private void WriteDepsFile(string depsFilePath) CompilationOptions compilationOptions = CompilationOptionsConverter.ConvertFrom(CompilerOptions); SingleProjectInfo mainProject = SingleProjectInfo.Create( - ProjectPath, + absProjectPath.Value, AssemblyName, AssemblyExtension, AssemblyVersion, @@ -219,7 +235,9 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) // graph with respect to the target RuntimeIdentifier. RuntimeGraph runtimeGraph = - IsSelfContained ? new RuntimeGraphCache(this).GetRuntimeGraph(RuntimeGraphPath) : null; + IsSelfContained && !string.IsNullOrEmpty(runtimeGraphPath) + ? new RuntimeGraphCache(this).GetRuntimeGraph(TaskEnvironment.GetAbsolutePath(runtimeGraphPath).Value) + : null; builder = new DependencyContextBuilder(mainProject, IncludeRuntimeFileVersions, runtimeGraph, projectContext, lockFileLookup); } @@ -244,7 +262,7 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) .WithReferenceProjectInfos(referenceProjects) .WithRuntimePackAssets(runtimePackAssets) .WithCompilationOptions(compilationOptions) - .WithReferenceAssembliesPath(new FrameworkReferenceResolver().GetDefaultReferenceAssembliesPath()) + .WithReferenceAssembliesPath(new FrameworkReferenceResolver(TaskEnvironment.GetEnvironmentVariable).GetDefaultReferenceAssembliesPath()) .WithPackagesThatWereFiltered(GetFilteredPackages()); if (CompileReferences.Length > 0) @@ -265,11 +283,11 @@ bool ShouldIncludeRuntimeAsset(ITaskItem item) DependencyContext dependencyContext = builder.Build(userRuntimeAssemblies); var writer = new DependencyContextWriter(); - using (var fileStream = File.Create(depsFilePath)) + using (var fileStream = File.Create(absDepsFilePath.Value)) { writer.Write(dependencyContext, fileStream); } - _filesWritten.Add(new TaskItem(depsFilePath)); + _filesWritten.Add(new TaskItem(DepsFilePath)); if (ValidRuntimeIdentifierPlatformsForAssets != null) { @@ -310,7 +328,7 @@ private bool ShouldWarnOnRuntimeIdentifer(string runtimeIdentifier) protected override void ExecuteCore() { - WriteDepsFile(DepsFilePath); + WriteDepsFile(DepsFilePath, ProjectPath, AssetsFilePath, RuntimeGraphPath); } } } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ResolveAppHosts.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ResolveAppHosts.cs index 00c6bf6fdcc3..a1faf017ffe9 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ResolveAppHosts.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ResolveAppHosts.cs @@ -9,8 +9,20 @@ namespace Microsoft.NET.Build.Tasks { - public class ResolveAppHosts : TaskBase + [MSBuildMultiThreadableTask] + public class ResolveAppHosts : TaskBase, IMultiThreadableTask { +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= TaskEnvironmentDefaults.Create(); + set => _taskEnvironment = value; + } +#else + public TaskEnvironment TaskEnvironment { get; set; } +#endif + public string TargetFrameworkIdentifier { get; set; } public string TargetFrameworkVersion { get; set; } @@ -78,8 +90,18 @@ public class ResolveAppHosts : TaskBase [Output] public ITaskItem[] PackAsToolShimAppHostPacks { get; set; } + private AbsolutePath? _absoluteRuntimeGraphPath; + private AbsolutePath? _absoluteTargetingPackRoot; + protected override void ExecuteCore() { + _absoluteRuntimeGraphPath = !string.IsNullOrEmpty(RuntimeGraphPath) + ? TaskEnvironment.GetAbsolutePath(RuntimeGraphPath) + : null; + _absoluteTargetingPackRoot = !string.IsNullOrEmpty(TargetingPackRoot) + ? TaskEnvironment.GetAbsolutePath(TargetingPackRoot) + : null; + var normalizedTargetFrameworkVersion = ProcessFrameworkReferences.NormalizeVersion(new Version(TargetFrameworkVersion)); var knownAppHostPacksForTargetFramework = KnownAppHostPacks @@ -245,7 +267,7 @@ private ITaskItem GetHostItem(string runtimeIdentifier, } string bestAppHostRuntimeIdentifier = NuGetUtils.GetBestMatchingRidWithExclusion( - new RuntimeGraphCache(this).GetRuntimeGraph(RuntimeGraphPath), + new RuntimeGraphCache(this).GetRuntimeGraph(_absoluteRuntimeGraphPath.Value.Value), runtimeIdentifier, runtimeIdentifiersToExclude.Split(';'), appHostRuntimeIdentifiers.Split(';'), @@ -288,15 +310,16 @@ private ITaskItem GetHostItem(string runtimeIdentifier, TaskItem appHostItem = new(itemName); string appHostPackPath = null; - if (!string.IsNullOrEmpty(TargetingPackRoot)) + if (_absoluteTargetingPackRoot.HasValue) { - appHostPackPath = Path.Combine(TargetingPackRoot, hostPackName, appHostPackVersion); + appHostPackPath = Path.Combine(_absoluteTargetingPackRoot.Value.Value, hostPackName, appHostPackVersion); } if (appHostPackPath != null && Directory.Exists(appHostPackPath)) { // Use AppHost from packs folder - appHostItem.SetMetadata(MetadataKeys.PackageDirectory, appHostPackPath); - appHostItem.SetMetadata(MetadataKeys.Path, Path.Combine(appHostPackPath, hostRelativePathInPackage)); + string originalAppHostPackPath = Path.Combine(TargetingPackRoot, hostPackName, appHostPackVersion); + appHostItem.SetMetadata(MetadataKeys.PackageDirectory, originalAppHostPackPath); + appHostItem.SetMetadata(MetadataKeys.Path, Path.Combine(originalAppHostPackPath, hostRelativePathInPackage)); } else if (EnableAppHostPackDownload) { diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ShowPreviewMessage.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ShowPreviewMessage.cs index a83fe3cc3e5a..0dc1d44629fb 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ShowPreviewMessage.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ShowPreviewMessage.cs @@ -10,6 +10,7 @@ namespace Microsoft.NET.Build.Tasks /// /// Provides a localizable mechanism for logging messages with different levels of importance from the SDK targets. /// + [MSBuildMultiThreadableTask] public class ShowPreviewMessage : TaskBase { protected override void ExecuteCore() diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/WriteAppConfigWithSupportedRuntime.cs b/src/Tasks/Microsoft.NET.Build.Tasks/WriteAppConfigWithSupportedRuntime.cs index a49c1e380e11..242a83ae1bab 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/WriteAppConfigWithSupportedRuntime.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/WriteAppConfigWithSupportedRuntime.cs @@ -7,8 +7,20 @@ namespace Microsoft.NET.Build.Tasks { - public sealed class WriteAppConfigWithSupportedRuntime : TaskBase + [MSBuildMultiThreadableTask] + public sealed class WriteAppConfigWithSupportedRuntime : TaskBase, IMultiThreadableTask { +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= TaskEnvironmentDefaults.Create(); + set => _taskEnvironment = value; + } +#else + public TaskEnvironment TaskEnvironment { get; set; } +#endif + /// /// Path to the app.config source file. /// @@ -34,8 +46,9 @@ protected override void ExecuteCore() AddSupportedRuntimeToAppconfig(doc, TargetFrameworkIdentifier, TargetFrameworkVersion, TargetFrameworkProfile); + string absoluteOutputPath = TaskEnvironment.GetAbsolutePath(OutputAppConfigFile.ItemSpec); var fileStream = new FileStream( - OutputAppConfigFile.ItemSpec, + absoluteOutputPath, FileMode.Create, FileAccess.Write, FileShare.Read); @@ -156,7 +169,7 @@ private XDocument LoadAppConfig(ITaskItem appConfigItem) } else { - document = XDocument.Load(appConfigItem.ItemSpec); + document = XDocument.Load(TaskEnvironment.GetAbsolutePath(appConfigItem.ItemSpec)); if (document.Root == null || document.Root.Name != "configuration") { throw new BuildErrorException(Strings.AppConfigRequiresRootConfiguration); diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAFrameworkReferenceResolver.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAFrameworkReferenceResolver.cs new file mode 100644 index 000000000000..b175d0d975ab --- /dev/null +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAFrameworkReferenceResolver.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + public class GivenAFrameworkReferenceResolver + { + [Fact] + public void ItReadsReferenceAssembliesPathThroughInjectedDelegate() + { + var envVarsRead = new List(); + var resolver = new FrameworkReferenceResolver(name => + { + envVarsRead.Add(name); + return name == "DOTNET_REFERENCE_ASSEMBLIES_PATH" ? "/custom/ref/path" : null; + }); + + var result = resolver.GetDefaultReferenceAssembliesPath(); + + result.Should().Be("/custom/ref/path"); + envVarsRead.Should().Contain("DOTNET_REFERENCE_ASSEMBLIES_PATH", + "env var should be read through the injected delegate, not process-global Environment"); + } + + [Fact] + public void ItFallsToProgramFilesWhenReferenceAssembliesPathNotSet() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On non-Windows, it returns null when env var is not set + var resolver = new FrameworkReferenceResolver(_ => null); + resolver.GetDefaultReferenceAssembliesPath().Should().BeNull(); + return; + } + + var resolver2 = new FrameworkReferenceResolver(name => + name == "ProgramFiles(x86)" ? @"C:\Program Files (x86)" : null); + + var result = resolver2.GetDefaultReferenceAssembliesPath(); + + result.Should().Be(@"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework"); + } + + [Fact] + public void ItDoesNotCallProcessGlobalEnvironment() + { + // Set a process-level env var that should NOT be seen by the resolver + // if it correctly uses the injected delegate + var originalValue = Environment.GetEnvironmentVariable("DOTNET_REFERENCE_ASSEMBLIES_PATH"); + try + { + Environment.SetEnvironmentVariable("DOTNET_REFERENCE_ASSEMBLIES_PATH", "/process-global-path"); + + // Inject a delegate that returns a DIFFERENT value + var resolver = new FrameworkReferenceResolver(name => + name == "DOTNET_REFERENCE_ASSEMBLIES_PATH" ? "/injected-path" : null); + + var result = resolver.GetDefaultReferenceAssembliesPath(); + + result.Should().Be("/injected-path", + "resolver should use injected delegate, not process-global Environment.GetEnvironmentVariable"); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_REFERENCE_ASSEMBLIES_PATH", originalValue); + } + } + } +} diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateBundleMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateBundleMultiThreading.cs new file mode 100644 index 000000000000..b2b0b735065d --- /dev/null +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateBundleMultiThreading.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.Build.Framework; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + [Collection("CWD-Dependent")] + public class GivenAGenerateBundleMultiThreading + { + [Fact] + public void ItResolvesOutputDirViaTaskEnvironment() + { + // Create a temp directory to act as a fake project dir (different from CWD). + // We can't fully execute GenerateBundle (needs real app host), but we can verify + // the task resolves paths via TaskEnvironment by triggering execution with + // relative paths that only exist under projectDir. + var projectDir = Path.Combine(Path.GetTempPath(), "bundle-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + // Create output dir and a dummy source file under projectDir + var outputRelativePath = "publish"; + var outputAbsolutePath = Path.Combine(projectDir, outputRelativePath); + Directory.CreateDirectory(outputAbsolutePath); + + var sourceRelativePath = Path.Combine("bin", "test.dll"); + var sourceAbsolutePath = Path.Combine(projectDir, sourceRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(sourceAbsolutePath)!); + File.WriteAllText(sourceAbsolutePath, "not a real dll"); + + var fileItem = new Microsoft.Build.Utilities.TaskItem(sourceRelativePath); + fileItem.SetMetadata(MetadataKeys.RelativePath, "test.dll"); + + var task = new GenerateBundle + { + AppHostName = "testhost.exe", + OutputDir = outputRelativePath, + FilesToBundle = new ITaskItem[] { fileItem }, + IncludeSymbols = false, + IncludeNativeLibraries = false, + IncludeAllContent = false, + TargetFrameworkVersion = "8.0", + RuntimeIdentifier = "win-x64", + ShowDiagnosticOutput = false, + EnableCompressionInSingleFile = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + + // Set TaskEnvironment pointing to projectDir (different from CWD). + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + // Execute — will fail because the source file isn't a real app host binary, + // but it should attempt to read from the correct (absolutized) path, not from CWD. + // The Bundler constructor will receive the absolutized OutputDir and the source + // file paths will be absolutized, proving TaskEnvironment is used. + // We expect an IOException or similar from the Bundler, NOT a DirectoryNotFoundException + // (which would happen if OutputDir wasn't absolutized). + try + { + task.Execute(); + } + catch (Exception ex) + { + // Expected — Bundler can't process fake files. + // But it should NOT be a DirectoryNotFoundException, which would indicate + // OutputDir wasn't absolutized via TaskEnvironment. + ex.Should().NotBeOfType( + "OutputDir should be absolutized via TaskEnvironment, not used as relative path"); + } + + // If the task didn't absolutize OutputDir, the Bundler would fail trying to use + // a relative path as the output directory. Since we can't easily assert on the + // internal Bundler behavior, the interface and attribute tests above are the + // primary validation, and this test serves as a smoke test. + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItHandlesEmptyOutputDir() + { + // When OutputDir is empty, GenerateBundle falls back to TaskEnvironment.ProjectDirectory + // (matching the old behavior of falling back to Environment.CurrentDirectory). + // The task should not throw during path resolution. + var projectDir = Path.Combine(Path.GetTempPath(), "bundle-empty-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + var sourceFile = Path.Combine(projectDir, "test.dll"); + File.WriteAllText(sourceFile, "not a real dll"); + + var fileItem = new Microsoft.Build.Utilities.TaskItem("test.dll"); + fileItem.SetMetadata(MetadataKeys.RelativePath, "test.dll"); + + var task = new GenerateBundle + { + AppHostName = "testhost.exe", + OutputDir = "", + FilesToBundle = new ITaskItem[] { fileItem }, + IncludeSymbols = false, + IncludeNativeLibraries = false, + IncludeAllContent = false, + TargetFrameworkVersion = "8.0", + RuntimeIdentifier = "win-x64", + ShowDiagnosticOutput = false, + EnableCompressionInSingleFile = false, + }; + task.BuildEngine = new MockBuildEngine(); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + Exception? caught = null; + try { task.Execute(); } catch (Exception ex) { caught = ex; } + + // Empty OutputDir should fall back to ProjectDirectory, not throw + // during path resolution. Any exception should be from Bundler + // processing (not a real binary), not from path resolution. + if (caught != null) + { + caught.Should().NotBeOfType( + "empty OutputDir should not cause NullReferenceException"); + caught.Should().NotBeOfType( + "empty OutputDir should fall back to ProjectDirectory"); + } + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItHandlesEmptyFileToBundleItemSpec() + { + var projectDir = Path.Combine(Path.GetTempPath(), "bundle-emptyfile-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + var outputRelativePath = "publish"; + Directory.CreateDirectory(Path.Combine(projectDir, outputRelativePath)); + + var fileItem = new Microsoft.Build.Utilities.TaskItem(""); + fileItem.SetMetadata(MetadataKeys.RelativePath, "test.dll"); + + var task = new GenerateBundle + { + AppHostName = "testhost.exe", + OutputDir = outputRelativePath, + FilesToBundle = new ITaskItem[] { fileItem }, + IncludeSymbols = false, + IncludeNativeLibraries = false, + IncludeAllContent = false, + TargetFrameworkVersion = "8.0", + RuntimeIdentifier = "win-x64", + ShowDiagnosticOutput = false, + EnableCompressionInSingleFile = false, + }; + task.BuildEngine = new MockBuildEngine(); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + Exception? caught = null; + try { task.Execute(); } catch (Exception ex) { caught = ex; } + + // Empty file ItemSpec should produce a meaningful error, not NullReferenceException. + if (caught != null) + { + caught.Should().NotBeOfType( + "empty FilesToBundle ItemSpec should not cause NullReferenceException"); + } + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItProducesSameExceptionInSingleProcessAndMultiProcessMode() + { + // GenerateBundle requires a real app host binary, so we can't compare actual outputs. + // Instead, we verify both modes fail at the same point (Bundler processing) with + // the same exception type, proving path resolution is equivalent. + var projectDir = Path.Combine(Path.GetTempPath(), "bundle-sp-mp-" + Guid.NewGuid().ToString("N")); + var otherDir = Path.Combine(Path.GetTempPath(), "bundle-other-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + + var savedCwd = Directory.GetCurrentDirectory(); + try + { + // Create output dir and dummy source under projectDir + var outputRelativePath = "publish"; + Directory.CreateDirectory(Path.Combine(projectDir, outputRelativePath)); + + var sourceRelativePath = Path.Combine("bin", "test.dll"); + var sourceAbsolutePath = Path.Combine(projectDir, sourceRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(sourceAbsolutePath)!); + File.WriteAllText(sourceAbsolutePath, "not a real dll"); + + GenerateBundle CreateTask() => new GenerateBundle + { + AppHostName = "testhost.exe", + OutputDir = outputRelativePath, + FilesToBundle = new ITaskItem[] + { + new Microsoft.Build.Utilities.TaskItem(sourceRelativePath) + { + // TaskItem doesn't have init syntax for metadata, set it below + } + }, + IncludeSymbols = false, + IncludeNativeLibraries = false, + IncludeAllContent = false, + TargetFrameworkVersion = "8.0", + RuntimeIdentifier = "win-x64", + ShowDiagnosticOutput = false, + EnableCompressionInSingleFile = false, + RetryCount = 0, + }; + + // --- Single-process run: CWD == projectDir --- + Exception? singleProcessException = null; + Directory.SetCurrentDirectory(projectDir); + try + { + var task = CreateTask(); + task.FilesToBundle[0].SetMetadata(MetadataKeys.RelativePath, "test.dll"); + task.BuildEngine = new MockBuildEngine(); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + try { task.Execute(); } catch (Exception ex) { singleProcessException = ex; } + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + } + + // --- Multi-process run: CWD != projectDir --- + Exception? multiProcessException = null; + Directory.SetCurrentDirectory(otherDir); + try + { + var task = CreateTask(); + task.FilesToBundle[0].SetMetadata(MetadataKeys.RelativePath, "test.dll"); + task.BuildEngine = new MockBuildEngine(); + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + try { task.Execute(); } catch (Exception ex) { multiProcessException = ex; } + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + } + + // Both modes should fail at the same point (Bundler, not path resolution) + if (singleProcessException != null && multiProcessException != null) + { + multiProcessException.GetType().Should().Be(singleProcessException.GetType(), + "both single-process and multi-process modes should fail with the same exception type"); + } + else + { + // If one succeeded and the other didn't, that's a path resolution problem + (singleProcessException == null).Should().Be(multiProcessException == null, + "both modes should either succeed or fail — a mismatch indicates path resolution discrepancy"); + } + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } + } +} diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateClsidMapMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateClsidMapMultiThreading.cs index 8f0476e20c10..9fbe0a27859f 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateClsidMapMultiThreading.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateClsidMapMultiThreading.cs @@ -54,7 +54,8 @@ public void ItProducesSameErrorsInMultiProcessAndMultiThreadedEnvironments() } finally { - Directory.SetCurrentDirectory(savedCwd); + try { Directory.SetCurrentDirectory(savedCwd); } + catch { Directory.SetCurrentDirectory(Path.GetTempPath()); } Directory.Delete(projectDir, true); if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); @@ -112,8 +113,10 @@ public void ItProducesSameClsidMapInMultiProcessAndMultiThreadedEnvironments() } finally { - Directory.SetCurrentDirectory(savedCwd); - Directory.Delete(projectDir, true); + try { Directory.SetCurrentDirectory(savedCwd); } + catch { Directory.SetCurrentDirectory(Path.GetTempPath()); } + if (Directory.Exists(projectDir)) + Directory.Delete(projectDir, true); if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); } diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs new file mode 100644 index 000000000000..c4f3ead3d3b1 --- /dev/null +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateDepsFileMultiThreading.cs @@ -0,0 +1,307 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + [Collection("CWD-Dependent")] + public class GivenAGenerateDepsFileMultiThreading + { + [Fact] + public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments() + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-parity-{Guid.NewGuid():N}")); + var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + try + { + SetupOutputDir(projectDir); + + // --- Multiprocess mode: CWD == projectDir --- + Directory.SetCurrentDirectory(projectDir); + var (result1, engine1, ex1) = RunTask(projectDir); + + // Clean output so second run can write again + CleanOutput(projectDir); + + // --- Multithreaded mode: CWD == otherDir --- + Directory.SetCurrentDirectory(otherDir); + var (result2, engine2, ex2) = RunTask(projectDir); + + result1.Should().Be(result2, + "task should return the same success/failure in both environments"); + (ex1 == null).Should().Be(ex2 == null, + "both environments should agree on whether an exception is thrown"); + if (ex1 != null && ex2 != null) + { + ex1.GetType().Should().Be(ex2.GetType(), + "exception type should be identical in both environments"); + } + engine1.Errors.Count.Should().Be(engine2.Errors.Count, + "error count should be the same in both environments"); + for (int i = 0; i < engine1.Errors.Count; i++) + { + engine1.Errors[i].Message.Should().Be( + engine2.Errors[i].Message, + $"error message [{i}] should be identical in both environments"); + } + engine1.Warnings.Count.Should().Be(engine2.Warnings.Count, + "warning count should be the same in both environments"); + } + finally + { + Directory.SetCurrentDirectory(Path.GetTempPath()); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } + + [Fact] + public void FilesWritten_PreservesRelativeDepsFilePath() + { + // Migration absolutizes DepsFilePath for File.Create() internally, + // but FilesWritten output must preserve the original relative form. + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-rel-{Guid.NewGuid():N}")); + var decoyDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-rel-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(decoyDir); + try + { + SetupOutputDir(projectDir); + Directory.SetCurrentDirectory(decoyDir); + + var task = CreateTask(projectDir); + + Exception? caughtEx = null; + bool result = false; + try { result = task.Execute(); } + catch (Exception ex) { caughtEx = ex; } + + // Task should find/create files via TaskEnvironment, not CWD + if (caughtEx != null) + { + caughtEx.Should().NotBeOfType( + "task should resolve paths via TaskEnvironment, not CWD"); + caughtEx.Should().NotBeOfType( + "task should resolve paths via TaskEnvironment, not CWD"); + } + + // FilesWritten output must preserve the original relative DepsFilePath + result.Should().BeTrue("task must succeed for FilesWritten assertions to be meaningful"); + var depsRelPath = Path.Combine("output", "myapp.deps.json"); + task.FilesWritten.Should().ContainSingle(); + task.FilesWritten[0].ItemSpec.Should().Be(depsRelPath, + "FilesWritten must preserve the original relative DepsFilePath — absolutization is internal only"); + Path.IsPathRooted(task.FilesWritten[0].ItemSpec).Should().BeFalse( + "output path should remain relative"); + } + finally + { + Directory.SetCurrentDirectory(Path.GetTempPath()); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(decoyDir)) Directory.Delete(decoyDir, true); + } + } + + [Fact] + public void AssetsFilePath_AbsolutizedBeforeLockFileCache() + { + // Exercises the AssetsFilePath absolutization path (GenerateDepsFile.cs:320) + // that LockFileCache rejects with "Path not rooted" if not absolutized. + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-assets-{Guid.NewGuid():N}")); + var decoyDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-assets-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(decoyDir); + try + { + SetupOutputDir(projectDir); + // Create a minimal project.assets.json + Directory.CreateDirectory(Path.Combine(projectDir, "obj")); + File.WriteAllText(Path.Combine(projectDir, "obj", "project.assets.json"), MinimalAssetsJson); + + Directory.SetCurrentDirectory(decoyDir); + + var task = CreateTaskWithAssetsFile(projectDir); + task.Execute(); + + var engine = (MockBuildEngine)task.BuildEngine; + // LockFileCache rejects relative paths with "not rooted" — migration must prevent this + engine.Errors.Select(e => e.Message ?? "").Should().NotContain(m => + m.Contains("not rooted", StringComparison.OrdinalIgnoreCase), + "AssetsFilePath should be absolutized by migration before reaching LockFileCache"); + } + finally + { + Directory.SetCurrentDirectory(Path.GetTempPath()); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(decoyDir)) Directory.Delete(decoyDir, true); + } + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public async System.Threading.Tasks.Task GenerateDepsFile_ConcurrentExecution(int parallelism) + { + var decoyDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"deps-conc-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(decoyDir); + try + { + Directory.SetCurrentDirectory(decoyDir); + + var results = new ConcurrentBag<(bool success, string exType, string exMsg)>(); + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) + { + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => + RunConcurrentWorker(idx, startGate, results)); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + + results.Select(r => r.exType).Distinct().Should().HaveCount(1, + "all threads should experience the same failure mode"); + results.Should().NotContain(r => r.exType == nameof(FileNotFoundException) + || r.exType == nameof(DirectoryNotFoundException), + "tasks should find/create files via TaskEnvironment, not CWD"); + } + finally + { + Directory.SetCurrentDirectory(Path.GetTempPath()); + if (Directory.Exists(decoyDir)) Directory.Delete(decoyDir, true); + } + } + + private static void RunConcurrentWorker( + int idx, + ManualResetEventSlim startGate, + ConcurrentBag<(bool success, string exType, string exMsg)> results) + { + var projectDir = Path.Combine(Path.GetTempPath(), $"deps-conc-{idx}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(projectDir); + try + { + SetupOutputDir(projectDir); + var task = CreateTask(projectDir); + + startGate.Wait(); + var result = task.Execute(); + results.Add((result, "none", "")); + } + catch (Exception ex) + { + results.Add((false, ex.GetType().Name, ex.Message)); + } + finally + { + if (Directory.Exists(projectDir)) + Directory.Delete(projectDir, true); + } + } + + private static void SetupOutputDir(string projectDir) + { + Directory.CreateDirectory(Path.Combine(projectDir, "output")); + } + + private static void CleanOutput(string projectDir) + { + var depsFile = Path.Combine(projectDir, "output", "myapp.deps.json"); + if (File.Exists(depsFile)) File.Delete(depsFile); + } + + private static GenerateDepsFile CreateTask(string projectDir) + { + return new GenerateDepsFile + { + BuildEngine = new MockBuildEngine(), + ProjectPath = Path.Combine("src", "myapp.csproj"), + DepsFilePath = Path.Combine("output", "myapp.deps.json"), + TargetFramework = "net8.0", + AssemblyName = "myapp", + AssemblyExtension = ".dll", + AssemblyVersion = "1.0.0", + IncludeMainProject = true, + // Skip AssetsFilePath (null) — avoids needing a real lock file + // Skip RuntimeGraphPath validation — IsSelfContained=false + IsSelfContained = false, + CompileReferences = Array.Empty(), + ResolvedNuGetFiles = Array.Empty(), + ResolvedRuntimeTargetsFiles = Array.Empty(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), + }; + } + + private static GenerateDepsFile CreateTaskWithAssetsFile(string projectDir) + { + return new GenerateDepsFile + { + BuildEngine = new MockBuildEngine(), + ProjectPath = Path.Combine("src", "myapp.csproj"), + DepsFilePath = Path.Combine("output", "myapp.deps.json"), + AssetsFilePath = Path.Combine("obj", "project.assets.json"), + TargetFramework = "net8.0", + AssemblyName = "myapp", + AssemblyExtension = ".dll", + AssemblyVersion = "1.0.0", + IncludeMainProject = true, + IsSelfContained = false, + CompileReferences = Array.Empty(), + ResolvedNuGetFiles = Array.Empty(), + ResolvedRuntimeTargetsFiles = Array.Empty(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), + }; + } + + private static (bool result, MockBuildEngine engine, Exception? exception) RunTask(string projectDir) + { + var task = CreateTask(projectDir); + var engine = (MockBuildEngine)task.BuildEngine; + + try + { + var result = task.Execute(); + return (result, engine, null); + } + catch (Exception ex) + { + return (false, engine, ex); + } + } + + private const string MinimalAssetsJson = """ + { + "version": 3, + "targets": { "net8.0": {} }, + "libraries": {}, + "projectFileDependencyGroups": { "net8.0": [] }, + "packageFolders": {}, + "project": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "test", + "projectName": "test", + "projectPath": "test.csproj", + "packagesPath": "", + "outputPath": "", + "projectStyle": "PackageReference", + "originalTargetFrameworks": ["net8.0"], + "sources": {}, + "frameworks": { "net8.0": { "targetAlias": "net8.0", "projectReferences": {} } } + }, + "frameworks": { "net8.0": { "targetAlias": "net8.0" } } + } + } + """; + } +} diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateRuntimeConfigMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateRuntimeConfigMultiThreading.cs index 2d945cbdc168..8aac19cbeab0 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateRuntimeConfigMultiThreading.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAGenerateRuntimeConfigMultiThreading.cs @@ -108,7 +108,8 @@ public void ItBehavesSameWithEmptyAssetsFilePathInBothEnvironments() } finally { - Directory.SetCurrentDirectory(savedCwd); + try { Directory.SetCurrentDirectory(savedCwd); } + catch { Directory.SetCurrentDirectory(Path.GetTempPath()); } if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); if (Directory.Exists(otherDir)) @@ -219,7 +220,8 @@ public void ItBehavesSameWithEmptyOrNullRuntimeConfigPathInBothEnvironments(stri } finally { - Directory.SetCurrentDirectory(savedCwd); + try { Directory.SetCurrentDirectory(savedCwd); } + catch { Directory.SetCurrentDirectory(Path.GetTempPath()); } if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); if (Directory.Exists(otherDir)) @@ -285,7 +287,8 @@ public void UserRuntimeConfigProducesSameOutputInBothEnvironments() } finally { - Directory.SetCurrentDirectory(savedCwd); + try { Directory.SetCurrentDirectory(savedCwd); } + catch { Directory.SetCurrentDirectory(Path.GetTempPath()); } if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); if (Directory.Exists(otherDir)) @@ -349,7 +352,8 @@ public void UserRuntimeConfigWithNonexistentFileProducesSameOutputInBothEnvironm } finally { - Directory.SetCurrentDirectory(savedCwd); + try { Directory.SetCurrentDirectory(savedCwd); } + catch { Directory.SetCurrentDirectory(Path.GetTempPath()); } if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); if (Directory.Exists(otherDir)) diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolveAppHostsMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolveAppHostsMultiThreading.cs new file mode 100644 index 000000000000..74cf0a3afdc9 --- /dev/null +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolveAppHostsMultiThreading.cs @@ -0,0 +1,492 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.Build.Framework; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + [Collection("CWD-Dependent")] + public class GivenAResolveAppHostsMultiThreading + { + [Fact] + public void ItResolvesTargetingPackRootViaTaskEnvironment() + { + // Create a temp directory to act as a fake project dir (different from CWD). + // Set up TargetingPackRoot as a relative path that only resolves under projectDir. + // If the task absolutizes via TaskEnvironment, Directory.Exists will find the pack. + // If not, it'll look at a CWD-relative path and won't find it. + var projectDir = Path.Combine(Path.GetTempPath(), "apphost-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + // Create a RuntimeGraph file under projectDir + var runtimeGraphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(runtimeGraphPath, "{\"runtimes\":{\"win-x64\":{\"#import\":[\"win\",\"any\"]},\"win\":{\"#import\":[\"any\"]},\"any\":{}}}"); + + // Create a targeting pack root with an app host pack structure under projectDir + var targetingPackRelative = "packs"; + var hostPackName = "Microsoft.NETCore.App.Host.win-x64"; + var hostPackVersion = "8.0.0"; + var packPath = Path.Combine(projectDir, targetingPackRelative, hostPackName, hostPackVersion); + var hostBinaryDir = Path.Combine(packPath, "runtimes", "win-x64", "native"); + Directory.CreateDirectory(hostBinaryDir); + File.WriteAllText(Path.Combine(hostBinaryDir, "apphost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "singlefilehost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "comhost.dll"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "ijwhost.dll"), "fake"); + + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", hostPackVersion); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = targetingPackRelative, + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = runtimeGraphPath, + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + + // Set TaskEnvironment pointing to projectDir + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + task.Execute().Should().BeTrue( + string.Join("; ", mockEngine.Errors.Select(e => e.Message))); + + // If TargetingPackRoot was resolved via TaskEnvironment, the task should have + // found the pack directory and set the Path metadata on the AppHost item. + task.AppHost.Should().NotBeNull().And.HaveCount(1); + var appHostPath = task.AppHost[0].GetMetadata(MetadataKeys.Path); + // Output preserves the original relative TargetingPackRoot form + appHostPath.Should().StartWith(targetingPackRelative, + "the AppHost path should preserve the original relative TargetingPackRoot"); + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItHandlesEmptyRuntimeGraphPath() + { + var projectDir = Path.Combine(Path.GetTempPath(), "apphost-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", "8.0.0"); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = "packs", + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = "", + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + var result = false; + Exception? caught = null; + try { result = task.Execute(); } catch (Exception ex) { caught = ex; } + + if (caught != null) + { + caught.Should().NotBeOfType( + "empty RuntimeGraphPath should not cause NullReferenceException"); + } + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItPreservesRelativePathInPackageMetadata() + { + // When TargetingPackRoot is relative, the task absolutizes it via TaskEnvironment. + // PathInPackage metadata should remain a relative path (it's the path *within* the pack), + // while Path metadata should be the fully resolved absolute path. + var projectDir = Path.Combine(Path.GetTempPath(), "apphost-pathtest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + var runtimeGraphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(runtimeGraphPath, "{\"runtimes\":{\"win-x64\":{\"#import\":[\"win\",\"any\"]},\"win\":{\"#import\":[\"any\"]},\"any\":{}}}"); + + var targetingPackRelative = "packs"; + var hostPackName = "Microsoft.NETCore.App.Host.win-x64"; + var hostPackVersion = "8.0.0"; + var packPath = Path.Combine(projectDir, targetingPackRelative, hostPackName, hostPackVersion); + var hostBinaryDir = Path.Combine(packPath, "runtimes", "win-x64", "native"); + Directory.CreateDirectory(hostBinaryDir); + File.WriteAllText(Path.Combine(hostBinaryDir, "apphost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "singlefilehost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "comhost.dll"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "ijwhost.dll"), "fake"); + + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", hostPackVersion); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = targetingPackRelative, + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = runtimeGraphPath, + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + task.Execute().Should().BeTrue( + string.Join("; ", mockEngine.Errors.Select(e => e.Message))); + + task.AppHost.Should().NotBeNull().And.HaveCount(1); + + // PathInPackage should be a relative path within the pack (not absolutized) + var pathInPackage = task.AppHost[0].GetMetadata(MetadataKeys.PathInPackage); + pathInPackage.Should().NotBeNullOrEmpty(); + Path.IsPathRooted(pathInPackage).Should().BeFalse( + "PathInPackage should remain a relative path, not be affected by absolutization of TargetingPackRoot"); + pathInPackage.Should().Be(Path.Combine("runtimes", "win-x64", "native", "apphost.exe")); + + // Path should preserve the original relative form (using original TargetingPackRoot) + var resolvedPath = task.AppHost[0].GetMetadata(MetadataKeys.Path); + resolvedPath.Should().StartWith(targetingPackRelative, + "Path metadata should preserve the original relative TargetingPackRoot"); + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItHandlesEmptyTargetingPackRoot() + { + var projectDir = Path.Combine(Path.GetTempPath(), "apphost-emptytpr-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + var runtimeGraphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(runtimeGraphPath, "{\"runtimes\":{\"win-x64\":{\"#import\":[\"win\",\"any\"]},\"win\":{\"#import\":[\"any\"]},\"any\":{}}}"); + + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", "8.0.0"); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = "", + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = runtimeGraphPath, + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + Exception? caught = null; + try { task.Execute(); } catch (Exception ex) { caught = ex; } + + // Empty TargetingPackRoot is guarded by a string.IsNullOrEmpty check in the task, + // so it should not throw NullReferenceException or ArgumentException from GetAbsolutePath. + if (caught != null) + { + caught.Should().NotBeOfType( + "empty TargetingPackRoot should not cause NullReferenceException"); + caught.Should().NotBeOfType( + "empty TargetingPackRoot should be handled gracefully, not passed to GetAbsolutePath"); + } + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItPreservesRelativePathInAllHostOutputs() + { + // Verify path format preservation across all host output types (AppHost, SingleFileHost, + // ComHost, IjwHost) — PathInPackage and PackageDirectory/Path should preserve original relative form. + var projectDir = Path.Combine(Path.GetTempPath(), "apphost-alloutputs-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + try + { + var runtimeGraphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(runtimeGraphPath, "{\"runtimes\":{\"win-x64\":{\"#import\":[\"win\",\"any\"]},\"win\":{\"#import\":[\"any\"]},\"any\":{}}}"); + + var targetingPackRelative = "packs"; + var hostPackName = "Microsoft.NETCore.App.Host.win-x64"; + var hostPackVersion = "8.0.0"; + var packPath = Path.Combine(projectDir, targetingPackRelative, hostPackName, hostPackVersion); + var hostBinaryDir = Path.Combine(packPath, "runtimes", "win-x64", "native"); + Directory.CreateDirectory(hostBinaryDir); + File.WriteAllText(Path.Combine(hostBinaryDir, "apphost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "singlefilehost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "comhost.dll"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "ijwhost.dll"), "fake"); + + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", hostPackVersion); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = targetingPackRelative, + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = runtimeGraphPath, + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + task.Execute().Should().BeTrue( + string.Join("; ", mockEngine.Errors.Select(e => e.Message))); + + // Verify all host types share the same path format contract + var hostOutputs = new (string Name, ITaskItem[]? Items)[] + { + ("AppHost", task.AppHost), + ("SingleFileHost", task.SingleFileHost), + ("ComHost", task.ComHost), + ("IjwHost", task.IjwHost), + }; + + foreach (var (name, items) in hostOutputs) + { + items.Should().NotBeNull($"{name} should be resolved").And.HaveCount(1); + var item = items![0]; + + var pathInPackage = item.GetMetadata(MetadataKeys.PathInPackage); + pathInPackage.Should().NotBeNullOrEmpty($"{name} should have PathInPackage metadata"); + Path.IsPathRooted(pathInPackage).Should().BeFalse( + $"{name}.PathInPackage should remain a relative path within the pack"); + + var resolvedPath = item.GetMetadata(MetadataKeys.Path); + resolvedPath.Should().NotBeNullOrEmpty($"{name} should have Path metadata"); + resolvedPath.Should().StartWith(targetingPackRelative, + $"{name}.Path should preserve original relative TargetingPackRoot"); + + var packageDir = item.GetMetadata(MetadataKeys.PackageDirectory); + packageDir.Should().NotBeNullOrEmpty($"{name} should have PackageDirectory metadata"); + packageDir.Should().StartWith(targetingPackRelative, + $"{name}.PackageDirectory should preserve original relative TargetingPackRoot"); + } + } + finally + { + Directory.Delete(projectDir, true); + } + } + + [Fact] + public void ItProducesSameOutputInSingleProcessAndMultiProcessMode() + { + var projectDir = Path.Combine(Path.GetTempPath(), "apphost-sp-mp-" + Guid.NewGuid().ToString("N")); + var otherDir = Path.Combine(Path.GetTempPath(), "apphost-other-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + + var savedCwd = Directory.GetCurrentDirectory(); + try + { + // Create a RuntimeGraph file under projectDir + var runtimeGraphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(runtimeGraphPath, "{\"runtimes\":{\"win-x64\":{\"#import\":[\"win\",\"any\"]},\"win\":{\"#import\":[\"any\"]},\"any\":{}}}"); + + // Create a targeting pack root with an app host pack structure under projectDir + var targetingPackRelative = "packs"; + var hostPackName = "Microsoft.NETCore.App.Host.win-x64"; + var hostPackVersion = "8.0.0"; + var packPath = Path.Combine(projectDir, targetingPackRelative, hostPackName, hostPackVersion); + var hostBinaryDir = Path.Combine(packPath, "runtimes", "win-x64", "native"); + Directory.CreateDirectory(hostBinaryDir); + File.WriteAllText(Path.Combine(hostBinaryDir, "apphost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "singlefilehost.exe"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "comhost.dll"), "fake"); + File.WriteAllText(Path.Combine(hostBinaryDir, "ijwhost.dll"), "fake"); + + // --- Single-process run: CWD == projectDir --- + ITaskItem[] singleProcessAppHost; + Directory.SetCurrentDirectory(projectDir); + try + { + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", hostPackVersion); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = targetingPackRelative, + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = runtimeGraphPath, + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + task.Execute().Should().BeTrue( + "single-process run should succeed: " + string.Join("; ", mockEngine.Errors.Select(e => e.Message))); + singleProcessAppHost = task.AppHost; + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + } + + // --- Multi-process run: CWD != projectDir --- + ITaskItem[] multiProcessAppHost; + Directory.SetCurrentDirectory(otherDir); + try + { + var knownAppHostPack = new Microsoft.Build.Utilities.TaskItem("Microsoft.NETCore.App.Host"); + knownAppHostPack.SetMetadata("TargetFramework", "net8.0"); + knownAppHostPack.SetMetadata("AppHostRuntimeIdentifiers", "win-x64;linux-x64;osx-x64"); + knownAppHostPack.SetMetadata("AppHostPackNamePattern", "Microsoft.NETCore.App.Host.**RID**"); + knownAppHostPack.SetMetadata("AppHostPackVersion", hostPackVersion); + knownAppHostPack.SetMetadata(MetadataKeys.ExcludedRuntimeIdentifiers, ""); + + var task = new ResolveAppHosts + { + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = targetingPackRelative, + AppHostRuntimeIdentifier = "win-x64", + RuntimeFrameworkVersion = null, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DotNetSingleFileHostExecutableNameWithoutExtension = "singlefilehost", + DotNetComHostLibraryNameWithoutExtension = "comhost", + DotNetIjwHostLibraryNameWithoutExtension = "ijwhost", + RuntimeGraphPath = runtimeGraphPath, + KnownAppHostPacks = new ITaskItem[] { knownAppHostPack }, + NuGetRestoreSupported = true, + EnableAppHostPackDownload = false, + }; + var mockEngine = new MockBuildEngine(); + task.BuildEngine = mockEngine; + task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir); + + task.Execute().Should().BeTrue( + "multi-process run should succeed: " + string.Join("; ", mockEngine.Errors.Select(e => e.Message))); + multiProcessAppHost = task.AppHost; + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + } + + // Assert both runs produced the same AppHost output + singleProcessAppHost.Should().NotBeNull().And.HaveCount(1); + multiProcessAppHost.Should().NotBeNull().And.HaveCount(1); + + var spItem = singleProcessAppHost[0]; + var mpItem = multiProcessAppHost[0]; + + mpItem.GetMetadata(MetadataKeys.Path).Should().Be( + spItem.GetMetadata(MetadataKeys.Path), + "Path metadata should match between single-process and multi-process runs"); + mpItem.GetMetadata(MetadataKeys.PackageDirectory).Should().Be( + spItem.GetMetadata(MetadataKeys.PackageDirectory), + "PackageDirectory metadata should match between single-process and multi-process runs"); + mpItem.GetMetadata(MetadataKeys.PathInPackage).Should().Be( + spItem.GetMetadata(MetadataKeys.PathInPackage), + "PathInPackage metadata should match between single-process and multi-process runs"); + mpItem.GetMetadata(MetadataKeys.RuntimeIdentifier).Should().Be( + spItem.GetMetadata(MetadataKeys.RuntimeIdentifier), + "RuntimeIdentifier metadata should match between single-process and multi-process runs"); + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } + } +} diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAWriteAppConfigMultiThreading.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAWriteAppConfigMultiThreading.cs new file mode 100644 index 000000000000..49ec0cc7d49e --- /dev/null +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAWriteAppConfigMultiThreading.cs @@ -0,0 +1,218 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + [Collection("CWD-Dependent")] + public class GivenAWriteAppConfigMultiThreading + { + [Fact] + public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments() + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"wac-parity-{Guid.NewGuid():N}")); + var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"wac-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + var savedCwd = Directory.GetCurrentDirectory(); + try + { + SetupProjectLayout(projectDir); + + // --- Multiprocess mode: CWD == projectDir --- + Directory.SetCurrentDirectory(projectDir); + var (result1, engine1, ex1) = RunTask(projectDir); + + // Clean output for second run + CleanOutput(projectDir); + + // --- Multithreaded mode: CWD == otherDir --- + Directory.SetCurrentDirectory(otherDir); + var (result2, engine2, ex2) = RunTask(projectDir); + + result1.Should().Be(result2, + "task should return the same success/failure in both environments"); + (ex1 == null).Should().Be(ex2 == null, + "both environments should agree on whether an exception is thrown"); + if (ex1 != null && ex2 != null) + { + ex1.GetType().Should().Be(ex2.GetType(), + "exception type should be identical in both environments"); + } + engine1.Errors.Count.Should().Be(engine2.Errors.Count, + "error count should be the same in both environments"); + engine1.Warnings.Count.Should().Be(engine2.Warnings.Count, + "warning count should be the same in both environments"); + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } + + [Fact] + public void OutputAppConfig_WrittenToCorrectLocation() + { + // With CWD set to decoyDir, task must write the output file + // relative to TaskEnvironment's project directory, not CWD. + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"wac-out-{Guid.NewGuid():N}")); + var decoyDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"wac-out-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(decoyDir); + var savedCwd = Directory.GetCurrentDirectory(); + try + { + SetupProjectLayout(projectDir); + Directory.SetCurrentDirectory(decoyDir); + + var task = CreateTask(projectDir); + + Exception? caughtEx = null; + bool result = false; + try { result = task.Execute(); } + catch (Exception ex) { caughtEx = ex; } + + if (caughtEx != null) + { + caughtEx.Should().NotBeOfType( + "task should resolve paths via TaskEnvironment, not CWD"); + caughtEx.Should().NotBeOfType( + "task should resolve paths via TaskEnvironment, not CWD"); + } + + result.Should().BeTrue("task must succeed for FilesWritten assertions to be meaningful"); + + // Output file should be under projectDir, not decoyDir + var expectedPath = Path.Combine(projectDir, "obj", "myapp.exe.config"); + File.Exists(expectedPath).Should().BeTrue( + "output app.config should be written relative to project directory"); + + var decoyPath = Path.Combine(decoyDir, "obj", "myapp.exe.config"); + File.Exists(decoyPath).Should().BeFalse( + "output should NOT be written to CWD"); + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, true); + if (Directory.Exists(decoyDir)) Directory.Delete(decoyDir, true); + } + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public async System.Threading.Tasks.Task WriteAppConfig_ConcurrentExecution(int parallelism) + { + var decoyDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"wac-conc-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(decoyDir); + var savedCwd = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(decoyDir); + + var results = new ConcurrentBag<(bool success, string exType, string exMsg)>(); + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) + { + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => + RunConcurrentWorker(idx, startGate, results)); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + + results.Select(r => r.exType).Distinct().Should().HaveCount(1, + "all threads should experience the same failure mode"); + results.Should().NotContain(r => r.exType == nameof(FileNotFoundException) + || r.exType == nameof(DirectoryNotFoundException), + "tasks should resolve paths via TaskEnvironment, not CWD"); + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + if (Directory.Exists(decoyDir)) Directory.Delete(decoyDir, true); + } + } + + private static void RunConcurrentWorker( + int idx, + ManualResetEventSlim startGate, + ConcurrentBag<(bool success, string exType, string exMsg)> results) + { + var projectDir = Path.Combine(Path.GetTempPath(), $"wac-conc-{idx}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(projectDir); + try + { + SetupProjectLayout(projectDir); + var task = CreateTask(projectDir); + + startGate.Wait(); + var result = task.Execute(); + results.Add((result, "none", "")); + } + catch (Exception ex) + { + results.Add((false, ex.GetType().Name, ex.Message)); + } + finally + { + if (Directory.Exists(projectDir)) + Directory.Delete(projectDir, true); + } + } + + private static void SetupProjectLayout(string projectDir) + { + Directory.CreateDirectory(Path.Combine(projectDir, "obj")); + // Create a minimal app.config source + var appConfigPath = Path.Combine(projectDir, "app.config"); + File.WriteAllText(appConfigPath, "\n\n"); + } + + private static void CleanOutput(string projectDir) + { + var outputFile = Path.Combine(projectDir, "obj", "myapp.exe.config"); + if (File.Exists(outputFile)) File.Delete(outputFile); + } + + private static WriteAppConfigWithSupportedRuntime CreateTask(string projectDir) + { + return new WriteAppConfigWithSupportedRuntime + { + BuildEngine = new MockBuildEngine(), + AppConfigFile = new TaskItem(Path.Combine("app.config")), + OutputAppConfigFile = new TaskItem(Path.Combine("obj", "myapp.exe.config")), + TargetFrameworkIdentifier = ".NETFramework", + TargetFrameworkVersion = "v4.7.2", + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), + }; + } + + private static (bool result, MockBuildEngine engine, Exception? exception) RunTask(string projectDir) + { + var task = CreateTask(projectDir); + var engine = (MockBuildEngine)task.BuildEngine; + + try + { + var result = task.Execute(); + return (result, engine, null); + } + catch (Exception ex) + { + return (false, engine, ex); + } + } + } +} diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenTasksUseAbsolutePaths.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenTasksUseAbsolutePaths.cs index 6eb32043be60..83ecffbdcef4 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenTasksUseAbsolutePaths.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenTasksUseAbsolutePaths.cs @@ -202,6 +202,122 @@ public void GetAssemblyAttributes_WithRelativePaths_ShouldResolveFromProjectDire #endregion + #region GenerateDepsFile + + [Fact] + public void GenerateDepsFile_WithRelativePaths_ShouldResolveFromProjectDirectory() + { + var assetsContent = @"{ + ""version"": 3, + ""targets"": { "".NETCoreApp,Version=v8.0"": {} }, + ""libraries"": {}, + ""projectFileDependencyGroups"": { "".NETCoreApp,Version=v8.0"": [] }, + ""project"": { ""version"": ""1.0.0"", ""frameworks"": { ""net8.0"": {} } } + }"; + _env.CreateProjectDirectory("obj"); + _env.CreateProjectFile("obj/project.assets.json", assetsContent); + _env.CreateProjectFile("myapp.csproj", ""); + + File.Exists(_env.GetProjectPath("obj/project.assets.json")).Should().BeTrue(); + File.Exists("obj/project.assets.json").Should().BeFalse("file should NOT exist relative to CWD"); + + var task = new GenerateDepsFile + { + BuildEngine = new MockBuildEngine(), + TaskEnvironment = _env.TaskEnvironment, + ProjectPath = "myapp.csproj", + AssetsFilePath = "obj/project.assets.json", + DepsFilePath = "obj/myapp.deps.json", + TargetFramework = "net8.0", + AssemblyName = "myapp", + AssemblyExtension = ".dll", + AssemblyVersion = "1.0.0.0", + IncludeMainProject = true, + RuntimeFrameworks = Array.Empty(), + CompileReferences = Array.Empty(), + ResolvedNuGetFiles = Array.Empty(), + ResolvedRuntimeTargetsFiles = Array.Empty(), + RuntimeGraphPath = "" + }; + + var result = task.Execute(); + + result.Should().BeTrue("task should resolve relative paths via TaskEnvironment"); + File.Exists(_env.GetProjectPath("obj/myapp.deps.json")).Should().BeTrue("deps file should be written to project dir"); + } + + #endregion + + #region ResolveAppHosts - No File I/O (with empty input) + + [Fact] + public void ResolveAppHosts_NoFileIO_ShouldSucceed() + { + var task = new ResolveAppHosts + { + BuildEngine = new MockBuildEngine(), + TaskEnvironment = _env.TaskEnvironment, + TargetFrameworkIdentifier = ".NETCoreApp", + TargetFrameworkVersion = "8.0", + TargetingPackRoot = "packs", + RuntimeGraphPath = "", + AppHostRuntimeIdentifier = "win-x64", + PackAsToolShimRuntimeIdentifiers = Array.Empty(), + KnownAppHostPacks = Array.Empty() + }; + + var result = task.Execute(); + _output.WriteLine($"Result: {result}"); + } + + #endregion + + #region ShowPreviewMessage - No File I/O + + [Fact] + public void ShowPreviewMessage_NoFileIO_ShouldSucceed() + { + var task = new ShowPreviewMessage + { + BuildEngine = new MockBuildEngine() + }; + + var result = task.Execute(); + result.Should().BeTrue(); + } + + #endregion + + #region WriteAppConfigWithSupportedRuntime + + [Fact] + public void WriteAppConfigWithSupportedRuntime_WithRelativePaths_ShouldResolveFromProjectDirectory() + { + _env.CreateProjectFile("App.config", ""); + _env.CreateProjectDirectory("bin"); + + File.Exists(_env.GetProjectPath("App.config")).Should().BeTrue(); + File.Exists("App.config").Should().BeFalse("file should NOT exist relative to CWD"); + + var task = new WriteAppConfigWithSupportedRuntime + { + BuildEngine = new MockBuildEngine(), + TaskEnvironment = _env.TaskEnvironment, + AppConfigFile = new MockTaskItem("App.config", new Dictionary()), + OutputAppConfigFile = new MockTaskItem("bin/myapp.exe.config", new Dictionary()), + TargetFrameworkIdentifier = ".NETFramework", + TargetFrameworkVersion = "4.8" + }; + + var result = task.Execute(); + + result.Should().BeTrue("task should resolve relative paths via TaskEnvironment"); + File.Exists(_env.GetProjectPath("bin/myapp.exe.config")).Should().BeTrue( + "output should be written to project dir, not CWD"); + } + + #endregion + #region ResolvePackageAssets [Fact] diff --git a/test/Microsoft.NET.Build.Tasks.Tests/Mocks/TaskTestEnvironment.cs b/test/Microsoft.NET.Build.Tasks.Tests/Mocks/TaskTestEnvironment.cs index 4584d74f7982..507ffd64e330 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/Mocks/TaskTestEnvironment.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/Mocks/TaskTestEnvironment.cs @@ -46,7 +46,14 @@ public TaskTestEnvironment() TaskEnvironment = TaskEnvironmentHelper.CreateForTest(ProjectDirectory); // Switch CWD to the spawn directory so relative paths don't accidentally work. - _savedCwd = Directory.GetCurrentDirectory(); + try + { + _savedCwd = Directory.GetCurrentDirectory(); + } + catch + { + _savedCwd = Path.GetTempPath(); + } Directory.SetCurrentDirectory(SpawnDirectory); } @@ -95,7 +102,16 @@ public string GetIncorrectPath(string relativePath) public void Dispose() { - Directory.SetCurrentDirectory(_savedCwd); + // Restore CWD to a stable path. We can't rely on _savedCwd because + // a parallel test may have changed CWD to a temp dir that's since been deleted. + try + { + Directory.SetCurrentDirectory(_savedCwd); + } + catch (Exception) when (!Directory.Exists(_savedCwd)) + { + Directory.SetCurrentDirectory(Path.GetTempPath()); + } try { diff --git a/test/Microsoft.NET.Build.Tasks.Tests/TestLockFiles.cs b/test/Microsoft.NET.Build.Tasks.Tests/TestLockFiles.cs index 6f1c2f07185e..8b0e6cc9a548 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/TestLockFiles.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/TestLockFiles.cs @@ -9,10 +9,12 @@ namespace Microsoft.NET.Build.Tasks.UnitTests { internal static class TestLockFiles { + private static readonly string s_testAssemblyDirectory = + Path.GetDirectoryName(typeof(TestLockFiles).Assembly.Location)!; + public static LockFile GetLockFile(string lockFilePrefix) { - string baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - string filePath = Path.Combine(baseDir, "LockFiles", $"{lockFilePrefix}.project.lock.json"); + string filePath = Path.Combine(s_testAssemblyDirectory, "LockFiles", $"{lockFilePrefix}.project.lock.json"); return LockFileUtilities.GetLockFile(filePath, NullLogger.Instance); }