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()`.
All 19 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()`.
Comment thread
JanProvaznik marked this conversation as resolved.
Outdated

## 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.)
4 changes: 2 additions & 2 deletions documentation/specs/multithreading/multithreaded-msbuild.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,11 @@ 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. All 19 built-in MSBuild tasks now implement `IMultiThreadableTask` with a default `TaskEnvironment` backed by `MultiProcessTaskEnvironmentDriver.Instance`, which acts as a fallback for explicit instantiation and task host scenarios.
Comment thread
JanProvaznik marked this conversation as resolved.
Outdated

## Tasks transition

In the initial phase of development of multithreaded execution mode, all tasks will run in sidecar taskhosts. Over time, we will update tasks that are maintained by us and our partners (such as MSBuild, SDK, and NuGet) to add the `MSBuildMultiThreadableTaskAttribute` and ensure thread-safety. As these tasks are marked with the attribute, their execution would be moved into the entry process. Customers' tasks would be executed in the sidecar taskhosts unless they add the attribute to their task classes.
All built-in tasks maintained by the MSBuild team have been migrated to implement `IMultiThreadableTask` with default `TaskEnvironment` initialization and are decorated with `[MSBuildMultiThreadableTask]`. Partner tasks (SDK, NuGet) should follow the same pattern. Customer tasks that do not add the attribute will continue to execute in sidecar task hosts.
Comment thread
JanProvaznik marked this conversation as resolved.
Outdated

To ease task authoring, we will provide a Roslyn analyzer that will check for known-bad API usage, like `System.Environment.GetEnvironmentVariable` or `System.IO.Directory.SetCurrentDirectory`, and suggest alternatives that use the `TaskEnvironment` object (for tasks that also implement `IMultiThreadableTask`).

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);
}
```

All 19 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.
Comment thread
JanProvaznik marked this conversation as resolved.
Outdated

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;
Comment thread
JanProvaznik marked this conversation as resolved.
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);
Comment thread
JanProvaznik marked this conversation as resolved.

#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);
Comment thread
JanProvaznik marked this conversation as resolved.

/// <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);

Comment thread
JanProvaznik marked this conversation as resolved.
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);
Comment thread
JanProvaznik marked this conversation as resolved.

/// <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
Loading