Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/Build.UnitTests/BackEnd/HashBasedPipeNaming_Tests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Tests for hash-based pipe naming in NamedPipeUtil and Handshake.ComputeHash.
/// </summary>
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";

Comment thread
JakeRadMSFT marked this conversation as resolved.
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,34 @@ protected IList<NodeContext> GetNodes(
if (nodeReuseRequested)
{
IList<Process> 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<int> pids = NamedPipeUtil.FindNodesByHandshakeHash(handshakeHash);
var processes = new List<Process>(pids.Count);
Comment thread
JakeRadMSFT marked this conversation as resolved.
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<Process>(possibleRunningNodesList);

if (possibleRunningNodesList.Count > 0)
Expand Down Expand Up @@ -719,8 +746,11 @@ private static void ValidateRemotePipeSecurityOnWindows(NamedPipeClientStream no
/// </summary>
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(
Expand Down
8 changes: 7 additions & 1 deletion src/MSBuild/NodeEndpointOutOfProcTaskHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Build.BackEnd;
using Microsoft.Build.Framework;
using Microsoft.Build.Internal;
using Microsoft.Build.Shared;

namespace Microsoft.Build.CommandLine
{
Expand All @@ -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
Expand Down
50 changes: 29 additions & 21 deletions src/Shared/CommunicationsUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Caching computed hash.
/// </summary>
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);

/// <summary>
/// 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.
/// </summary>
public string ComputeHash()
{
Expand All @@ -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);
}

/// <summary>
/// This class contains utility methods for the MSBuild engine.
/// </summary>
Expand Down Expand Up @@ -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();
}
}
}

Expand Down
Loading