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;