diff --git a/src/Build.UnitTests/BackEnd/StringArrayWithNullsTask.cs b/src/Build.UnitTests/BackEnd/StringArrayWithNullsTask.cs new file mode 100644 index 00000000000..657ef618657 --- /dev/null +++ b/src/Build.UnitTests/BackEnd/StringArrayWithNullsTask.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +#nullable disable + +namespace Microsoft.Build.UnitTests.BackEnd +{ + /// + /// A task that returns a string array containing null elements. + /// Used to test the fix for https://github.com/dotnet/msbuild/issues/13174 + /// + public class StringArrayWithNullsTask : Task + { + [Output] + public string[] OutputArray { get; set; } + + [Output] + public int Pid { get; set; } + + public override bool Execute() + { + // Return an array with nulls - this pattern occurs in real tasks like GenerateGlobalUsings + OutputArray = new string[] { "first", null, "third", null, "fifth" }; + Pid = Process.GetCurrentProcess().Id; + return true; + } + } +} diff --git a/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs b/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs index 9b80c023c94..22178bbe618 100644 --- a/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs +++ b/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs @@ -363,5 +363,45 @@ public void VariousParameterTypesCanBeTransmittedToAndReceivedFromTaskHost() projectInstance.GetPropertyValue("CustomStructOutput").ShouldBe(TaskBuilderTestTask.s_customStruct.ToString(CultureInfo.InvariantCulture)); projectInstance.GetPropertyValue("EnumOutput").ShouldBe(TargetBuiltReason.BeforeTargets.ToString()); } + + /// + /// Verifies that a task returning a string[] with null elements does not crash + /// when executed via TaskHostFactory. This is a regression test for + /// https://github.com/dotnet/msbuild/issues/13174 + /// + [Fact] + public void StringArrayWithNullsDoesNotCrashTaskHost() + { + using TestEnvironment env = TestEnvironment.Create(); + + string projectContents = $@" + + + + <{typeof(StringArrayWithNullsTask).Name}> + + + + +"; + + TransientTestFile project = env.CreateFile("testProject.csproj", projectContents); + ProjectInstance projectInstance = new(project.Path); + BuildManager buildManager = BuildManager.DefaultBuildManager; + BuildResult buildResult = buildManager.Build(new BuildParameters(), new BuildRequestData(projectInstance, targetsToBuild: new[] { "TestTarget" })); + + // The build should succeed - nulls should be filtered, not cause a crash + buildResult.OverallResult.ShouldBe(BuildResultCode.Success); + + // Verify task ran out-of-process (TaskHostFactory should force this) + string taskPidStr = projectInstance.GetPropertyValue("TaskPid"); + taskPidStr.ShouldNotBeNullOrEmpty(); + int.TryParse(taskPidStr, out int taskPid).ShouldBeTrue(); + Process.GetCurrentProcess().Id.ShouldNotBe(taskPid, "Task should have run in a separate TaskHost process"); + + // Verify output items - nulls should be filtered out, leaving 3 items + var outputItems = projectInstance.GetItems("OutputItems"); + outputItems.Count.ShouldBe(3, "Null elements should be filtered from the string array"); + } } } diff --git a/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs b/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs index ae77fb43e03..0ad4fc8fe6b 100644 --- a/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs +++ b/src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs @@ -419,7 +419,16 @@ private OutOfProcTaskHostTaskResult InstantiateAndExecuteTask( { try { - finalParameterValues[value.Name] = value.GetValue(wrappedTask, null); + object outputValue = value.GetValue(wrappedTask, null); + + // Filter null elements from string[] outputs to avoid crash. + // See https://github.com/dotnet/msbuild/issues/13174 + if (outputValue is string[] stringArray) + { + outputValue = FilterNullsFromStringArray(stringArray, value.Name); + } + + finalParameterValues[value.Name] = outputValue; } catch (Exception e) when (!ExceptionHandling.IsCriticalException(e)) { @@ -451,5 +460,46 @@ private void LogErrorDelegate(string taskLocation, int taskLine, int taskColumn, null, taskName)); } + + /// + /// Filters null elements from a string[] task output. + /// See https://github.com/dotnet/msbuild/issues/13174 + /// + private string[] FilterNullsFromStringArray(string[] stringArray, string parameterName) + { + // Count nulls + int nullCount = 0; + foreach (string s in stringArray) + { + if (s == null) + { + nullCount++; + } + } + + if (nullCount == 0) + { + return stringArray; + } + + // Filter nulls and log + string[] filtered = new string[stringArray.Length - nullCount]; + int j = 0; + foreach (string s in stringArray) + { + if (s != null) + { + filtered[j++] = s; + } + } + + buildEngine.LogMessageEvent(new BuildMessageEventArgs( + message: ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("TaskHostAcquired_NullsFiltered", parameterName, nullCount), + helpKeyword: null, + senderName: taskName, + importance: MessageImportance.Normal)); + + return filtered; + } } } diff --git a/src/MSBuild/Resources/Strings.resx b/src/MSBuild/Resources/Strings.resx index 6e75cc0db7e..797d284b563 100644 --- a/src/MSBuild/Resources/Strings.resx +++ b/src/MSBuild/Resources/Strings.resx @@ -1719,6 +1719,13 @@ + + + Task output parameter "{0}" contained {1} null element(s) which have been filtered out. + LOCALIZATION: {0} is the name of the task output parameter, {1} is the count of null elements filtered. + + +