File: Coordinator\CoordinatorSettings.cs
Web Access
Project: src\msbuild\src\Framework\Microsoft.Build.Framework.csproj (Microsoft.Build.Framework)
// 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.Security.Cryptography;
using System.Text;
using Microsoft.Build.BackEnd;

#if NET
using Microsoft.Build.Utilities;
#endif

namespace Microsoft.Build.Framework.Coordinator;

/// <summary>
///  Shared settings for coordinator client and server behavior.
/// </summary>
internal sealed record class CoordinatorSettings()
{
    public const string PipeNameBase = "msbuild-coordinator";
    public const int DefaultHeartbeatIntervalMs = 5_000;
    public const int DefaultMissedHeartbeatsThreshold = 3;
    public const int DefaultInitialConnectionTimeoutMs = 200;
    public const int DefaultConnectionTimeoutMs = 5_000;
    public const int DefaultShutdownTimeoutMs = 60_000;
    public const int MaxHeartbeatIntervalMs = 300_000;

    private static string DefaultPipeName => $"{PipeNameBase}-{Environment.UserName}";

    private int? _heartbeatIntervalMs;
    private int? _missedHeartbeatsThreshold;
    private int? _initialConnectionTimeoutMs;
    private int? _connectionTimeoutMs;
    private int? _shutdownTimeoutMs;
    private int? _totalNodeBudget;
    private int? _processId;

    /// <summary>
    ///  Singleton settings instance populated with default values.
    /// </summary>
    public static CoordinatorSettings Default { get; } = new();

    public string PipeName
    {
        get => field ??= NamedPipeUtil.GetPlatformSpecificPipeName(DefaultPipeName);
        init => field = NamedPipeUtil.GetPlatformSpecificPipeName(value);
    }

    public int HeartbeatIntervalMs
    {
        get => _heartbeatIntervalMs ??= DefaultHeartbeatIntervalMs;
        init => _heartbeatIntervalMs = value > 0
            ? Math.Min(value, MaxHeartbeatIntervalMs)
            : DefaultHeartbeatIntervalMs;
    }

    public int MissedHeartbeatsThreshold
    {
        get => _missedHeartbeatsThreshold ??= DefaultMissedHeartbeatsThreshold;
        init => _missedHeartbeatsThreshold = value > 0 ? value : DefaultMissedHeartbeatsThreshold;
    }

    public int TotalNodeBudget
    {
        get => _totalNodeBudget ??= Environment.ProcessorCount;
        init => _totalNodeBudget = value <= 0 ? Environment.ProcessorCount : value;
    }

    public int ShutdownTimeoutMs
    {
        get => _shutdownTimeoutMs ??= DefaultShutdownTimeoutMs;
        init => _shutdownTimeoutMs = value >= 0 ? value : DefaultShutdownTimeoutMs;
    }

    /// <summary>
    ///  The timeout in milliseconds for the initial fast probe to detect an already-running coordinator.
    /// </summary>
    public int InitialConnectionTimeoutMs
    {
        get => _initialConnectionTimeoutMs ??= DefaultInitialConnectionTimeoutMs;
        init => _initialConnectionTimeoutMs = value > 0 ? value : DefaultInitialConnectionTimeoutMs;
    }

    public int ConnectionTimeoutMs
    {
        get => _connectionTimeoutMs ??= DefaultConnectionTimeoutMs;
        init => _connectionTimeoutMs = value > 0 ? value : DefaultConnectionTimeoutMs;
    }

    public int ProcessId
    {
        get => _processId ??= EnvironmentUtilities.CurrentProcessId;
        init => _processId = value > 0 ? value : EnvironmentUtilities.CurrentProcessId;
    }

    /// <summary>
    ///  Computes the heartbeat timeout in milliseconds using long arithmetic to avoid overflow.
    /// </summary>
    public long HeartbeatTimeoutMs => (long)HeartbeatIntervalMs * MissedHeartbeatsThreshold;

    /// <summary>
    ///  The named mutex used by the coordinator server to ensure single-instance execution.
    /// </summary>
    public string ServerMutexName => GetMutexName("server");

