diff --git a/src/Build.UnitTests/BackEnd/HashBasedPipeNaming_Tests.cs b/src/Build.UnitTests/BackEnd/HashBasedPipeNaming_Tests.cs new file mode 100644 index 00000000000..488dee7cf5d --- /dev/null +++ b/src/Build.UnitTests/BackEnd/HashBasedPipeNaming_Tests.cs @@ -0,0 +1,179 @@ +// 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.IO; +using Microsoft.Build.Internal; +using Microsoft.Build.Shared; +using Shouldly; +using Xunit; + +#nullable disable + +namespace Microsoft.Build.UnitTests +{ + /// + /// Tests for hash-based pipe naming in NamedPipeUtil and Handshake.ComputeHash. + /// + public class HashBasedPipeNaming_Tests + { + #region ComputeHash Tests + + [Fact] + public void ComputeHash_ReturnsDeterministicValue() + { + var handshake = new Handshake(HandshakeOptions.NodeReuse); + string hash1 = handshake.ComputeHash(); + string hash2 = handshake.ComputeHash(); + + hash1.ShouldNotBeNullOrEmpty(); + hash2.ShouldNotBeNullOrEmpty(); + hash1.ShouldBe(hash2); + } + + [Fact] + public void ComputeHash_SameOptionsSameHash() + { + var h1 = new Handshake(HandshakeOptions.NodeReuse); + var h2 = new Handshake(HandshakeOptions.NodeReuse); + + h1.ComputeHash().ShouldBe(h2.ComputeHash()); + } + + [Fact] + public void ComputeHash_DifferentOptionsYieldDifferentHash() + { + var h1 = new Handshake(HandshakeOptions.NodeReuse); + var h2 = new Handshake(HandshakeOptions.None); + + h1.ComputeHash().ShouldNotBe(h2.ComputeHash()); + } + + [Fact] + public void ComputeHash_NoPaddingOrSlashes() + { + var handshake = new Handshake(HandshakeOptions.NodeReuse); + string hash = handshake.ComputeHash(); + + // Hash should be URL/filename-safe: no / or = characters + hash.ShouldNotContain("/"); + hash.ShouldNotContain("="); + } + + [Fact] + public void ComputeHash_IsCached() + { + var handshake = new Handshake(HandshakeOptions.NodeReuse); + string hash1 = handshake.ComputeHash(); + string hash2 = handshake.ComputeHash(); + + // Should be the exact same object reference (cached) + ReferenceEquals(hash1, hash2).ShouldBeTrue(); + } + + #endregion + + #region GetHashBasedPipeName Tests + + [Fact] + public void GetHashBasedPipeName_ContainsHashAndPid() + { + string hash = "abc123"; + int pid = 42; + string pipeName = NamedPipeUtil.GetHashBasedPipeName(hash, pid); + + pipeName.ShouldContain("MSBuild-abc123-42"); + } + + [Fact] + public void GetHashBasedPipeName_DefaultsToCurrentPid() + { + string hash = "testhash"; + string pipeName = NamedPipeUtil.GetHashBasedPipeName(hash); + + int currentPid = EnvironmentUtilities.CurrentProcessId; + pipeName.ShouldContain($"MSBuild-testhash-{currentPid}"); + } + + [UnixOnlyFact] + public void GetHashBasedPipeName_OnUnix_IsAbsolutePath() + { + string pipeName = NamedPipeUtil.GetHashBasedPipeName("hash", 123); + pipeName.ShouldStartWith("/tmp/"); + } + + #endregion + + #region FindNodesByHandshakeHash Tests + + [WindowsOnlyFact] + public void FindNodesByHandshakeHash_ReturnsEmptyOnWindows() + { + var pids = NamedPipeUtil.FindNodesByHandshakeHash("nonexistent"); + pids.ShouldBeEmpty(); + } + + [UnixOnlyFact] + public void FindNodesByHandshakeHash_FindsMatchingPipeFiles() + { + string testHash = $"test-{Guid.NewGuid():N}"; + + // Create fake pipe files in /tmp + string pipe1 = $"/tmp/MSBuild-{testHash}-1001"; + string pipe2 = $"/tmp/MSBuild-{testHash}-1002"; + string pipeOther = $"/tmp/MSBuild-otherhash-9999"; + + try + { + File.WriteAllText(pipe1, ""); + File.WriteAllText(pipe2, ""); + File.WriteAllText(pipeOther, ""); + + var pids = NamedPipeUtil.FindNodesByHandshakeHash(testHash); + + pids.ShouldContain(1001); + pids.ShouldContain(1002); + pids.ShouldNotContain(9999); + } + finally + { + File.Delete(pipe1); + File.Delete(pipe2); + File.Delete(pipeOther); + } + } + + [UnixOnlyFact] + public void FindNodesByHandshakeHash_IgnoresMalformedFileNames() + { + string testHash = $"test-{Guid.NewGuid():N}"; + string pipeGood = $"/tmp/MSBuild-{testHash}-5555"; + string pipeBad = $"/tmp/MSBuild-{testHash}-notanumber"; + + try + { + File.WriteAllText(pipeGood, ""); + File.WriteAllText(pipeBad, ""); + + var pids = NamedPipeUtil.FindNodesByHandshakeHash(testHash); + + pids.ShouldContain(5555); + pids.Count.ShouldBe(1); + } + finally + { + File.Delete(pipeGood); + File.Delete(pipeBad); + } + } + + [UnixOnlyFact] + public void FindNodesByHandshakeHash_ReturnsEmptyWhenNoMatches() + { + var pids = NamedPipeUtil.FindNodesByHandshakeHash($"nopipes-{Guid.NewGuid():N}"); + pids.ShouldBeEmpty(); + } + + #endregion + } +} diff --git a/src/Build/BackEnd/Components/Communications/NodeEndpointOutOfProc.cs b/src/Build/BackEnd/Components/Communications/NodeEndpointOutOfProc.cs index 437d741d41b..da46d0fb26f 100644 --- a/src/Build/BackEnd/Components/Communications/NodeEndpointOutOfProc.cs +++ b/src/Build/BackEnd/Components/Communications/NodeEndpointOutOfProc.cs @@ -26,7 +26,13 @@ internal NodeEndpointOutOfProc(bool enableReuse, bool lowPriority) _enableReuse = enableReuse; LowPriority = lowPriority; - InternalConstruct(); + // Use hash-based pipe name for fast discovery on Unix. + // Format: MSBuild-{hash}-{pid} — allows schedulers to find compatible nodes + // by listing /tmp/MSBuild-{hash}-* instead of probing all dotnet processes. + string? pipeName = NativeMethodsShared.IsUnixLike + ? NamedPipeUtil.GetHashBasedPipeName(GetHandshake().ComputeHash()) + : null; // Windows: keep legacy MSBuild{PID} naming + InternalConstruct(pipeName); } /// diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 9f701208dde..58cabc06fa8 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -255,7 +255,34 @@ protected IList GetNodes( if (nodeReuseRequested) { IList possibleRunningNodesList; - (expectedProcessName, possibleRunningNodesList) = GetPossibleRunningNodes(msbuildLocation, expectedNodeMode); + + // On Unix, use hash-based pipe file listing for O(1) discovery of compatible nodes + // instead of enumerating all dotnet processes and probing each one. + if (NativeMethodsShared.IsUnixLike) + { + string handshakeHash = nodeLaunchData.Handshake.ComputeHash(); + IList pids = NamedPipeUtil.FindNodesByHandshakeHash(handshakeHash); + var processes = new List(pids.Count); + foreach (int pid in pids) + { + try + { + processes.Add(Process.GetProcessById(pid)); + } + catch + { + // Process may have exited between pipe file listing and this call. + } + } + + expectedProcessName = "dotnet"; + possibleRunningNodesList = processes; + } + else + { + (expectedProcessName, possibleRunningNodesList) = GetPossibleRunningNodes(msbuildLocation, expectedNodeMode); + } + possibleRunningNodes = new ConcurrentQueue(possibleRunningNodesList); if (possibleRunningNodesList.Count > 0) @@ -719,8 +746,11 @@ private static void ValidateRemotePipeSecurityOnWindows(NamedPipeClientStream no /// private Stream TryConnectToProcess(int nodeProcessId, int timeout, Handshake handshake, out HandshakeResult result) { - // Try and connect to the process. - string pipeName = NamedPipeUtil.GetPlatformSpecificPipeName(nodeProcessId); + // On Unix, nodes create pipes with hash-based names for fast discovery. + // On Windows, keep legacy MSBuild{PID} naming. + string pipeName = NativeMethodsShared.IsUnixLike + ? NamedPipeUtil.GetHashBasedPipeName(handshake.ComputeHash(), nodeProcessId) + : NamedPipeUtil.GetPlatformSpecificPipeName(nodeProcessId); #pragma warning disable SA1111, SA1009 // Closing parenthesis should be on line of last parameter NamedPipeClientStream nodeStream = new NamedPipeClientStream( diff --git a/src/MSBuild/NodeEndpointOutOfProcTaskHost.cs b/src/MSBuild/NodeEndpointOutOfProcTaskHost.cs index 4967da62e1b..0a802566d78 100644 --- a/src/MSBuild/NodeEndpointOutOfProcTaskHost.cs +++ b/src/MSBuild/NodeEndpointOutOfProcTaskHost.cs @@ -4,6 +4,7 @@ using Microsoft.Build.BackEnd; using Microsoft.Build.Framework; using Microsoft.Build.Internal; +using Microsoft.Build.Shared; namespace Microsoft.Build.CommandLine { @@ -24,7 +25,12 @@ internal class NodeEndpointOutOfProcTaskHost : NodeEndpointOutOfProcBase internal NodeEndpointOutOfProcTaskHost(bool nodeReuse, byte parentPacketVersion) { _nodeReuse = nodeReuse; - InternalConstruct(pipeName: null, parentPacketVersion); + + // Use hash-based pipe name on Unix to match TryConnectToProcess. + string? pipeName = NativeMethodsShared.IsUnixLike + ? NamedPipeUtil.GetHashBasedPipeName(GetHandshake().ComputeHash()) + : null; + InternalConstruct(pipeName, parentPacketVersion); } #endregion // Constructors and Factories diff --git a/src/Shared/CommunicationsUtilities.cs b/src/Shared/CommunicationsUtilities.cs index 8c4f6a50167..c8391ba2464 100644 --- a/src/Shared/CommunicationsUtilities.cs +++ b/src/Shared/CommunicationsUtilities.cs @@ -340,35 +340,15 @@ private static HandshakeComponents CreateStandardComponents(int options, int sal public virtual string GetKey() => $"{_handshakeComponents.Options} {_handshakeComponents.Salt} {_handshakeComponents.FileVersionMajor} {_handshakeComponents.FileVersionMinor} {_handshakeComponents.FileVersionBuild} {_handshakeComponents.FileVersionPrivate} {_handshakeComponents.SessionId}".ToString(CultureInfo.InvariantCulture); public virtual byte? ExpectedVersionInFirstByte => CommunicationsUtilities.handshakeVersion; - } - internal sealed class ServerNodeHandshake : Handshake - { /// /// Caching computed hash. /// private string _computedHash = null; - public override byte? ExpectedVersionInFirstByte => null; - - internal ServerNodeHandshake(HandshakeOptions nodeType) - : base(nodeType, includeSessionId: false, toolsDirectory: null) - { - } - - public override HandshakeComponents RetrieveHandshakeComponents() => new HandshakeComponents( - CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.Options), - CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.Salt), - CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionMajor), - CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionMinor), - CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionBuild), - CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionPrivate)); - - public override string GetKey() => $"{_handshakeComponents.Options} {_handshakeComponents.Salt} {_handshakeComponents.FileVersionMajor} {_handshakeComponents.FileVersionMinor} {_handshakeComponents.FileVersionBuild} {_handshakeComponents.FileVersionPrivate}" - .ToString(CultureInfo.InvariantCulture); - /// /// Computes Handshake stable hash string representing whole state of handshake. + /// Used for hash-based pipe naming to enable fast node discovery without trial-and-error probing. /// public string ComputeHash() { @@ -391,6 +371,27 @@ public string ComputeHash() } } + internal sealed class ServerNodeHandshake : Handshake + { + public override byte? ExpectedVersionInFirstByte => null; + + internal ServerNodeHandshake(HandshakeOptions nodeType) + : base(nodeType, includeSessionId: false, toolsDirectory: null) + { + } + + public override HandshakeComponents RetrieveHandshakeComponents() => new HandshakeComponents( + CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.Options), + CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.Salt), + CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionMajor), + CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionMinor), + CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionBuild), + CommunicationsUtilities.AvoidEndOfHandshakeSignal(_handshakeComponents.FileVersionPrivate)); + + public override string GetKey() => $"{_handshakeComponents.Options} {_handshakeComponents.Salt} {_handshakeComponents.FileVersionMajor} {_handshakeComponents.FileVersionMinor} {_handshakeComponents.FileVersionBuild} {_handshakeComponents.FileVersionPrivate}" + .ToString(CultureInfo.InvariantCulture); + } + /// /// This class contains utility methods for the MSBuild engine. /// @@ -1006,6 +1007,13 @@ internal static HandshakeOptions GetHandshakeOptions( } architectureFlagToSet = taskHostParameters.Architecture; + + // Resolve "*" (any) to the current architecture so both parent and child + // compute identical HandshakeOptions and hash-based pipe names. + if (string.Equals(architectureFlagToSet, XMakeAttributes.MSBuildArchitectureValues.any, StringComparison.OrdinalIgnoreCase)) + { + architectureFlagToSet = XMakeAttributes.GetCurrentMSBuildArchitecture(); + } } } diff --git a/src/Shared/NamedPipeUtil.cs b/src/Shared/NamedPipeUtil.cs index 0b85b05bacd..28330170c06 100644 --- a/src/Shared/NamedPipeUtil.cs +++ b/src/Shared/NamedPipeUtil.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.IO; using Microsoft.Build.Internal; @@ -20,6 +21,57 @@ internal static string GetPlatformSpecificPipeName(int? processId = null) return GetPlatformSpecificPipeName(pipeName); } + /// + /// Returns a pipe name that encodes both the handshake hash and the process ID. + /// Format: MSBuild-{hash}-{pid} + /// This allows discovery of compatible nodes by listing pipes matching the hash prefix, + /// eliminating trial-and-error probing of all dotnet processes. + /// + internal static string GetHashBasedPipeName(string handshakeHash, int? processId = null) + { + processId ??= EnvironmentUtilities.CurrentProcessId; + string pipeName = $"MSBuild-{handshakeHash}-{processId}"; + return GetPlatformSpecificPipeName(pipeName); + } + + /// + /// Finds pipe files matching a handshake hash and extracts their PIDs. + /// Only works on Unix where pipes are files in /tmp. + /// + internal static IList FindNodesByHandshakeHash(string handshakeHash) + { + var pids = new List(); + // GetPlatformSpecificPipeName returns full paths like /tmp/MSBuild-{hash}-{pid} + // on Unix, and .NET does NOT add CoreFxPipe_ prefix for absolute paths. + string prefix = $"MSBuild-{handshakeHash}-"; + string? pipeDir = NativeMethodsShared.IsUnixLike ? "/tmp" : null; + + if (pipeDir == null) + { + // On Windows, named pipes aren't files — fall back to legacy discovery. + return pids; + } + + try + { + foreach (string file in System.IO.Directory.EnumerateFiles(pipeDir, $"MSBuild-{handshakeHash}-*")) + { + string fileName = Path.GetFileName(file); + if (fileName.StartsWith(prefix) && int.TryParse(fileName.Substring(prefix.Length), out int pid)) + { + pids.Add(pid); + } + } + } + catch + { + // Directory enumeration can fail (e.g. permissions); return empty + // so the caller falls through to launching new nodes. + } + + return pids; + } + internal static string GetPlatformSpecificPipeName(string pipeName) { if (NativeMethodsShared.IsUnixLike)