Skip to content
2 changes: 2 additions & 0 deletions .github/instructions/tasks.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Built-in tasks ship with MSBuild and cannot be independently versioned.
* Support UNC paths, long paths (> 260 chars), and cross-platform separators.

## Multithreaded Task Migration

* All built-in tasks implement `IMultiThreadableTask` with a default `TaskEnvironment` backed by `MultiProcessTaskEnvironmentDriver.Instance`.
* Shared static state is a concurrency hazard in multi-process builds.

## Related Documentation
Expand Down
8 changes: 4 additions & 4 deletions .github/skills/multithreaded-task-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ b. Implement `IMultiThreadableTask` **only if** the task needs `TaskEnvironment`
[MSBuildMultiThreadableTask]
public class MyTask : Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
...
}
```
Expand Down Expand Up @@ -61,7 +61,7 @@ The [`AbsolutePath`](https://github.com/dotnet/msbuild/blob/main/src/Framework/P

## Updating Unit Tests

Every test creating a task instance must set `TaskEnvironment = TaskEnvironmentHelper.CreateForTest()`.
Built-in MSBuild tasks now initialize `TaskEnvironment` with a `MultiProcessTaskEnvironmentDriver`-backed default. Tests creating instances of built-in tasks no longer need manual `TaskEnvironment` setup. For custom or third-party tasks that implement `IMultiThreadableTask` without a default initializer, set `TaskEnvironment = TaskEnvironmentHelper.CreateForTest()`.

## APIs to Avoid

Expand Down Expand Up @@ -276,7 +276,7 @@ Assertions: Execute() return value, [Output] exact string, error message content
## Sign-Off Checklist

- [ ] `[MSBuildMultiThreadableTask]` on every concrete class (not just base — `Inherited=false`)
- [ ] `IMultiThreadableTask` only on classes that use `TaskEnvironment` APIs
- [ ] `IMultiThreadableTask` on classes that use `TaskEnvironment` APIs, with default initializer `= new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance)`
- [ ] Every `[Output]` property: exact string value matches pre-migration
- [ ] Every `Log.LogError`/`LogWarning`: path in message matches pre-migration (use `OriginalValue`)
- [ ] Every `GetAbsolutePath` call: null/empty exception behavior matches old code path
Expand All @@ -285,7 +285,7 @@ Assertions: Execute() return value, [Output] exact string, error message content
- [ ] Every `??` or `?.` added: verified it doesn't swallow a previously-thrown exception
- [ ] No `AbsolutePath` leaks into user-visible strings unintentionally
- [ ] Helper methods traced for internal File API usage with non-absolutized paths
- [ ] All tests set `TaskEnvironment = TaskEnvironmentHelper.CreateForTest()`
- [ ] Tests for custom tasks set `TaskEnvironment = TaskEnvironmentHelper.CreateForTest()` (built-in tasks have a default)
- [ ] Cross-framework: tested on both net472 and net10.0
- [ ] Concurrent execution: two tasks with different project directories produce correct results
- [ ] No forbidden APIs (`Environment.Exit`, `Console.*`, etc.)
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ The `MSBuildMultiThreadableTaskAttribute` is **non-inheritable** (`Inherited = f
* Derived classes cannot accidentally inherit thread-safety assumptions from base classes
* The routing decision is always explicit and visible in the task's source code

Tasks may optionally implement `IMultiThreadableTask` to access `TaskEnvironment` APIs, but only the attribute determines routing behavior.
Tasks may optionally implement `IMultiThreadableTask` to access `TaskEnvironment` APIs, but only the attribute determines routing behavior. If task implements `IMultiThreadableTask`, `TaskEnvironment` should be backed by `MultiProcessTaskEnvironmentDriver.Instance`, which acts as a fallback for explicit instantiation and task host scenarios.

## Tasks transition

Expand Down
2 changes: 1 addition & 1 deletion documentation/specs/multithreading/taskhost-threading.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ When the main thread receives a `TaskHostConfiguration` packet, it spawns the ta
1. Sets up the environment (working directory, env vars, culture)
2. Loads the task assembly and instantiates the task
3. Sets task parameters via reflection
4. Calls `task.Execute()`
4. Calls `task.Execute()` — for tasks implementing `IMultiThreadableTask`, the default `TaskEnvironment` (backed by `MultiProcessTaskEnvironmentDriver`) is available, providing safe access to the task host process's working directory and environment variables
5. Collects output parameters
6. Packages the result into `TaskHostTaskComplete` and signals `_taskCompleteEvent`

