Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Tasks/Common/ProcessTaskEnvironmentDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ public ProcessStartInfo GetProcessStartInfo()
var startInfo = new ProcessStartInfo
{
WorkingDirectory = _projectDirectory.Value,
UseShellExecute = false,
};

// Populate environment from the scoped environment dictionary
Expand Down
26 changes: 26 additions & 0 deletions src/Tasks/Common/TaskEnvironmentDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

// Provides a default TaskEnvironment for single-threaded MSBuild execution.
// When MSBuild supports IMultiThreadableTask, it sets TaskEnvironment directly.
// This fallback ensures tasks work with older MSBuild versions that do not set it.

#if NETFRAMEWORK

using System;

namespace Microsoft.Build.Framework
{
internal static class TaskEnvironmentDefaults
{
/// <summary>
/// Creates a default TaskEnvironment backed by the current process environment.
/// Uses Environment.CurrentDirectory as the project directory, which in single-threaded
/// MSBuild is set to the project directory before task execution.
/// </summary>
internal static TaskEnvironment Create() =>
new TaskEnvironment(new ProcessTaskEnvironmentDriver(Environment.CurrentDirectory));
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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 Microsoft.Build.Utilities;
using Xunit;

namespace Microsoft.NET.Build.Tasks.UnitTests
{
[Collection("CWD-Dependent")]

public class GivenAProduceContentAssetsMultiThreading
{
[Fact]
public void ContentPreprocessorOutputDirectory_IsResolvedRelativeToProjectDirectory()
{
var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"produce-mt-{Guid.NewGuid():N}"));
Directory.CreateDirectory(projectDir);
try
{
// Create a relative output directory under projectDir
var ppOutputDir = Path.Combine(projectDir, "obj", "pp");
Directory.CreateDirectory(ppOutputDir);

var contentFile = new MockTaskItem("path/to/content.cs", new Dictionary<string, string>
{
{ "NuGetPackageId", "MyPackage" },
{ "NuGetPackageVersion", "1.0.0" },
{ "BuildAction", "Compile" },
{ "CodeLanguage", "any" },
{ "CopyToOutput", "false" },
{ "PPOutputPath", "" },
{ "OutputPath", "" }
});

var task = new ProduceContentAssets
{
BuildEngine = new MockBuildEngine(),
ContentFileDependencies = new ITaskItem[] { contentFile },
ContentPreprocessorOutputDirectory = Path.Combine("obj", "pp"),
ProjectLanguage = "C#",
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
};

var result = task.Execute();

result.Should().BeTrue("task should succeed with relative ContentPreprocessorOutputDirectory resolved via TaskEnvironment");
}
finally
{
Directory.Delete(projectDir, true);
}
}

[Fact]
public void ItProducesSameResultsRegardlessOfCwd()
{
var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"produce-parity-{Guid.NewGuid():N}"));
var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"produce-decoy-{Guid.NewGuid():N}"));
Directory.CreateDirectory(projectDir);
Directory.CreateDirectory(otherDir);
var savedCwd = Directory.GetCurrentDirectory();
try
{
var contentFile1 = new MockTaskItem("path/to/content.cs", new Dictionary<string, string>
{
{ "NuGetPackageId", "MyPackage" },
{ "NuGetPackageVersion", "1.0.0" },
{ "BuildAction", "Compile" },
{ "CodeLanguage", "any" },
{ "CopyToOutput", "false" },
{ "PPOutputPath", "" },
{ "OutputPath", "" }
});
var contentFile2 = new MockTaskItem("path/to/content.cs", new Dictionary<string, string>
{
{ "NuGetPackageId", "MyPackage" },
{ "NuGetPackageVersion", "1.0.0" },
{ "BuildAction", "Compile" },
{ "CodeLanguage", "any" },
{ "CopyToOutput", "false" },
{ "PPOutputPath", "" },
{ "OutputPath", "" }
});

var taskEnv = TaskEnvironmentHelper.CreateForTest(projectDir);

// --- Multiprocess mode: CWD == projectDir ---
Directory.SetCurrentDirectory(projectDir);
var engine1 = new MockBuildEngine();
var task1 = new ProduceContentAssets
{
Comment on lines +89 to +93
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

This test mutates the process-wide current directory via Directory.SetCurrentDirectory(...). Because xUnit runs tests in parallel by default, this can race with other tests in the same assembly and cause flakiness. Consider isolating this test in a non-parallelized collection or restructuring to avoid changing CWD.

Copilot uses AI. Check for mistakes.
BuildEngine = engine1,
ContentFileDependencies = new ITaskItem[] { contentFile1 },
ProjectLanguage = "C#",
TaskEnvironment = taskEnv,
};
var result1 = task1.Execute();

// --- Multithreaded mode: CWD == otherDir ---
Directory.SetCurrentDirectory(otherDir);
var engine2 = new MockBuildEngine();
var task2 = new ProduceContentAssets
{
BuildEngine = engine2,
ContentFileDependencies = new ITaskItem[] { contentFile2 },
ProjectLanguage = "C#",
TaskEnvironment = taskEnv,
};
var result2 = task2.Execute();

result1.Should().Be(result2,
"task should return the same result regardless of CWD");
engine1.Errors.Count.Should().Be(engine2.Errors.Count,
"error count should be the same in both environments");
task1.ProcessedContentItems.Length.Should().Be(task2.ProcessedContentItems.Length,
"ProcessedContentItems count should be identical regardless of CWD");
task1.CopyLocalItems.Length.Should().Be(task2.CopyLocalItems.Length,
"CopyLocalItems count should be identical regardless of CWD");
task1.FileWrites.Length.Should().Be(task2.FileWrites.Length,
"FileWrites count should be identical regardless of CWD");
}
finally
{
Directory.SetCurrentDirectory(savedCwd);
Directory.Delete(projectDir, true);
if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public void ItProcessesContentFiles()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -99,6 +101,8 @@ public void ItOutputsFileWritesForProcessedContent()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -159,6 +163,8 @@ public void ItOutputsCopyLocalItems()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -235,6 +241,8 @@ public void ItOutputsContentItemsWithActiveBuildAction()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -318,6 +326,8 @@ public void ItCanOutputOnlyPreprocessedItems()
ProduceOnlyPreprocessorFiles = true,
ProjectLanguage = null,
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -373,6 +383,8 @@ public void ItIgnoresProjectLanguageIfCodeLanguageIsOnlyAny()
ContentPreprocessorOutputDirectory = null,
ProjectLanguage = "C#",
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -415,6 +427,8 @@ public void ItProcessesOnlyProjectLanguageIfPresent()
ContentPreprocessorOutputDirectory = null,
ProjectLanguage = "C#",
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand Down Expand Up @@ -459,6 +473,8 @@ public void ItProcessesOnlyAnyItemsIfProjectLanguageNotPresent()
ContentPreprocessorOutputDirectory = null,
ProjectLanguage = "C#",
};
task.BuildEngine = new MockBuildEngine();
task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();

