Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4bf3189
Initial plan
Copilot Dec 3, 2025
101bae4
Add comprehensive spec document for threading fixes and task migratio…
Copilot Dec 3, 2025
08bd63a
Update spec with race condition fix details and concurrent collection…
Copilot Dec 3, 2025
645bfec
Implement File I/O task migration to multithreading API
Copilot Dec 5, 2025
40f9447
remove document
JanProvaznik Dec 10, 2025
7f3f946
taskenvironment can't be null
JanProvaznik Dec 10, 2025
efbdd60
Address PR review comments: fix redundant path resolution and comments
JanProvaznik Dec 10, 2025
9cce81f
Use AbsolutePath struct for type safety in file I/O tasks
JanProvaznik Dec 10, 2025
def225d
refactoring
JanProvaznik Dec 10, 2025
140104f
update tests with taskenvironment
JanProvaznik Dec 16, 2025
1a6c0bc
inject taskenv to all tests
JanProvaznik Dec 16, 2025
7114c55
fix makedir for invalid directories
JanProvaznik Dec 16, 2025
5fd56aa
Merge branch 'main' into copilot/outline-threading-fixes-logic-updates
JanProvaznik Dec 17, 2025
7dc368c
makedir fix optimization
JanProvaznik Dec 19, 2025
e2b230b
filestate
JanProvaznik Dec 19, 2025
a4a499d
pr feedback 1 - filestate, delete caching
JanProvaznik Jan 7, 2026
65f0d01
revert wrong change
JanProvaznik Jan 7, 2026
7f61524
fix test
JanProvaznik Jan 7, 2026
a207afe
simplify filestate
JanProvaznik Jan 7, 2026
e58fe21
absolutize logging
JanProvaznik Jan 7, 2026
8b3575a
absolutize logging + fix bug Copy
JanProvaznik Jan 7, 2026
b708104
fix compile errors
JanProvaznik Jan 8, 2026
95febbb
original property in AbsolutePath
JanProvaznik Jan 8, 2026
68da521
use consistently abstraction
JanProvaznik Jan 8, 2026
31b18c0
fix test
JanProvaznik Jan 8, 2026
3169478
catch exception during normalization and log them with appropriate codes
JanProvaznik Jan 8, 2026
155bc02
nullable value types work this way...
JanProvaznik Jan 8, 2026
9aac1ad
Merge branch 'main' into copilot/outline-threading-fixes-logic-updates
JanProvaznik Jan 8, 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
28 changes: 18 additions & 10 deletions src/Tasks/Copy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// A task that copies files.
/// </summary>
public class Copy : TaskExtension, IIncrementalTask, ICancelableTask
[MSBuildMultiThreadableTask]
public class Copy : TaskExtension, IIncrementalTask, ICancelableTask, IMultiThreadableTask
{
internal const string AlwaysRetryEnvVar = "MSBUILDALWAYSRETRY";
internal const string AlwaysOverwriteReadOnlyFilesEnvVar = "MSBUILDALWAYSOVERWRITEREADONLYFILES";
Expand Down Expand Up @@ -185,6 +186,11 @@ public Copy()

public bool FailIfNotIncremental { get; set; }

/// <summary>
/// Task environment for multithreaded execution
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

#endregion

/// <summary>
Expand Down Expand Up @@ -370,9 +376,11 @@ private void LogAlwaysRetryDiagnosticFromResources(string messageResourceName, p
if (!hardLinkCreated && !symbolicLinkCreated)
{
// Do not log a fake command line as well, as it's superfluous, and also potentially expensive
Log.LogMessage(MessageImportance.Normal, FileComment, sourceFileState.FileNameFullPath, destinationFileState.FileNameFullPath);
string sourceFilePath = TaskEnvironment.GetAbsolutePath(sourceFileState.Name);
string destinationFilePath = TaskEnvironment.GetAbsolutePath(destinationFileState.Name);
Log.LogMessage(MessageImportance.Normal, FileComment, sourceFilePath, destinationFilePath);

File.Copy(sourceFileState.Name, destinationFileState.Name, true);
File.Copy(sourceFilePath, destinationFilePath, true);
}

// If the destinationFile file exists, then make sure it's read-write.
Expand Down Expand Up @@ -444,7 +452,7 @@ internal bool Execute(
}

// Environment variable stomps on user-requested value if it's set.
if (Environment.GetEnvironmentVariable(AlwaysOverwriteReadOnlyFilesEnvVar) != null)
if (TaskEnvironment.GetEnvironmentVariable(AlwaysOverwriteReadOnlyFilesEnvVar) != null)
{
OverwriteReadOnlyFiles = true;
}
Expand Down Expand Up @@ -510,7 +518,7 @@ private bool CopySingleThreaded(

if (!copyComplete)
{
if (DoCopyIfNecessary(new FileState(SourceFiles[i].ItemSpec), new FileState(DestinationFiles[i].ItemSpec), copyFile))
if (DoCopyIfNecessary(new FileState(SourceFiles[i].ItemSpec, TaskEnvironment), new FileState(DestinationFiles[i].ItemSpec, TaskEnvironment), copyFile))
{
filesActuallyCopied[destPath] = SourceFiles[i].ItemSpec;
copyComplete = true;
Expand Down Expand Up @@ -656,8 +664,8 @@ void ProcessPartition()
if (!copyComplete)
{
if (DoCopyIfNecessary(
new FileState(sourceItem.ItemSpec),
new FileState(destItem.ItemSpec),
new FileState(sourceItem.ItemSpec, TaskEnvironment),
new FileState(destItem.ItemSpec, TaskEnvironment),
copyFile))
{
copyComplete = true;
Expand Down Expand Up @@ -1085,15 +1093,15 @@ public override bool Execute()
/// Compares two paths to see if they refer to the same file. We can't solve the general
/// canonicalization problem, so we just compare strings on the full paths.
/// </summary>
private static bool PathsAreIdentical(FileState source, FileState destination)
private bool PathsAreIdentical(FileState source, FileState destination)
{
if (string.Equals(source.Name, destination.Name, FileUtilities.PathComparison))
{
return true;
}

source.FileNameFullPath = Path.GetFullPath(source.Name);
destination.FileNameFullPath = Path.GetFullPath(destination.Name);
source.FileNameFullPath = TaskEnvironment.GetAbsolutePath(source.Name);
destination.FileNameFullPath = TaskEnvironment.GetAbsolutePath(destination.Name);
return string.Equals(source.FileNameFullPath, destination.FileNameFullPath, FileUtilities.PathComparison);
}

Expand Down
13 changes: 10 additions & 3 deletions src/Tasks/Delete.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// Delete files from disk.
/// </summary>
public class Delete : TaskExtension, ICancelableTask, IIncrementalTask
[MSBuildMultiThreadableTask]
public class Delete : TaskExtension, ICancelableTask, IIncrementalTask, IMultiThreadableTask
{
#region Properties

Expand Down Expand Up @@ -63,6 +64,11 @@ public ITaskItem[] Files
/// <remarks></remarks>
public bool FailIfNotIncremental { get; set; }

/// <summary>
/// The task environment for thread-safe operations.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

/// <summary>
/// Verify that the inputs are correct.
/// </summary>
Expand Down Expand Up @@ -119,7 +125,8 @@ public override bool Execute()
{
try
{
if (FileSystems.Default.FileExists(file.ItemSpec))
string filePath = TaskEnvironment.GetAbsolutePath(file.ItemSpec);
if (FileSystems.Default.FileExists(filePath))
{
if (FailIfNotIncremental)
{
Expand All @@ -131,7 +138,7 @@ public override bool Execute()
Log.LogMessageFromResources(MessageImportance.Normal, "Delete.DeletingFile", file.ItemSpec);
}

File.Delete(file.ItemSpec);
File.Delete(filePath);
}
else
{
Expand Down
13 changes: 10 additions & 3 deletions src/Tasks/FileIO/ReadLinesFromFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// Read a list of items from a file.
/// </summary>
public class ReadLinesFromFile : TaskExtension
[MSBuildMultiThreadableTask]
public class ReadLinesFromFile : TaskExtension, IMultiThreadableTask
{
/// <summary>
/// File to read lines from.
/// </summary>
[Required]
public ITaskItem File { get; set; }

/// <summary>
/// The task environment for thread-safe operations.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

/// <summary>
/// Receives lines from file.
/// </summary>
Expand All @@ -38,11 +44,12 @@ public override bool Execute()
bool success = true;
if (File != null)
{
if (FileSystems.Default.FileExists(File.ItemSpec))
string filePath = TaskEnvironment.GetAbsolutePath(File.ItemSpec);
if (FileSystems.Default.FileExists(filePath))
{
try
{
string[] textLines = System.IO.File.ReadAllLines(File.ItemSpec);
string[] textLines = System.IO.File.ReadAllLines(filePath);

var nonEmptyLines = new List<ITaskItem>();
char[] charsToTrim = { '\0', ' ', '\t' };
Expand Down
12 changes: 10 additions & 2 deletions src/Tasks/FileIO/WriteLinesToFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// Appends a list of items to a file. One item per line with carriage returns in-between.
/// </summary>
public class WriteLinesToFile : TaskExtension, IIncrementalTask
[MSBuildMultiThreadableTask]
public class WriteLinesToFile : TaskExtension, IIncrementalTask, IMultiThreadableTask
{
// Default encoding taken from System.IO.WriteAllText()
private static readonly Encoding s_defaultEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);

/// <summary>
/// The task environment for thread-safe operations.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

/// <summary>
/// File to write lines to.
/// </summary>
Expand Down Expand Up @@ -70,7 +76,9 @@ public override bool Execute()
return success;
}

string filePath = FileUtilities.NormalizePath(File.ItemSpec);
// Use TaskEnvironment to resolve relative paths when available (multi-threaded mode),
// otherwise fall back to standard normalization
string filePath = TaskEnvironment.GetAbsolutePath(File.ItemSpec);

string contentsAsString = string.Empty;

Expand Down
4 changes: 2 additions & 2 deletions src/Tasks/FileState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,11 @@ public void ThrowException()
/// Constructor.
/// Only stores file name: does not grab the file state until first request.
/// </summary>
internal FileState(string filename)
internal FileState(string filename, Microsoft.Build.Framework.TaskEnvironment taskEnvironment)
{
ErrorUtilities.VerifyThrowArgumentLength(filename);
_filename = filename;
_data = new Lazy<FileDirInfo>(() => new FileDirInfo(_filename));
_data = new Lazy<FileDirInfo>(() => new FileDirInfo(taskEnvironment.GetAbsolutePath(_filename)));
}

/// <summary>
Expand Down
22 changes: 16 additions & 6 deletions src/Tasks/MakeDir.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// A task that creates a directory
/// </summary>
public class MakeDir : TaskExtension, IIncrementalTask
[MSBuildMultiThreadableTask]
public class MakeDir : TaskExtension, IIncrementalTask, IMultiThreadableTask
{
[Required]
public ITaskItem[] Directories
Expand All @@ -33,6 +34,11 @@ public ITaskItem[] Directories

public bool FailIfNotIncremental { get; set; }

/// <summary>
/// The task environment for thread-safe operations.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

private ITaskItem[] _directories;

#region ITask Members
Expand All @@ -55,11 +61,14 @@ public override bool Execute()
{
try
{
// Get absolute path for thread-safe operations
string absolutePath = TaskEnvironment.GetAbsolutePath(directory.ItemSpec);

// For speed, eliminate duplicates caused by poor targets authoring
if (!directoriesSet.Contains(directory.ItemSpec))
if (!directoriesSet.Contains(absolutePath))
{
// Only log a message if we actually need to create the folder
if (!FileUtilities.DirectoryExistsNoThrow(directory.ItemSpec))
if (!FileUtilities.DirectoryExistsNoThrow(absolutePath))
{
if (FailIfNotIncremental)
{
Expand All @@ -70,7 +79,7 @@ public override bool Execute()
// Do not log a fake command line as well, as it's superfluous, and also potentially expensive
Log.LogMessageFromResources(MessageImportance.Normal, "MakeDir.Comment", directory.ItemSpec);

Directory.CreateDirectory(FileUtilities.FixFilePath(directory.ItemSpec));
Directory.CreateDirectory(FileUtilities.FixFilePath(absolutePath));
}
}

Expand All @@ -82,8 +91,9 @@ public override bool Execute()
Log.LogErrorWithCodeFromResources("MakeDir.Error", directory.ItemSpec, e.Message);
}

// Add even on failure to avoid reattempting
directoriesSet.Add(directory.ItemSpec);
// Add even on failure to avoid reattempting (use absolute path for proper deduplication)
string pathForDeduplication = TaskEnvironment.GetAbsolutePath(directory.ItemSpec);
directoriesSet.Add(pathForDeduplication);
}
}

Expand Down
17 changes: 12 additions & 5 deletions src/Tasks/RemoveDir.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// Remove the specified directories.
/// </summary>
public class RemoveDir : TaskExtension, IIncrementalTask
[MSBuildMultiThreadableTask]
public class RemoveDir : TaskExtension, IIncrementalTask, IMultiThreadableTask
{
//-----------------------------------------------------------------------------------
// Property: directory to remove
//-----------------------------------------------------------------------------------
private ITaskItem[] _directories;

/// <summary>
/// The task environment for thread-safe operations.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

[Required]
public ITaskItem[] Directories
{
Expand Down Expand Up @@ -53,15 +59,16 @@ public override bool Execute()

foreach (ITaskItem directory in Directories)
{
if (string.IsNullOrEmpty(directory.ItemSpec))
var directoryPath = TaskEnvironment.GetAbsolutePath(directory.ItemSpec);
if (string.IsNullOrEmpty(directoryPath))
{
// Skip any empty ItemSpecs, otherwise RemoveDir will wipe the root of the current drive (!).
// https://github.com/dotnet/msbuild/issues/7563
Log.LogWarningWithCodeFromResources("RemoveDir.EmptyPath");
continue;
}

if (FileSystems.Default.DirectoryExists(directory.ItemSpec))
if (FileSystems.Default.DirectoryExists(directoryPath))
{
if (FailIfNotIncremental)
{
Expand All @@ -81,7 +88,7 @@ public override bool Execute()
{
// If the directory delete operation returns an unauthorized access exception
// we need to attempt to remove the readonly attributes and try again.
currentSuccess = RemoveReadOnlyAttributeRecursively(new DirectoryInfo(directory.ItemSpec));
currentSuccess = RemoveReadOnlyAttributeRecursively(new DirectoryInfo(directoryPath));
if (currentSuccess)
{
// Retry the remove directory operation, this time we want to log any errors
Expand Down Expand Up @@ -120,7 +127,7 @@ private bool RemoveDirectory(ITaskItem directory, bool logUnauthorizedError, out
try
{
// Try to delete the directory
Directory.Delete(directory.ItemSpec, true);
Directory.Delete(TaskEnvironment.GetAbsolutePath(directory.ItemSpec), true);
}
catch (UnauthorizedAccessException e)
{
Expand Down
9 changes: 8 additions & 1 deletion src/Tasks/Touch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// This class defines the touch task.
/// </summary>
public class Touch : TaskExtension, IIncrementalTask
[MSBuildMultiThreadableTask]
public class Touch : TaskExtension, IIncrementalTask, IMultiThreadableTask
{
private MessageImportance messageImportance;

Expand Down Expand Up @@ -48,6 +49,11 @@ public class Touch : TaskExtension, IIncrementalTask
[Output]
public ITaskItem[] TouchedFiles { get; set; }

/// <summary>
/// The task environment for thread-safe operations.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }

/// <summary>
/// Importance: high, normal, low (default normal)
/// </summary>
Expand Down Expand Up @@ -196,6 +202,7 @@ private bool TouchFile(
SetLastAccessTime fileSetLastAccessTime,
SetLastWriteTime fileSetLastWriteTime)
{
file = TaskEnvironment.GetAbsolutePath(file);
if (!fileExists(file))
{
// If the file does not exist then we check if we need to create it.
Expand Down