Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4b60bed
Refactor CliFolderPathCalculatorCore to support env var injection
SimaTian Feb 23, 2026
be29117
Remove static convenience methods per review feedback
SimaTian Feb 23, 2026
1aa3a28
Refactor FrameworkReferenceResolver to support env var injection
SimaTian Feb 24, 2026
0af0c18
Migrate 5 Framework & Environment tasks to IMultiThreadableTask (Grou…
SimaTian Feb 22, 2026
691534b
Fix static Environment.GetEnvironmentVariable in ResolveTargetingPack…
SimaTian Feb 22, 2026
ba123d2
Fix compilation error: pass allowCacheLookup to static Resolve method
SimaTian Feb 22, 2026
7261071
Add TODO comments for transitive CliFolderPathCalculatorCore env var …
SimaTian Feb 22, 2026
edf0de7
Add CWD-independence behavioral test for ResolveTargetingPackAssets
SimaTian Feb 22, 2026
7c2e2d4
Add concurrent execution tests for Group 11 tasks
SimaTian Feb 22, 2026
323495b
Address PR review: ManualResetEventSlim + existing test fixes + cache…
SimaTian Feb 23, 2026
fc0013e
Fix test assertions: null output array + targeting pack ItemSpec mism…
SimaTian Feb 23, 2026
ad94f20
Fix CWD race: add [Collection] to serialize CWD-mutating tests
SimaTian Feb 23, 2026
fad0a41
Fix TaskEnvironment null crash, hash test exclusion, and path separat…
SimaTian Feb 24, 2026
7b8e1ba
Fix CWD race: use assembly-based paths in TestLockFiles and baselines
SimaTian Feb 24, 2026
aa47a0e
Fix AbsolutePath combining constructor: normalize '..' segments
SimaTian Feb 24, 2026
3034b17
Add UseShellExecute=false in ProcessTaskEnvironmentDriver
SimaTian Feb 25, 2026
b4a5e1a
Revert AbsolutePath: remove Path.GetFullPath wrapping
SimaTian Feb 25, 2026
8180bff
Fix CWD-dependent Strings.resx loading in GivenThatWeHaveErrorCodes
SimaTian Feb 25, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public static string DotnetHomePath
{
get
{
return CliFolderPathCalculatorCore.GetDotnetHomePath()
return new CliFolderPathCalculatorCore().GetDotnetHomePath()
?? throw new ConfigurationException(
string.Format(
LocalizableStrings.FailedToDetermineUserHomeDirectory,
Expand Down
28 changes: 24 additions & 4 deletions src/Common/CliFolderPathCalculatorCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,31 @@

namespace Microsoft.DotNet.Configurer
{
static class CliFolderPathCalculatorCore
class CliFolderPathCalculatorCore
{
public const string DotnetHomeVariableName = "DOTNET_CLI_HOME";
public const string DotnetProfileDirectoryName = ".dotnet";

public static string? GetDotnetUserProfileFolderPath()
private readonly Func<string, string?> _getEnvironmentVariable;

/// <summary>
/// Creates an instance that reads environment variables from the process environment.
/// </summary>
public CliFolderPathCalculatorCore()
: this(Environment.GetEnvironmentVariable)
{
}

/// <summary>
/// Creates an instance that reads environment variables via the supplied delegate.
/// Use this from MSBuild tasks to route reads through TaskEnvironment.
/// </summary>
public CliFolderPathCalculatorCore(Func<string, string?> getEnvironmentVariable)
{
_getEnvironmentVariable = getEnvironmentVariable ?? throw new ArgumentNullException(nameof(getEnvironmentVariable));
}

public string? GetDotnetUserProfileFolderPath()
{
string? homePath = GetDotnetHomePath();
if (homePath is null)
Expand All @@ -19,9 +38,9 @@ static class CliFolderPathCalculatorCore
return Path.Combine(homePath, DotnetProfileDirectoryName);
}

public static string? GetDotnetHomePath()
public string? GetDotnetHomePath()
{
var home = Environment.GetEnvironmentVariable(DotnetHomeVariableName);
var home = _getEnvironmentVariable(DotnetHomeVariableName);
if (string.IsNullOrEmpty(home))
{
home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
Expand All @@ -33,5 +52,6 @@ static class CliFolderPathCalculatorCore

return home;
}

}
}
2 changes: 1 addition & 1 deletion src/RazorSdk/Tool/ServerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ internal static string GetPidFilePath()
var path = Environment.GetEnvironmentVariable("DOTNET_BUILD_PIDFILE_DIRECTORY");
if (string.IsNullOrEmpty(path))
{
var homePath = CliFolderPathCalculatorCore.GetDotnetHomePath();
var homePath = new CliFolderPathCalculatorCore().GetDotnetHomePath();
if (homePath is null)
{
// Couldn't locate the user profile directory. Bail.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ private sealed class CachedState
};

// First check if requested SDK resolves to a workload SDK pack
string? userProfileDir = CliFolderPathCalculatorCore.GetDotnetUserProfileFolderPath();
string? userProfileDir = new CliFolderPathCalculatorCore().GetDotnetUserProfileFolderPath();
ResolutionResult? workloadResult = null;
if (dotnetRoot is not null && netcoreSdkVersion is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private class CachedState
resolverContext.State = cachedState;
}

string? userProfileDir = CliFolderPathCalculatorCore.GetDotnetUserProfileFolderPath();
string? userProfileDir = new CliFolderPathCalculatorCore().GetDotnetUserProfileFolderPath();
ResolutionResult? result = null;
if (cachedState.DotnetRootPath is not null && cachedState.SdkVersion is not null)
{
Expand Down
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
Expand Up @@ -73,7 +73,7 @@ public void ItBuildsDependencyContextsFromProjectLockFiles(
.Build();

JObject result = Save(dependencyContext);
JObject baseline = ReadJson($"{baselineFileName}.deps.json");
JObject baseline = ReadJson(Path.Combine(TestLockFiles.TestAssemblyDirectory, $"{baselineFileName}.deps.json"));

try
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// 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 Xunit;

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

public class GivenAProcessFrameworkReferencesMultiThreading
{
[Fact]
public void EmptyFrameworkReferences_DoesNotCrash()
{
var task = new ProcessFrameworkReferences
{
BuildEngine = new MockBuildEngine(),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
TargetFrameworkVersion = "8.0",
TargetingPackRoot = "",
RuntimeGraphPath = "",
FrameworkReferences = Array.Empty<ITaskItem>(),
KnownFrameworkReferences = Array.Empty<ITaskItem>(),
KnownRuntimePacks = Array.Empty<ITaskItem>(),
KnownCrossgen2Packs = Array.Empty<ITaskItem>(),
KnownILCompilerPacks = Array.Empty<ITaskItem>(),
KnownILLinkPacks = Array.Empty<ITaskItem>(),
KnownWebAssemblySdkPacks = Array.Empty<ITaskItem>(),
};

// Should not throw; may return true (nothing to process) or false (missing required props)
var act = () => task.Execute();
act.Should().NotThrow("empty framework references should be handled gracefully");
}

[Fact]
public void ProjectDirectory_UsedInsteadOfEnvironmentCurrentDirectory()
{
// Verify that the task uses TaskEnvironment.ProjectDirectory for global.json search
// instead of Environment.CurrentDirectory
var projectDir = Path.Combine(Path.GetTempPath(), "pfr-mt-" + Guid.NewGuid().ToString("N"));
var otherDir = Path.Combine(Path.GetTempPath(), "pfr-decoy-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(projectDir);
Directory.CreateDirectory(otherDir);
var savedCwd = Directory.GetCurrentDirectory();

try
{
// Multiprocess mode: CWD == projectDir
Directory.SetCurrentDirectory(projectDir);
var (result1, engine1) = RunTask(projectDir);

// Multithreaded mode: CWD == otherDir
Directory.SetCurrentDirectory(otherDir);
var (result2, engine2) = RunTask(projectDir);

// Both should produce the same result
result1.Should().Be(result2,
"task should return the same result regardless of CWD");
engine1.Errors.Count.Should().Be(engine2.Errors.Count,
"error count should match between multiprocess and multithreaded modes");
engine1.Warnings.Count.Should().Be(engine2.Warnings.Count,
"warning count should match between multiprocess and multithreaded modes");
}
Comment on lines +41 to +69
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.

ProjectDirectory_UsedInsteadOfEnvironmentCurrentDirectory changes the process CWD, but RunTask() uses empty FrameworkReferences/known packs. In that configuration, ProcessFrameworkReferences may never invoke the code path that reads TaskEnvironment.ProjectDirectory for global.json resolution, so the test may not validate the intended behavior. Consider supplying the minimal inputs needed to force workload resolver/global.json lookup, and isolate the Directory.SetCurrentDirectory usage from xUnit parallel execution.

Copilot generated this review using guidance from repository custom instructions.
finally
{
Directory.SetCurrentDirectory(savedCwd);
Directory.Delete(projectDir, true);
if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true);
}
}

[Theory]
[InlineData(4)]
[InlineData(16)]
public async System.Threading.Tasks.Task ProcessFrameworkReferences_ConcurrentExecution(int parallelism)
{
var errors = new ConcurrentBag<string>();
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(() =>
{
try
{
var task = new ProcessFrameworkReferences
{
BuildEngine = new MockBuildEngine(),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
TargetFrameworkVersion = "8.0",
TargetingPackRoot = "",
RuntimeGraphPath = "",
FrameworkReferences = Array.Empty<ITaskItem>(),
KnownFrameworkReferences = Array.Empty<ITaskItem>(),
KnownRuntimePacks = Array.Empty<ITaskItem>(),
KnownCrossgen2Packs = Array.Empty<ITaskItem>(),
KnownILCompilerPacks = Array.Empty<ITaskItem>(),
KnownILLinkPacks = Array.Empty<ITaskItem>(),
KnownWebAssemblySdkPacks = Array.Empty<ITaskItem>(),
};
startGate.Wait();
task.Execute();
}
Comment on lines +83 to +110
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.

These concurrency tests use Barrier.SignalAndWait() with no timeout. If any iteration throws before reaching the barrier, the remaining threads will hang the test run indefinitely. Consider adding a timeout and ensuring the barrier is always signaled (or use a different coordination primitive that can’t deadlock the test runner).

Copilot uses AI. Check for mistakes.
catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); }
});
}
startGate.Set();
await System.Threading.Tasks.Task.WhenAll(tasks);

errors.Should().BeEmpty();
}

private static (bool result, MockBuildEngine engine) RunTask(string projectDir)
{
var engine = new MockBuildEngine();
var task = new ProcessFrameworkReferences
{
BuildEngine = engine,
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
TargetFrameworkVersion = "8.0",
TargetingPackRoot = "",
RuntimeGraphPath = "",
FrameworkReferences = Array.Empty<ITaskItem>(),
KnownFrameworkReferences = Array.Empty<ITaskItem>(),
KnownRuntimePacks = Array.Empty<ITaskItem>(),
KnownCrossgen2Packs = Array.Empty<ITaskItem>(),
KnownILCompilerPacks = Array.Empty<ITaskItem>(),
KnownILLinkPacks = Array.Empty<ITaskItem>(),
KnownWebAssemblySdkPacks = Array.Empty<ITaskItem>(),
};

bool result;
try { result = task.Execute(); }
catch { result = false; }
return (result, engine);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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 Xunit;

namespace Microsoft.NET.Build.Tasks.UnitTests
{
public class GivenAResolveFrameworkReferencesMultiThreading
{
[Fact]
public void EmptyInputs_DoesNotCrash()
{
var task = new ResolveFrameworkReferences
{
BuildEngine = new MockBuildEngine(),
FrameworkReferences = Array.Empty<ITaskItem>(),
ResolvedTargetingPacks = Array.Empty<ITaskItem>(),
ResolvedRuntimePacks = Array.Empty<ITaskItem>(),
};

var result = task.Execute();

result.Should().BeTrue("empty inputs should succeed with no output");
(task.ResolvedFrameworkReferences ?? Array.Empty<ITaskItem>()).Should().BeEmpty();
}

[Fact]
public void ResolvesFrameworkReferences_WithMatchingPacks()
{
var fwRef = new MockTaskItem("Microsoft.NETCore.App", new Dictionary<string, string>());

var targetingPack = new MockTaskItem("Microsoft.NETCore.App", new Dictionary<string, string>());
targetingPack.SetMetadata("FrameworkName", "Microsoft.NETCore.App");
targetingPack.SetMetadata("NuGetPackageVersion", "8.0.0");
targetingPack.SetMetadata("Path", @"C:\packs\targeting");

var runtimePack = new MockTaskItem("Microsoft.NETCore.App.Runtime.win-x64", new Dictionary<string, string>());
runtimePack.SetMetadata("FrameworkName", "Microsoft.NETCore.App");
runtimePack.SetMetadata("NuGetPackageVersion", "8.0.0");
runtimePack.SetMetadata("PackageDirectory", @"C:\packs\runtime");

var task = new ResolveFrameworkReferences
{
BuildEngine = new MockBuildEngine(),
FrameworkReferences = new ITaskItem[] { fwRef },
ResolvedTargetingPacks = new ITaskItem[] { targetingPack },
ResolvedRuntimePacks = new ITaskItem[] { runtimePack },
};

var result = task.Execute();

result.Should().BeTrue();
task.ResolvedFrameworkReferences.Should().NotBeEmpty();
}
[Theory]
[InlineData(4)]
[InlineData(16)]
public async System.Threading.Tasks.Task ResolveFrameworkReferences_ConcurrentExecution(int parallelism)
{
var errors = new ConcurrentBag<string>();
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(() =>
{
try
{
var task = new ResolveFrameworkReferences
{
BuildEngine = new MockBuildEngine(),
FrameworkReferences = Array.Empty<ITaskItem>(),
ResolvedTargetingPacks = Array.Empty<ITaskItem>(),
ResolvedRuntimePacks = Array.Empty<ITaskItem>(),
};
startGate.Wait();
task.Execute();
}
catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); }
});
}
startGate.Set();
await System.Threading.Tasks.Task.WhenAll(tasks);

errors.Should().BeEmpty();
}
}
}
Loading
Loading