    /// <summary>
    ///  The named mutex used by clients to serialize coordinator launch attempts.
    /// </summary>
    public string LaunchMutexName => GetMutexName("launch");

    public static CoordinatorSettings FromEnvironment()
    {
        string? pipeNameOverride = Environment.GetEnvironmentVariable(Constants.PipeNameEnvVarName);
        string pipeName = !string.IsNullOrEmpty(pipeNameOverride)
            ? pipeNameOverride
            : DefaultPipeName;

        return Default with
        {
            PipeName = pipeName,
            HeartbeatIntervalMs = EnvironmentUtilities.GetValueAsInt32OrDefault(
                Constants.HeartbeatIntervalEnvVarName,
                DefaultHeartbeatIntervalMs),
            MissedHeartbeatsThreshold = DefaultMissedHeartbeatsThreshold,
            TotalNodeBudget = EnvironmentUtilities.GetValueAsInt32OrDefault(
                Constants.NodeBudgetEnvVarName,
                Environment.ProcessorCount),
            ShutdownTimeoutMs = EnvironmentUtilities.GetValueAsInt32OrDefault(
                Constants.ShutdownTimeoutEnvVarName,
                DefaultShutdownTimeoutMs),
            ConnectionTimeoutMs = DefaultConnectionTimeoutMs,
            ProcessId = EnvironmentUtilities.CurrentProcessId,
        };
    }

    /// <summary>
    ///  Generates a platform-appropriate mutex name by combining the pipe name with a purpose suffix.
    /// </summary>
    private string GetMutexName(string purpose)
    {
        if (NativeMethods.IsWindows)
        {
            return $"Global\\{PipeName}-{purpose}";
        }

        // Named mutexes on Unix do not accept path-like names (for example '/tmp/...').
        // Hash the pipe name into a stable, compact identifier safe for the runtime.
        string prefix = $"msbuild-coordinator-{purpose}-";

#if NET
        int byteCount = Encoding.UTF8.GetByteCount(PipeName);
        using BufferScope<byte> pipeNameBytes = byteCount <= 256
            ? new(stackalloc byte[byteCount])
            : new(byteCount);

        Encoding.UTF8.GetBytes(PipeName, pipeNameBytes);

        Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
        SHA256.HashData(pipeNameBytes[..byteCount], hash);
#else
        using SHA256 sha256 = SHA256.Create();
        byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(PipeName));
#endif

        // We're being a bit clever here by defining PrefixAndHash as a ref struct on modern .NET
        // since string.Create<TState> is defined with a 'where TState : allows ref struct' constraint.
        return string.Create(prefix.Length + (hash.Length * 2), new PrefixAndHash(prefix, hash), static (span, state) =>
        {
            var (prefix, hash) = state;

            prefix.CopyTo(span);
            span = span[prefix.Length..];

            for (int i = 0; i < hash.Length; i++)
            {
                byte b = hash[i];
                span[0] = HexDigitChar(b / 16);
                span[1] = HexDigitChar(b % 16);
                span = span[2..];
            }
        });

        static char HexDigitChar(int value)
            => (char)(value + (value < 10 ? '0' : 'a' - 10));
    }

#if NET
    private readonly ref struct PrefixAndHash
    {
        public readonly ReadOnlySpan<char> Prefix;
        public readonly ReadOnlySpan<byte> Hash;

        public PrefixAndHash(ReadOnlySpan<char> prefix, ReadOnlySpan<byte> hash)
        {
            Prefix = prefix;
            Hash = hash;
        }

        public void Deconstruct(out ReadOnlySpan<char> prefix, out ReadOnlySpan<byte> hash)
        {
            prefix = Prefix;
            hash = Hash;
        }
    }
#else
    private readonly struct PrefixAndHash(string prefix, byte[] hash)
    {
        public ReadOnlySpan<char> Prefix => prefix;

        public ReadOnlySpan<byte> Hash => hash;

        public void Deconstruct(out ReadOnlySpan<char> prefix, out ReadOnlySpan<byte> hash)
        {
            prefix = Prefix;
            hash = Hash;
        }
    }
#endif
}