Expand Down
4 changes: 3 additions & 1 deletion documentation/specs/multithreading/thread-safe-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ Similar to how MSBuild provides the abstract `Task` class with default implement
namespace Microsoft.Build.Utilities;
public abstract class MultiThreadableTask : Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment{ get; set; }
public TaskEnvironment TaskEnvironment{ get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
}
```

Built-in MSBuild tasks initialize `TaskEnvironment` with a `MultiProcessTaskEnvironmentDriver`-backed default. This ensures tasks have a usable `TaskEnvironment` even when explicitly instantiated outside the engine (e.g., `new Copy()`) or run in the out-of-proc task host. The engine's in-proc path (`TaskExecutionHost.InitializeForBatch`) overwrites the default with the appropriate driver before `Execute()` is called.

Task authors who want to support older MSBuild versions need to:
- Maintain both thread-safe and legacy implementations.
- Use conditional task declarations based on MSBuild version to select which assembly to load the task from.
Expand Down
122 changes: 122 additions & 0 deletions src/Build.UnitTests/BackEnd/TaskHost_MultiThreadableTask_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.Build.Tasks;
using Microsoft.Build.UnitTests;
using Microsoft.Build.Utilities;
using Shouldly;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.Build.Engine.UnitTests.BackEnd
{
/// <summary>
/// Tests that IMultiThreadableTask implementations always have a usable TaskEnvironment,
/// even when explicitly instantiated or run in the out-of-proc task host.
/// </summary>
public class TaskHost_MultiThreadableTask_Tests : IDisposable
{
private readonly ITestOutputHelper _output;
private readonly TestEnvironment _env;
private readonly string _testProjectsDir;

public TaskHost_MultiThreadableTask_Tests(ITestOutputHelper output)
{
_output = output;
_env = TestEnvironment.Create(output);
_testProjectsDir = _env.CreateFolder().Path;
}

public void Dispose()
{
_env.Dispose();
}

/// <summary>
/// A subclass of a built-in IMultiThreadableTask (MakeDir) should inherit the
/// non-null default TaskEnvironment. This covers the scenario where someone
/// derives from a built-in task and explicitly instantiates it.
/// </summary>
[Fact]
public void ExplicitlyInstantiated_InheritedTask_HasNonNullTaskEnvironment()
{
var task = new DerivedMakeDirTask();
IMultiThreadableTask multiThreadable = task;

multiThreadable.TaskEnvironment.ShouldNotBeNull();
}

/// <summary>
/// When a task that inherits from a built-in IMultiThreadableTask (MakeDir) runs in
/// the out-of-proc task host (via TaskHostFactory), the inherited default TaskEnvironment
/// should be usable. The task accesses TaskEnvironment.ProjectDirectory in Execute() —
/// without the default it would NRE.
/// </summary>
[Fact]
public void InheritedTask_InTaskHost_HasUsableTaskEnvironment()
{
string projectContent = $"""
<Project>
<UsingTask TaskName="DerivedMakeDirTask"
AssemblyFile="{Assembly.GetExecutingAssembly().Location}"
TaskFactory="TaskHostFactory" />

<Target Name="TestTarget">
<DerivedMakeDirTask Directories="does-not-matter" />
</Target>
</Project>
""";

string projectFile = Path.Combine(_testProjectsDir, "TaskEnvTest.proj");
File.WriteAllText(projectFile, projectContent);

var logger = new MockLogger(_output);
var buildParameters = new BuildParameters
{
Loggers = [logger],
DisableInProcNode = false,
EnableNodeReuse = false,
};

var buildRequestData = new BuildRequestData(
projectFile,
new Dictionary<string, string?>(),
null,
["TestTarget"],
null);

var result = BuildManager.DefaultBuildManager.Build(buildParameters, buildRequestData);

_output.WriteLine(logger.FullLog);

result.OverallResult.ShouldBe(BuildResultCode.Success);

// Verify the task actually ran in the task host
TaskRouterTestHelper.AssertTaskUsedTaskHost(logger, "DerivedMakeDirTask");

// Verify the task was able to read ProjectDirectory without NRE
logger.FullLog.ShouldContain("TaskEnvironment.ProjectDirectory=");
}
}

/// <summary>
/// Task that inherits from the built-in MakeDir (which implements IMultiThreadableTask).
/// Does NOT declare its own TaskEnvironment — it relies on the inherited default from MakeDir.
/// Overrides Execute() to log TaskEnvironment.ProjectDirectory, proving the default works.
/// </summary>
public class DerivedMakeDirTask : MakeDir
{
public override bool Execute()
{
string projectDir = TaskEnvironment.ProjectDirectory;
Log.LogMessage(MessageImportance.High, $"TaskEnvironment.ProjectDirectory={projectDir}");
return true;
}
}
}
2 changes: 1 addition & 1 deletion src/Tasks/AssignTargetPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class AssignTargetPath : TaskExtension, IMultiThreadableTask
/// <summary>
/// Gets or sets the task execution environment for thread-safe path resolution.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);


/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/Copy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ public Copy()
public bool FailIfNotIncremental { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

#endregion

Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/Delete.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public ITaskItem[] Files
public bool FailIfNotIncremental { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// Verify that the inputs are correct.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/DownloadFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public sealed class DownloadFile : TaskExtension, ICancelableTask, IIncrementalT
public bool FailIfNotIncremental { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// Gets or sets a <see cref="HttpMessageHandler"/> to use. This is used by unit tests to mock a connection to a remote server.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/FileIO/GetFileHash.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ internal static readonly Dictionary<string, Func<HashAlgorithm>> SupportedAlgori
public ITaskItem[] Items { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

public override bool Execute()
{
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/FileIO/ReadLinesFromFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class ReadLinesFromFile : TaskExtension, IMultiThreadableTask
public ITaskItem File { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// Receives lines from file.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/FileIO/VerifyFileHash.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace Microsoft.Build.Tasks
public sealed class VerifyFileHash : TaskExtension, ICancelableTask, IMultiThreadableTask
{
/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// The file path.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/FileIO/WriteLinesToFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class WriteLinesToFile : TaskExtension, IIncrementalTask, IMultiThreadabl
private static readonly Encoding s_defaultEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// File to write lines to.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/ListOperators/FindUnderPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class FindUnderPath : TaskExtension, IMultiThreadableTask
/// <summary>
/// Gets or sets the task execution environment for thread-safe path resolution.
/// </summary>
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// Filter based on whether items fall under this path or not.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/MakeDir.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public ITaskItem[] Directories
public bool FailIfNotIncremental { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

private ITaskItem[] _directories;

Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/Move.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public class Move : TaskExtension, ICancelableTask, IIncrementalTask, IMultiThre
public bool FailIfNotIncremental { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// Stop and return (in an undefined state) as soon as possible.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/RemoveDir.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class RemoveDir : TaskExtension, IIncrementalTask, IMultiThreadableTask
private ITaskItem[] _directories;

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

[Required]
public ITaskItem[] Directories
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/Touch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class Touch : TaskExtension, IIncrementalTask, IMultiThreadableTask
public ITaskItem[] TouchedFiles { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// Importance: high, normal, low (default normal)
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/Unzip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public sealed class Unzip : TaskExtension, ICancelableTask, IIncrementalTask, IM
public bool FailIfNotIncremental { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <inheritdoc cref="ICancelableTask.Cancel"/>
public void Cancel()
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/WriteCodeFragment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ namespace Microsoft.Build.Tasks
public class WriteCodeFragment : TaskExtension, IMultiThreadableTask
{
/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
private const string TypeNameSuffix = "_TypeName";
private const string IsLiteralSuffix = "_IsLiteral";
private static readonly string[] NamespaceImports = ["System", "System.Reflection"];
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/XmlPeek.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class XmlPeek : TaskExtension, IMultiThreadableTask
#region Properties

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// The XPath Query.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/XmlPoke.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class XmlPoke : TaskExtension, IMultiThreadableTask
#region Properties

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// The XML input as file path.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/XslTransformation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class XslTransformation : TaskExtension, IMultiThreadableTask
#region Members

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; }
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

/// <summary>
/// The output files.
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/ZipDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public sealed class ZipDirectory : TaskExtension, IIncrementalTask, IMultiThread
public string? CompressionLevel { get; set; }

/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; } = null!;
public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);

public override bool Execute()
{
Expand Down