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)