diff --git a/src/Shared/TaskFactoryUtilities.cs b/src/Shared/TaskFactoryUtilities.cs
index 1dc8353799e..b773a23393b 100644
--- a/src/Shared/TaskFactoryUtilities.cs
+++ b/src/Shared/TaskFactoryUtilities.cs
@@ -267,5 +267,28 @@ public static bool ShouldCompileForOutOfProcess(IBuildEngine taskFactoryEngineCo
return null;
}
+
+ ///
+ /// Resolves a potentially relative source code file path for inline task factories.
+ /// In multithreaded mode (/mt), relative paths are resolved relative to the project file directory
+ /// rather than the current working directory. In other modes, the path is returned unchanged.
+ ///
+ /// The source code file path to resolve (may be relative or absolute).
+ /// Whether the build is running in multithreaded mode.
+ /// The directory of the project file.
+ /// The resolved absolute path in multithreaded mode, or the original path otherwise.
+ ///
+ /// This method only modifies path resolution in multithreaded builds to maintain
+ /// backward compatibility with existing multi-process build behavior.
+ ///
+ public static string ResolveTaskSourceCodePath(string path, bool isMultiThreadedBuild, string projectDirectory)
+ {
+ if (!isMultiThreadedBuild || Path.IsPathRooted(path))
+ {
+ return path;
+ }
+
+ return Path.Combine(projectDirectory, path);
+ }
}
}
diff --git a/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs b/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs
index 814ea1e440a..0253b720504 100644
--- a/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs
+++ b/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs
@@ -10,10 +10,9 @@
using Microsoft.Build.UnitTests;
using Microsoft.Build.UnitTests.Shared;
using Microsoft.Build.Utilities;
-#if NETFRAMEWORK
-using Microsoft.IO;
-#else
using System.IO;
+#if NETFRAMEWORK
+using MicrosoftIO = Microsoft.IO;
#endif
using Shouldly;
using VerifyTests;
@@ -924,7 +923,7 @@ private void TryLoadTaskBodyAndExpectFailure(string taskBody, string expectedErr
TaskResources = Shared.AssemblyResources.PrimaryResources
};
- bool success = RoslynCodeTaskFactory.TryLoadTaskBody(log, TaskName, taskBody, new List(), out RoslynCodeTaskFactoryTaskInfo _);
+ bool success = RoslynCodeTaskFactory.TryLoadTaskBody(log, TaskName, taskBody, new List(), buildEngine, out RoslynCodeTaskFactoryTaskInfo _);
success.ShouldBeFalse();
@@ -950,7 +949,7 @@ private void TryLoadTaskBodyAndExpectSuccess(
TaskResources = Shared.AssemblyResources.PrimaryResources
};
- bool success = RoslynCodeTaskFactory.TryLoadTaskBody(log, TaskName, taskBody, parameters ?? new List(), out RoslynCodeTaskFactoryTaskInfo taskInfo);
+ bool success = RoslynCodeTaskFactory.TryLoadTaskBody(log, TaskName, taskBody, parameters ?? new List(), buildEngine, out RoslynCodeTaskFactoryTaskInfo taskInfo);
buildEngine.Errors.ShouldBe(0, buildEngine.Log);
@@ -1160,5 +1159,365 @@ public void MultiThreadedBuildExecutesInlineTasksSuccessfully()
customMessage: "Inline task should be launched in external task host");
}
}
+
+ ///
+ /// Test that verifies Code Source attribute with relative path resolves correctly relative to the project file, not CWD.
+ /// This test exposes the bug where inline tasks with external code files fail in multi-threaded mode
+ /// because the file path is resolved relative to the multi-threaded process's CWD instead of the project file directory.
+ /// Creates a realistic scenario with nested directories similar to a multi-project solution structure.
+ ///
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void SourceCodeFromRelativeFilePath_ResolvesRelativeToProjectFile(bool forceOutOfProc)
+ {
+ using (TestEnvironment env = TestEnvironment.Create())
+ {
+ if (forceOutOfProc)
+ {
+ env.SetEnvironmentVariable("MSBUILDFORCEINLINETASKFACTORIESOUTOFPROC", "1");
+ }
+
+ // Create multi-project solution structure
+ // Root/
+ // ├─ solution.sln
+ // ├─ Project1/Project1.csproj (simple project)
+ // └─ Project2/
+ // ├─ Project2.csproj (has inline task with external code)
+ // └─ TaskCode.cs
+ TransientTestFolder rootFolder = env.CreateFolder(createFolder: true);
+ TransientTestFolder project1Folder = env.CreateFolder(Path.Combine(rootFolder.Path, "Project1"), createFolder: true);
+ TransientTestFolder project2Folder = env.CreateFolder(Path.Combine(rootFolder.Path, "Project2"), createFolder: true);
+
+ // Create simple first project
+ env.CreateFile(project1Folder, "Project1.csproj", @"
+
+
+
+
+");
+
+ // Create external code file in Project2 directory
+ const string taskCodeFile = "TaskCode.cs";
+ const string taskCode = @"
+namespace InlineTask
+{
+ using Microsoft.Build.Framework;
+ using Microsoft.Build.Utilities;
+
+ public class CustomTask : Task
+ {
+ public override bool Execute()
+ {
+ Log.LogMessage(MessageImportance.High, ""External task code executed successfully"");
+ return true;
+ }
+ }
+}";
+ env.CreateFile(project2Folder, taskCodeFile, taskCode);
+
+ // Create Project2 with inline task using RELATIVE path to external code
+ env.CreateFile(project2Folder, "Project2.csproj", $@"
+
+
+
+
+
+
+
+
+
+
+
+");
+
+ // Create solution file
+ TransientTestFile solutionFile = env.CreateFile(rootFolder, "solution.sln", @"
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project(""{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"") = ""Project1"", ""Project1\Project1.csproj"", ""{11111111-1111-1111-1111-111111111111}""
+EndProject
+Project(""{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"") = ""Project2"", ""Project2\Project2.csproj"", ""{22222222-2222-2222-2222-222222222222}""
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ EndGlobalSection
+EndGlobal
+");
+
+ // Build with /m /mt to trigger multi-proc + out-of-proc task execution where worker processes have different CWD
+ // BUG: RoslynCodeTaskFactory resolves "TaskCode.cs" relative to worker CWD, not Project2 directory
+ string buildArgs = $"{solutionFile.Path} /m /mt";
+ string output = RunnerUtilities.ExecMSBuild(buildArgs, out bool success);
+
+ success.ShouldBeTrue(customMessage: $"Build should succeed with relative Code Source path. Output: {output}");
+ output.ShouldContain("External task code executed successfully",
+ customMessage: "Task from external code file should execute");
+ }
+ }
+
+ ///
+ /// Unit test that verifies TryLoadTaskBody resolves relative file paths correctly.
+ /// This is a lower-level test that directly tests the method responsible for loading code from external files.
+ ///
+ [Fact]
+ public void TryLoadTaskBody_ResolvesRelativeSourcePath()
+ {
+ using (TestEnvironment env = TestEnvironment.Create())
+ {
+ // Create a test directory structure
+ TransientTestFolder testFolder = env.CreateFolder(createFolder: true);
+ string codeFileName = "TestTask.cs";
+ string codeContent = "public class TestTask : Task { }";
+ TransientTestFile codeFile = env.CreateFile(testFolder, codeFileName, codeContent);
+
+ // Create a mock project file path in the same directory
+ string projectFilePath = Path.Combine(testFolder.Path, "test.proj");
+
+ // Create a mock build engine that returns our project file path
+ MockEngine mockEngine = new MockEngine
+ {
+ ProjectFileOfTaskNode = projectFilePath
+ };
+ TaskLoggingHelper log = new TaskLoggingHelper(mockEngine, "TestTask")
+ {
+ TaskResources = AssemblyResources.PrimaryResources,
+ HelpKeywordPrefix = "MSBuild."
+ };
+
+ // Try loading with relative path
+ string taskBody = $"";
+
+ // Set IsMultiThreadedBuild to true to trigger the path resolution logic
+ mockEngine.IsMultiThreadedBuild = true;
+
+ bool success = RoslynCodeTaskFactory.TryLoadTaskBody(
+ log,
+ "TestTask",
+ taskBody,
+ Array.Empty(),
+ mockEngine,
+ out RoslynCodeTaskFactoryTaskInfo taskInfo);
+
+ success.ShouldBeTrue(customMessage: "TryLoadTaskBody should succeed");
+ taskInfo.SourceCode.ShouldContain("TestTask", customMessage: "Should have loaded code from file");
+ }
+ }
+
+ ///
+ /// Verifies that absolute paths in multi-threaded mode are passed through unchanged.
+ ///
+ [Fact]
+ public void TryLoadTaskBody_AbsolutePathPassesThrough()
+ {
+ using (TestEnvironment env = TestEnvironment.Create())
+ {
+ TransientTestFolder testFolder = env.CreateFolder(createFolder: true);
+ string codeFileName = "AbsolutePathTask.cs";
+ string codeContent = "public class AbsolutePathTask : Task { }";
+ TransientTestFile codeFile = env.CreateFile(testFolder, codeFileName, codeContent);
+
+ // Use absolute path
+ string absolutePath = codeFile.Path;
+
+ // Project file in a different directory
+ TransientTestFolder projectFolder = env.CreateFolder(createFolder: true);
+ string projectFilePath = Path.Combine(projectFolder.Path, "test.proj");
+
+ MockEngine mockEngine = new MockEngine
+ {
+ ProjectFileOfTaskNode = projectFilePath,
+ IsMultiThreadedBuild = true
+ };
+
+ TaskLoggingHelper log = new TaskLoggingHelper(mockEngine, "AbsolutePathTask")
+ {
+ TaskResources = AssemblyResources.PrimaryResources,
+ HelpKeywordPrefix = "MSBuild."
+ };
+
+ string taskBody = $"";
+
+ bool success = RoslynCodeTaskFactory.TryLoadTaskBody(
+ log,
+ "AbsolutePathTask",
+ taskBody,
+ Array.Empty(),
+ mockEngine,
+ out RoslynCodeTaskFactoryTaskInfo taskInfo);
+
+ success.ShouldBeTrue(customMessage: "Absolute path should work in multi-threaded mode");
+ taskInfo.SourceCode.ShouldContain("AbsolutePathTask");
+ }
+ }
+
+ ///
+ /// Verifies that relative paths with parent directory navigation (..) work correctly.
+ /// This is a legitimate use case for shared code files in parent directories.
+ ///
+ [Fact]
+ public void TryLoadTaskBody_RelativePathWithParentNavigation()
+ {
+ using (TestEnvironment env = TestEnvironment.Create())
+ {
+ TransientTestFolder rootFolder = env.CreateFolder(createFolder: true);
+
+ // Create shared code in root
+ string sharedCodeContent = "public class SharedTask : Task { }";
+ TransientTestFile sharedCodeFile = env.CreateFile(rootFolder, "SharedTask.cs", sharedCodeContent);
+
+ // Create project in subdirectory
+ TransientTestFolder projectFolder = env.CreateFolder(Path.Combine(rootFolder.Path, "Project"), createFolder: true);
+ string projectFilePath = Path.Combine(projectFolder.Path, "test.proj");
+
+ MockEngine mockEngine = new MockEngine
+ {
+ ProjectFileOfTaskNode = projectFilePath,
+ IsMultiThreadedBuild = true
+ };
+
+ TaskLoggingHelper log = new TaskLoggingHelper(mockEngine, "SharedTask")
+ {
+ TaskResources = AssemblyResources.PrimaryResources,
+ HelpKeywordPrefix = "MSBuild."
+ };
+
+ // Use relative path with parent directory navigation (cross-platform)
+ string relativePath = Path.Combine("..", "SharedTask.cs");
+ string taskBody = $"";
+
+ bool success = RoslynCodeTaskFactory.TryLoadTaskBody(
+ log,
+ "SharedTask",
+ taskBody,
+ Array.Empty(),
+ mockEngine,
+ out RoslynCodeTaskFactoryTaskInfo taskInfo);
+
+ success.ShouldBeTrue(customMessage: "Relative path with .. should work");
+ taskInfo.SourceCode.ShouldContain("SharedTask");
+ }
+ }
+
+ ///
+ /// Verifies that empty or whitespace source paths are handled with clear error messages.
+ ///
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(" ")]
+ public void TryLoadTaskBody_EmptySourcePathFails(string emptyPath)
+ {
+ MockEngine mockEngine = new MockEngine
+ {
+ ProjectFileOfTaskNode = "C:\\test\\test.proj",
+ IsMultiThreadedBuild = true
+ };
+
+ TaskLoggingHelper log = new TaskLoggingHelper(mockEngine, "EmptyPathTask")
+ {
+ TaskResources = AssemblyResources.PrimaryResources,
+ HelpKeywordPrefix = "MSBuild."
+ };
+
+ string taskBody = $"";
+
+ bool success = RoslynCodeTaskFactory.TryLoadTaskBody(
+ log,
+ "EmptyPathTask",
+ taskBody,
+ Array.Empty(),
+ mockEngine,
+ out RoslynCodeTaskFactoryTaskInfo _);
+
+ // Empty source attribute is already validated by XML parsing
+ success.ShouldBeFalse();
+ mockEngine.Errors.ShouldBeGreaterThan(0);
+ }
+
+ ///
+ /// Verifies behavior when ProjectFileOfTaskNode is null in multi-threaded mode.
+ /// This should fail with a clear error rather than silently producing incorrect results.
+ ///
+ [Fact]
+ public void TryLoadTaskBody_NullProjectFileInMultiThreadedMode()
+ {
+ using (TestEnvironment env = TestEnvironment.Create())
+ {
+ TransientTestFolder testFolder = env.CreateFolder(createFolder: true);
+ TransientTestFile codeFile = env.CreateFile(testFolder, "Task.cs", "public class Task { }");
+
+ MockEngine mockEngine = new MockEngine
+ {
+ ProjectFileOfTaskNode = null,
+ IsMultiThreadedBuild = true
+ };
+
+ TaskLoggingHelper log = new TaskLoggingHelper(mockEngine, "Task")
+ {
+ TaskResources = AssemblyResources.PrimaryResources,
+ HelpKeywordPrefix = "MSBuild."
+ };
+
+ string taskBody = "";
+
+ // This should fail - we can't resolve relative paths without a project file
+ Should.Throw(() =>
+ {
+ RoslynCodeTaskFactory.TryLoadTaskBody(
+ log,
+ "Task",
+ taskBody,
+ Array.Empty(),
+ mockEngine,
+ out RoslynCodeTaskFactoryTaskInfo _);
+ });
+ }
+ }
+
+ ///
+ /// Verifies that non-existent source files produce appropriate file not found errors.
+ ///
+ [Fact]
+ public void TryLoadTaskBody_NonExistentSourceFile()
+ {
+ using (TestEnvironment env = TestEnvironment.Create())
+ {
+ TransientTestFolder testFolder = env.CreateFolder(createFolder: true);
+ string projectFilePath = Path.Combine(testFolder.Path, "test.proj");
+
+ MockEngine mockEngine = new MockEngine
+ {
+ ProjectFileOfTaskNode = projectFilePath,
+ IsMultiThreadedBuild = true
+ };
+
+ TaskLoggingHelper log = new TaskLoggingHelper(mockEngine, "NonExistentTask")
+ {
+ TaskResources = AssemblyResources.PrimaryResources,
+ HelpKeywordPrefix = "MSBuild."
+ };
+
+ string taskBody = "";
+
+ // Should throw FileNotFoundException
+ Should.Throw(() =>
+ {
+ RoslynCodeTaskFactory.TryLoadTaskBody(
+ log,
+ "NonExistentTask",
+ taskBody,
+ Array.Empty(),
+ mockEngine,
+ out RoslynCodeTaskFactoryTaskInfo _);
+ });
+ }
+ }
}
}
diff --git a/src/Tasks/CodeTaskFactory.cs b/src/Tasks/CodeTaskFactory.cs
index 69c1fca6d84..bb9ffa74d06 100644
--- a/src/Tasks/CodeTaskFactory.cs
+++ b/src/Tasks/CodeTaskFactory.cs
@@ -146,6 +146,11 @@ private static Assembly CurrentDomainOnAssemblyResolve(object sender, ResolveEve
///
private TaskLoggingHelper _log;
+ ///
+ /// The build engine provided during Initialize.
+ ///
+ private IBuildEngine _taskFactoryLoggingHost;
+
///
/// Whether this factory should compile for out-of-process execution.
/// Set during Initialize() based on environment variables or host context.
@@ -185,6 +190,7 @@ public TaskPropertyInfo[] GetTaskParameters()
public bool Initialize(string taskName, IDictionary taskParameters, string taskElementContents, IBuildEngine taskFactoryLoggingHost)
{
_nameOfTask = taskName;
+ _taskFactoryLoggingHost = taskFactoryLoggingHost;
_log = new TaskLoggingHelper(taskFactoryLoggingHost, taskName)
{
TaskResources = AssemblyResources.PrimaryResources,
@@ -776,7 +782,11 @@ private Assembly CompileAssembly()
// If our code is in a separate file, then read it in here
if (_sourcePath != null)
{
- _sourceCode = FileSystems.Default.ReadFileAllText(_sourcePath);
+ bool isMultiThreaded = _taskFactoryLoggingHost is ITaskFactoryBuildParameterProvider hostContext && hostContext.IsMultiThreadedBuild;
+ string projectFilePath = _taskFactoryLoggingHost.ProjectFileOfTaskNode;
+ string projectDirectory = !string.IsNullOrEmpty(projectFilePath) ? Path.GetDirectoryName(projectFilePath) : null;
+ string resolvedPath = TaskFactoryUtilities.ResolveTaskSourceCodePath(_sourcePath, isMultiThreaded, projectDirectory);
+ _sourceCode = FileSystems.Default.ReadFileAllText(resolvedPath);
}
// A fragment is essentially the contents of the execute method (except the final return true/false)
diff --git a/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs b/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs
index c9854f90eab..b8a0932d296 100644
--- a/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs
+++ b/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactory.cs
@@ -169,7 +169,7 @@ public bool Initialize(string taskName, IDictionary pa
_compileForOutOfProcess = TaskFactoryUtilities.ShouldCompileForOutOfProcess(taskFactoryLoggingHost);
// Attempt to parse and extract everything from the
- if (!TryLoadTaskBody(_log, _taskName, taskBody, _parameters, out RoslynCodeTaskFactoryTaskInfo taskInfo))
+ if (!TryLoadTaskBody(_log, _taskName, taskBody, _parameters, taskFactoryLoggingHost, out RoslynCodeTaskFactoryTaskInfo taskInfo))
{
return false;
}
@@ -287,6 +287,7 @@ internal static string GetSourceCode(RoslynCodeTaskFactoryTaskInfo taskInfo, ICo
/// The name of the task.
/// The raw inner XML string of the <UsingTask />> to parse and validate.
/// An containing parameters for the task.
+ /// The build engine context, used to resolve relative Source paths in multithreaded mode.
/// A object that receives the details of the parsed task.
/// true if the task body was successfully parsed, otherwise false.
///
@@ -303,7 +304,7 @@ internal static string GetSourceCode(RoslynCodeTaskFactoryTaskInfo taskInfo, ICo
/// ]]>
///
///
- internal static bool TryLoadTaskBody(TaskLoggingHelper log, string taskName, string taskBody, ICollection parameters, out RoslynCodeTaskFactoryTaskInfo taskInfo)
+ internal static bool TryLoadTaskBody(TaskLoggingHelper log, string taskName, string taskBody, ICollection parameters, IBuildEngine taskFactoryEngineContext, out RoslynCodeTaskFactoryTaskInfo taskInfo)
{
taskInfo = new RoslynCodeTaskFactoryTaskInfo
{
@@ -453,7 +454,15 @@ internal static bool TryLoadTaskBody(TaskLoggingHelper log, string taskName, str
// Instead of using the inner text of the element, read the specified file as source code
taskInfo.CodeType = RoslynCodeTaskFactoryCodeType.Class;
- taskInfo.SourceCode = FileSystems.Default.ReadFileAllText(sourceAttribute.Value.Trim());
+
+ string sourcePath = sourceAttribute.Value.Trim();
+
+ bool isMultiThreaded = taskFactoryEngineContext is ITaskFactoryBuildParameterProvider provider && provider.IsMultiThreadedBuild;
+ string projectFilePath = taskFactoryEngineContext.ProjectFileOfTaskNode;
+ string projectDirectory = !string.IsNullOrEmpty(projectFilePath) ? Path.GetDirectoryName(projectFilePath) : null;
+ string resolvedPath = TaskFactoryUtilities.ResolveTaskSourceCodePath(sourcePath, isMultiThreaded, projectDirectory);
+
+ taskInfo.SourceCode = FileSystems.Default.ReadFileAllText(resolvedPath);
}
if (typeAttribute != null)
diff --git a/src/UnitTests.Shared/MockEngine.cs b/src/UnitTests.Shared/MockEngine.cs
index d978cc3ab22..9b21b43cf90 100644
--- a/src/UnitTests.Shared/MockEngine.cs
+++ b/src/UnitTests.Shared/MockEngine.cs
@@ -198,7 +198,7 @@ public IReadOnlyDictionary GetGlobalProperties()
public bool ContinueOnError => false;
- public string ProjectFileOfTaskNode => String.Empty;
+ public string ProjectFileOfTaskNode { get; set; } = String.Empty;
public int LineNumberOfTaskNode => 0;