// Asserts
Expand All @@ -482,8 +498,8 @@ public void ItProcessesOnlyAnyItemsIfProjectLanguageNotPresent()

#region Sample Test Data

private static readonly string ContentOutputDirectory = Path.Combine("bin", "obj");
private static readonly string PackageRootDirectory = Path.Combine("root", "packages");
private static readonly string ContentOutputDirectory = Path.GetFullPath(Path.Combine("bin", "obj"));
private static readonly string PackageRootDirectory = Path.GetFullPath(Path.Combine("root", "packages"));

private static ITaskItem[] GetPreprocessorValueItems(Dictionary<string, string> values)
=> values.Select(kvp => new MockTaskItem(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 GivenAResolveCopyLocalAssetsMultiThreading
{
private const string AssetsJson = """
{
"version": 3,
"targets": { ".NETCoreApp,Version=v8.0": {} },
"libraries": {},
"packageFolders": {},
"projectFileDependencyGroups": { ".NETCoreApp,Version=v8.0": [] },
"project": {
"version": "1.0.0",
"frameworks": { "net8.0": {} }
}
}
""";

[Fact]
public void AssetsFilePath_IsResolvedRelativeToProjectDirectory()
{
var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"copyloc-mt-{Guid.NewGuid():N}"));
Directory.CreateDirectory(projectDir);
try
{
var objDir = Path.Combine(projectDir, "obj");
Directory.CreateDirectory(objDir);
File.WriteAllText(Path.Combine(objDir, "project.assets.json"), AssetsJson);

var task = new ResolveCopyLocalAssets
{
BuildEngine = new MockBuildEngine(),
AssetsFilePath = Path.Combine("obj", "project.assets.json"),
TargetFramework = ".NETCoreApp,Version=v8.0",
RuntimeIdentifier = "",
IsSelfContained = false,
ResolveRuntimeTargets = false,
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
};

var result = task.Execute();

result.Should().BeTrue("task should succeed when assets file is found via TaskEnvironment");
task.ResolvedAssets.Should().BeEmpty("no packages in the lock file means no assets");
}
finally
{
Directory.Delete(projectDir, true);
}
}

[Fact]
public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments()
{
var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"copyloc-parity-{Guid.NewGuid():N}"));
var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"copyloc-decoy-{Guid.NewGuid():N}"));
Directory.CreateDirectory(projectDir);
Directory.CreateDirectory(otherDir);
var savedCwd = Directory.GetCurrentDirectory();
try
{
var objDir = Path.Combine(projectDir, "obj");
Directory.CreateDirectory(objDir);
File.WriteAllText(Path.Combine(objDir, "project.assets.json"), AssetsJson);

var assetsRelPath = Path.Combine("obj", "project.assets.json");
var taskEnv = TaskEnvironmentHelper.CreateForTest(projectDir);

// --- Multiprocess mode: CWD == projectDir ---
Directory.SetCurrentDirectory(projectDir);
var engine1 = new MockBuildEngine();
var task1 = new ResolveCopyLocalAssets
{
Comment on lines +78 to +82
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

This test changes the process-wide current directory (Directory.SetCurrentDirectory). With xUnit’s default parallel execution, this can interfere with unrelated tests and introduce flakiness. Consider putting this test into a non-parallelized collection or avoiding global CWD mutation.

Copilot uses AI. Check for mistakes.
BuildEngine = engine1,
AssetsFilePath = assetsRelPath,
TargetFramework = ".NETCoreApp,Version=v8.0",
RuntimeIdentifier = "",
IsSelfContained = false,
ResolveRuntimeTargets = false,
TaskEnvironment = taskEnv,
};
var result1 = task1.Execute();

// --- Multithreaded mode: CWD == otherDir ---
Directory.SetCurrentDirectory(otherDir);
var engine2 = new MockBuildEngine();
var task2 = new ResolveCopyLocalAssets
{
BuildEngine = engine2,
AssetsFilePath = assetsRelPath,
TargetFramework = ".NETCoreApp,Version=v8.0",
RuntimeIdentifier = "",
IsSelfContained = false,
ResolveRuntimeTargets = false,
TaskEnvironment = taskEnv,
};
var result2 = task2.Execute();

result1.Should().Be(result2,
"task should return the same result regardless of CWD");
engine1.Errors.Count.Should().Be(engine2.Errors.Count,
"error count should be identical in both environments");
task1.ResolvedAssets.Length.Should().Be(task2.ResolvedAssets.Length,
"ResolvedAssets count should be identical regardless of CWD");
}
finally
{
Directory.SetCurrentDirectory(savedCwd);
Directory.Delete(projectDir, true);
if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true);
}
}
}
}
Loading
Loading