File: BackEnd\CommunicationsUtilities.cs
Web Access
Project: ..\..\..\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.Collections;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipes;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Threading;
using Microsoft.Build.BackEnd;
using Microsoft.Build.Framework;
using Microsoft.Build.Framework.Utilities;
using Microsoft.Build.Shared;
using Microsoft.NET.StringTools;
 
namespace Microsoft.Build.Internal;
 
/// <summary>
///  This class contains utility methods for the MSBuild engine.
/// </summary>
internal static class CommunicationsUtilities
{
    /// <summary>
    ///  Indicates to the NodeEndpoint that all the various parts of the Handshake have been sent.
    /// </summary>
    private const int EndOfHandshakeSignal = -0x2a2a2a2a;
 
    /// <summary>
    ///  The version of the handshake. This should be updated each time the handshake structure is altered.
    /// </summary>
    internal const byte handshakeVersion = 0x01;
 
    /// <summary>
    ///  The timeout to connect to a node.
    /// </summary>
    private const int DefaultNodeConnectionTimeout = 900 * 1000; // 15 minutes; enough time that a dev will typically do another build in this time
 
    /// <summary>
    ///  Whether to trace communications.
    /// </summary>
    private static readonly bool s_trace = Traits.Instance.DebugNodeCommunication;
 
    /// <summary>
    ///  Lock trace to ensure we are logging in serial fashion.
    /// </summary>
    private static readonly LockType s_traceLock = new LockType();
 
    /// <summary>
    ///  Place to dump trace.
    /// </summary>
    private static string? s_debugDumpPath;
 
    /// <summary>
    ///  Ticks at last time logged.
    /// </summary>
    private static long s_lastLoggedTicks = DateTime.UtcNow.Ticks;
 
    /// <summary>
    ///  On Windows, environment variables should be case-insensitive;
    ///  on Unix-like systems, they should be case-sensitive, but this might be a breaking change in an edge case.
    ///  https://github.com/dotnet/msbuild/issues/12858.
    /// </summary>
    internal static StringComparer EnvironmentVariableComparer => StringComparer.OrdinalIgnoreCase;
 
    /// <summary>
    ///  Gets the node connection timeout.
    /// </summary>
    internal static int NodeConnectionTimeout
        => EnvironmentUtilities.GetValueAsInt32OrDefault("MSBUILDNODECONNECTIONTIMEOUT", DefaultNodeConnectionTimeout);
 
    /// <summary>
    ///  A set of environment variables cached from the last time we called GetEnvironmentVariables.
    ///  Used to avoid allocations if the environment has not changed.
    /// </summary>
    private static EnvironmentState? s_environmentState;
 
    /// <summary>
    ///  Get environment block.
    /// </summary>
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    [System.Runtime.Versioning.SupportedOSPlatform("windows")]
    private static extern unsafe char* GetEnvironmentStrings();
 
    /// <summary>
    ///  Free environment block.
    /// </summary>
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    [System.Runtime.Versioning.SupportedOSPlatform("windows")]
    private static extern unsafe bool FreeEnvironmentStrings(char* pStrings);
 
#if NETFRAMEWORK
    /// <summary>
    ///  Set environment variable P/Invoke.
    /// </summary>
    [DllImport("kernel32.dll", EntryPoint = "SetEnvironmentVariable", SetLastError = true, CharSet = CharSet.Unicode)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool SetEnvironmentVariableNative(string name, string? value);
 
    /// <summary>
    ///  Sets an environment variable using P/Invoke to workaround the .NET Framework BCL implementation.
    /// </summary>
    /// <remarks>
    ///  .NET Framework implementation of SetEnvironmentVariable checks the length of the value and throws an exception if
    ///  it's greater than or equal to 32,767 characters. This limitation does not exist on modern Windows or .NET.
    /// </remarks>
    internal static void SetEnvironmentVariable(string name, string? value)
    {
        if (!SetEnvironmentVariableNative(name, value))
        {
            throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error());
        }
    }
#endif
 
    /// <summary>
    ///  A container to atomically swap a cached set of environment variables and the block string used to create it.
    ///  The environment block property will only be set on Windows, since on Unix we need to directly call
    ///  Environment.GetEnvironmentVariables().
    /// </summary>
    private sealed record class EnvironmentState(
        FrozenDictionary<string, string> EnvironmentVariables,
        ReadOnlyMemory<char> EnvironmentBlock = default);
 
    /// <summary>
    ///  Returns key value pairs of environment variables in a new dictionary
    ///  with a case-insensitive key comparer.
    /// </summary>
    /// <remarks>
    ///  Copied from the BCL implementation to eliminate some expensive security asserts on .NET Framework.
    /// </remarks>
    [System.Runtime.Versioning.SupportedOSPlatform("windows")]
    private static FrozenDictionary<string, string> GetEnvironmentVariablesWindows()
    {
        // The FrameworkDebugUtils static constructor can set the MSBUILDDEBUGPATH environment variable to propagate the debug path to out of proc nodes.
        // Need to ensure that constructor is called before this method returns in order to capture its env var write.
        // Otherwise the env var is not captured and thus gets deleted when RequestBuilder resets the environment based on the cached results of this method.
        FrameworkErrorUtilities.VerifyThrowInternalNull(FrameworkDebugUtils.ProcessInfoString, nameof(FrameworkDebugUtils.DebugPath));
 
        unsafe
        {
            char* pEnvironmentBlock = null;
 
            try
            {
                pEnvironmentBlock = GetEnvironmentStrings();
                if (pEnvironmentBlock == null)
                {
                    throw new OutOfMemoryException();
                }
 
                // Search for terminating \0\0 (two unicode \0's).
                char* pEnvironmentBlockEnd = pEnvironmentBlock;
                while (!(*pEnvironmentBlockEnd == '\0' && *(pEnvironmentBlockEnd + 1) == '\0'))
                {
                    pEnvironmentBlockEnd++;
                }
                long stringBlockLength = pEnvironmentBlockEnd - pEnvironmentBlock;
 
                // Avoid allocating any objects if the environment still matches the last state.
                // We speed this up by comparing the full block instead of individual key-value pairs.
                ReadOnlySpan<char> stringBlock = new(pEnvironmentBlock, (int)stringBlockLength);
 
                if (s_environmentState is { } lastState && lastState.EnvironmentBlock.Span.SequenceEqual(stringBlock))
                {
                    return lastState.EnvironmentVariables;
                }
 
                Dictionary<string, string> table = new(200, EnvironmentVariableComparer); // Razzle has 150 environment variables
 
                // Copy strings out, parsing into pairs and inserting into the table.
                // The first few environment variable entries start with an '='!
                // The current working directory of every drive (except for those drives
                // you haven't cd'ed into in your DOS window) are stored in the
                // environment block (as =C:=pwd) and the program's exit code is
                // as well (=ExitCode=00000000)  Skip all that start with =.
                // Read docs about Environment Blocks on MSDN's CreateProcess page.
 
                // Format for GetEnvironmentStrings is:
                // (=HiddenVar=value\0 | Variable=value\0)* \0
                // See the description of Environment Blocks in MSDN's
                // CreateProcess page (null-terminated array of null-terminated strings).
                // Note the =HiddenVar's aren't always at the beginning.
                for (int i = 0; i < stringBlockLength; i++)
                {
                    int startKey = i;
 
                    // Skip to key
                    // On some old OS, the environment block can be corrupted.
                    // Some lines will not have '=', so we need to check for '\0'.
                    while (*(pEnvironmentBlock + i) is not ('=' or '\0'))
                    {
                        i++;
                    }
 
                    if (*(pEnvironmentBlock + i) == '\0')
                    {
                        continue;
                    }
 
                    // Skip over environment variables starting with '='
                    if (i - startKey == 0)
                    {
                        while (*(pEnvironmentBlock + i) != 0)
                        {
                            i++;
                        }
 
                        continue;
                    }
 
                    string key = Strings.WeakIntern(new ReadOnlySpan<char>(pEnvironmentBlock + startKey, length: i - startKey));
 
                    i++;
 
                    // skip over '='
                    int startValue = i;
 
                    while (*(pEnvironmentBlock + i) != 0)
                    {
                        // Read to end of this entry
                        i++;
                    }
 
                    string value = Strings.WeakIntern(new ReadOnlySpan<char>(pEnvironmentBlock + startValue, length: i - startValue));
 
                    // skip over 0 handled by for loop's i++
                    table[key] = value;
                }
 
                // Update with the current state.
                EnvironmentState currentState =
                    new(table.ToFrozenDictionary(EnvironmentVariableComparer), stringBlock.ToArray());
                s_environmentState = currentState;
                return currentState.EnvironmentVariables;
            }
            finally
            {
                if (pEnvironmentBlock != null)
                {
                    FreeEnvironmentStrings(pEnvironmentBlock);
                }
            }
        }
    }
 
#if NET
    /// <summary>
    ///  Sets an environment variable using <see cref="Environment.SetEnvironmentVariable(string,string)" />.
    /// </summary>
    internal static void SetEnvironmentVariable(string name, string? value)
        => Environment.SetEnvironmentVariable(name, value);
#endif
 
    /// <summary>
    ///  <para>
    ///   Returns key value pairs of environment variables in a read-only dictionary
    ///   with a case-insensitive key comparer.
    ///  </para>
    ///  <para>
    ///   If the environment variables have not changed since the last time
    ///   this method was called, the same dictionary instance will be returned.
    ///  </para>
    /// </summary>
    internal static FrozenDictionary<string, string> GetEnvironmentVariables()
    {
        // Always call the native method on Windows, as we'll be able to avoid the internal
        // string and Hashtable allocations caused by Environment.GetEnvironmentVariables().
        if (NativeMethods.IsWindows)
        {
            return GetEnvironmentVariablesWindows();
        }
 
        IDictionary vars = Environment.GetEnvironmentVariables();
 
        // Directly use the enumerator since Current will box DictionaryEntry.
        IDictionaryEnumerator enumerator = vars.GetEnumerator();
 
        // If every key-value pair matches the last state, return a cached dictionary.
        if (s_environmentState?.EnvironmentVariables is { } lastEnvironmentVariables &&
            vars.Count == lastEnvironmentVariables.Count)
        {
            bool sameState = true;
 
            while (enumerator.MoveNext() && sameState)
            {
                DictionaryEntry entry = enumerator.Entry;
                if (!lastEnvironmentVariables.TryGetValue((string)entry.Key, out string? value)
                    || !string.Equals((string?)entry.Value, value, StringComparison.Ordinal))
                {
                    sameState = false;
                    break;
                }
            }
 
            if (sameState)
            {
                return lastEnvironmentVariables;
            }
        }
 
        // Otherwise, allocate and update with the current state.
        Dictionary<string, string> table = new(vars.Count, EnvironmentVariableComparer);
 
        enumerator.Reset();
        while (enumerator.MoveNext())
        {
            DictionaryEntry entry = enumerator.Entry;
            string key = Strings.WeakIntern((string)entry.Key);
            string value = Strings.WeakIntern((string?)entry.Value ?? string.Empty);
            table[key] = value;
        }
 
        EnvironmentState newState = new(table.ToFrozenDictionary(EnvironmentVariableComparer));
        s_environmentState = newState;
 
        return newState.EnvironmentVariables;
    }
 
    /// <summary>
    ///  Updates the environment to match the provided dictionary.
    /// </summary>
    internal static void SetEnvironment(IDictionary<string, string> newEnvironment)
    {
        if (newEnvironment != null)
        {
            // First, delete all no longer set variables
            FrozenDictionary<string, string> currentEnvironment = GetEnvironmentVariables();
 
            foreach (var (key, value) in currentEnvironment)
            {
                if (!newEnvironment.ContainsKey(key))
                {
                    SetEnvironmentVariable(key, value: null);
                }
            }
 
            // Then, make sure the new ones have their new values.
            foreach (var (key, value) in newEnvironment)
            {
                if (!currentEnvironment.TryGetValue(key, out string? currentValue) || currentValue != value)
                {
                    SetEnvironmentVariable(key, value);
                }
            }
        }
    }
 
    /// <summary>
    ///  Indicate to the client that all elements of the Handshake have been sent.
    /// </summary>
    internal static void WriteEndOfHandshakeSignal(this PipeStream stream)
        => stream.WriteIntForHandshake(EndOfHandshakeSignal);
 
    /// <summary>
    ///  Extension method to write a series of bytes to a stream.
    /// </summary>
    internal static void WriteIntForHandshake(this PipeStream stream, int value)
    {
        byte[] bytes = BitConverter.GetBytes(value);
 
        // We want to read the long and send it from left to right (this means big endian)
        // if we are little endian we need to reverse the array to keep the left to right reading
        if (BitConverter.IsLittleEndian)
        {
            Array.Reverse(bytes);
        }
 
        FrameworkErrorUtilities.VerifyThrow(bytes.Length == 4, "Int should be 4 bytes");
 
        stream.Write(bytes, 0, bytes.Length);
    }
 
    internal static bool TryReadEndOfHandshakeSignal(
        this PipeStream stream,
        bool isProvider,
#if NET
        int timeout,
#endif
        out HandshakeResult result)
    {
        // Accept only the first byte of the EndOfHandshakeSignal
        if (TryReadIntForHandshake(
            stream,
            byteToAccept: null,
#if NET
            timeout,
#endif
            out HandshakeResult innerResult))
        {
            byte negotiatedPacketVersion = 1;
 
            if (innerResult.Value != EndOfHandshakeSignal)
            {
                // If the received handshake part is not PacketVersionFromChildMarker it means we communicate with the host that does not support packet version negotiation.
                // Fallback to the old communication validation pattern.
                if (innerResult.Value != Handshake.PacketVersionFromChildMarker)
                {
                    result = CreateVersionMismatchResult(isProvider, innerResult.Value);
                    return false;
                }
 
                // We detected packet version marker, now let's read actual PacketVersion
                if (!TryReadIntForHandshake(
                    stream,
                    byteToAccept: null,
#if NET
                    timeout,
#endif
                    out HandshakeResult versionResult))
                {
                    result = versionResult;
                    return false;
                }
 
                byte childVersion = (byte)versionResult.Value;
                negotiatedPacketVersion = NodePacketTypeExtensions.GetNegotiatedPacketVersion(childVersion);
                Trace($"Node PacketVersion: {childVersion}, Local: {NodePacketTypeExtensions.PacketVersion}, Negotiated: {negotiatedPacketVersion}");
 
                if (!TryReadIntForHandshake(
                    stream,
                    byteToAccept: null,
#if NET
                    timeout,
#endif
                    out innerResult))
                {
                    result = innerResult;
                    return false;
                }
 
                if (innerResult.Value != EndOfHandshakeSignal)
                {
                    result = CreateVersionMismatchResult(isProvider, innerResult.Value);
                    return false;
                }
            }
 
            result = HandshakeResult.Success(0, negotiatedPacketVersion);
            return true;
        }
        else
        {
            result = innerResult;
            return false;
        }
    }
 
    private static HandshakeResult CreateVersionMismatchResult(bool isProvider, int receivedValue)
    {
        var errorMessage = isProvider
            ? $"Handshake failed on part {receivedValue}. Probably the client is a different MSBuild build."
            : $"Expected end of handshake signal but received {receivedValue}. Probably the host is a different MSBuild build.";
 
        Trace(errorMessage);
 
        return HandshakeResult.Failure(HandshakeStatus.VersionMismatch, errorMessage);
    }
 
    /// <summary>
    /// Extension method to read a series of bytes from a stream.
    /// If specified, leading byte matches one in the supplied array if any, returns rejection byte and throws IOException.
    /// </summary>
    internal static bool TryReadIntForHandshake(
        this PipeStream stream,
        byte? byteToAccept,
#if NET
        int timeout,
#endif
        out HandshakeResult result)
    {
        byte[] bytes = new byte[4];
 
#if NET
        if (!NativeMethods.IsWindows)
        {
            // Enforce a minimum timeout because the timeout passed to Connect() just before
            // calling this method does not apply on UNIX domain socket-based
            // implementations of PipeStream.
            // https://github.com/dotnet/corefx/issues/28791
            timeout = Math.Max(timeout, 50);
 
            // A legacy MSBuild.exe won't try to connect to MSBuild running
            // in a dotnet host process, so we can read the bytes simply.
            var readTask = stream.ReadAsync(bytes, 0, bytes.Length);
 
            // Manual timeout here because the timeout passed to Connect() just before
            // calling this method does not apply on UNIX domain socket-based
            // implementations of PipeStream.
            // https://github.com/dotnet/corefx/issues/28791
            if (!readTask.Wait(timeout))
            {
                result = HandshakeResult.Failure(HandshakeStatus.Timeout, $"Did not receive return handshake in {timeout}ms");
                return false;
            }
 
            readTask.GetAwaiter().GetResult();
        }
        else
#endif
        {
            int bytesRead = stream.Read(bytes, 0, bytes.Length);
 
            // Abort for connection attempts from ancient MSBuild.exes
            if (byteToAccept != null && bytesRead > 0 && byteToAccept != bytes[0])
            {
                stream.WriteIntForHandshake(0x0F0F0F0F);
                stream.WriteIntForHandshake(0x0F0F0F0F);
                result = HandshakeResult.Failure(HandshakeStatus.OldMSBuild, $"Client: rejected old host. Received byte {bytes[0]} instead of {byteToAccept}.");
                return false;
            }
 
            if (bytesRead != bytes.Length)
            {
                // We've unexpectly reached end of stream.
                // We are now in a bad state, disconnect on our end
                result = HandshakeResult.Failure(HandshakeStatus.UnexpectedEndOfStream, "Unexpected end of stream while reading for handshake");
 
                return false;
            }
        }
 
        try
        {
            // We want to read the long and send it from left to right (this means big endian)
            // If we are little endian the stream has already been reversed by the sender, we need to reverse it again to get the original number
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(bytes);
            }
 
            result = HandshakeResult.Success(BitConverter.ToInt32(bytes, 0 /* start index */));
        }
        catch (ArgumentException ex)
        {
            result = HandshakeResult.Failure(HandshakeStatus.EndiannessMismatch, $"Failed to convert the handshake to big-endian. {ex.Message}");
            return false;
        }
 
        return true;
    }
 
    /// <summary>
    ///  Given the appropriate information, return the equivalent HandshakeOptions.
    /// </summary>
    internal static HandshakeOptions GetHandshakeOptions(
        bool taskHost,
        TaskHostParameters taskHostParameters,
        string? architectureFlagToSet = null,
        bool nodeReuse = false,
        bool lowPriority = false)
    {
        HandshakeOptions context = taskHost ? HandshakeOptions.TaskHost : HandshakeOptions.None;
 
        int clrVersion = 0;
 
        // We don't know about the TaskHost.
        if (taskHost)
        {
            // No parameters given, default to current
            if (taskHostParameters.IsEmpty)
            {
                clrVersion = typeof(bool).Assembly.GetName().Version!.Major;
                architectureFlagToSet = XMakeAttributes.GetCurrentMSBuildArchitecture();
            }
            else // Figure out flags based on parameters given
            {
                FrameworkErrorUtilities.VerifyThrow(taskHostParameters.Runtime != null, "Should always have an explicit runtime when we call this method.");
                FrameworkErrorUtilities.VerifyThrow(taskHostParameters.Architecture != null, "Should always have an explicit architecture when we call this method.");
 
                if (taskHostParameters.Runtime.Equals(XMakeAttributes.MSBuildRuntimeValues.clr2, StringComparison.OrdinalIgnoreCase))
                {
                    clrVersion = 2;
                }
                else if (taskHostParameters.Runtime.Equals(XMakeAttributes.MSBuildRuntimeValues.clr4, StringComparison.OrdinalIgnoreCase))
                {
                    clrVersion = 4;
                }
                else if (taskHostParameters.Runtime.Equals(XMakeAttributes.MSBuildRuntimeValues.net, StringComparison.OrdinalIgnoreCase))
                {
                    clrVersion = 5;
                }
                else
                {
                    FrameworkErrorUtilities.ThrowInternalErrorUnreachable();
                }
 
                architectureFlagToSet = taskHostParameters.Architecture;
            }
        }
 
        if (!string.IsNullOrEmpty(architectureFlagToSet))
        {
            if (architectureFlagToSet!.Equals(XMakeAttributes.MSBuildArchitectureValues.x64, StringComparison.OrdinalIgnoreCase))
            {
                context |= HandshakeOptions.X64;
            }
            else if (architectureFlagToSet.Equals(XMakeAttributes.MSBuildArchitectureValues.arm64, StringComparison.OrdinalIgnoreCase))
            {
                context |= HandshakeOptions.Arm64;
            }
        }
 
        switch (clrVersion)
        {
            case 0:
            // Not a taskhost, runtime must match
            case 4:
                // Default for MSBuild running on .NET Framework 4,
                // not represented in handshake
                break;
            case 2:
                context |= HandshakeOptions.CLR2;
                break;
            case >= 5:
                context |= HandshakeOptions.NET;
                break;
            default:
                FrameworkErrorUtilities.ThrowInternalErrorUnreachable();
                break;
        }
 
        // Node reuse is not supported in CLR2 because it's a legacy runtime.
        if (nodeReuse && clrVersion != 2)
        {
            context |= HandshakeOptions.NodeReuse;
        }
 
        if (lowPriority)
        {
            context |= HandshakeOptions.LowPriority;
        }
 
#if FEATURE_SECURITY_PRINCIPAL_WINDOWS || NET
        // If we are running in elevated privs, we will only accept a handshake from an elevated process as well.
        // Both the client and the host will calculate this separately, and the idea is that if they come out the same
        // then we can be sufficiently confident that the other side has the same elevation level as us.  This is complementary
        // to the username check which is also done on connection.
        if (
#if NET
            NativeMethods.IsWindows &&
#endif
            new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator))
        {
            context |= HandshakeOptions.Administrator;
        }
#endif
        return context;
    }
 
    /// <summary>
    ///  Gets a hash code for this string.  If strings A and B are such that A.Equals(B), then
    ///  they will return the same hash code.
    ///  This is as implemented in CLR String.GetHashCode() [ndp\clr\src\BCL\system\String.cs]
    ///  but stripped out architecture specific defines
    ///  that causes the hashcode to be different and this causes problem in cross-architecture handshaking.
    /// </summary>
    internal static int GetHashCode(string fileVersion)
    {
        unsafe
        {
            fixed (char* src = fileVersion)
            {
                int hash1 = (5381 << 16) + 5381;
                int hash2 = hash1;
 
                int* pint = (int*)src;
                int len = fileVersion.Length;
                while (len > 0)
                {
                    hash1 = ((hash1 << 5) + hash1 + (hash1 >> 27)) ^ pint[0];
                    if (len <= 2)
                    {
                        break;
                    }
 
                    hash2 = ((hash2 << 5) + hash2 + (hash2 >> 27)) ^ pint[1];
                    pint += 2;
                    len -= 4;
                }
 
                return hash1 + (hash2 * 1566083941);
            }
        }
    }
 
    internal static int AvoidEndOfHandshakeSignal(int x)
        => x == EndOfHandshakeSignal ? ~x : x;
 
    /// <summary>
    ///  Writes trace information to a log file.
    /// </summary>
    public static void Trace(string message)
    {
        if (s_trace)
        {
            TraceCore(nodeId: -1, message);
        }
    }
 
    /// <inheritdoc cref="Trace(string)" />
    public static void Trace(int nodeId, string message)
    {
        if (s_trace)
        {
            TraceCore(nodeId, message);
        }
    }
 
    /// <inheritdoc cref="Trace(string)" />
    public static void Trace(TraceInterpolatedStringHandler message)
    {
        if (s_trace)
        {
            TraceCore(nodeId: -1, message.GetFormattedText());
        }
    }
 
    /// <inheritdoc cref="Trace(string)" />
    public static void Trace(int nodeId, TraceInterpolatedStringHandler message)
    {
        if (s_trace)
        {
            TraceCore(nodeId, message.GetFormattedText());
        }
    }
 
    [InterpolatedStringHandler]
    public ref struct TraceInterpolatedStringHandler
    {
        private StringBuilderHelper _builder;
 
        public TraceInterpolatedStringHandler(int literalLength, int formattedCount, out bool isEnabled)
        {
            isEnabled = s_trace;
            _builder = isEnabled ? new(literalLength) : default;
        }
 
        public readonly void AppendLiteral(string value)
            => _builder.AppendLiteral(value);
 
        public readonly void AppendFormatted<TValue>(TValue value)
            => _builder.AppendFormatted(value);
 
        public readonly void AppendFormatted<TValue>(TValue value, string format)
            where TValue : IFormattable
            => _builder.AppendFormatted(value, format);
 
        public readonly string GetFormattedText()
            => _builder.GetFormattedText();
    }
 
    private static void TraceCore(int nodeId, string message)
    {
        lock (s_traceLock)
        {
            s_debugDumpPath ??= FrameworkDebugUtils.DebugPath;
 
            if (!string.IsNullOrEmpty(s_debugDumpPath))
            {
                Directory.CreateDirectory(s_debugDumpPath);
            }
            else
            {
                // Note: FileUtilities.TempFileDirectory ensures the directory exists,
                // so we don't need to create it in that case.
                s_debugDumpPath = FileUtilities.TempFileDirectory;
            }
 
            try
            {
                string fileName = nodeId == -1
                    ? $"MSBuild_CommTrace_PID_{EnvironmentUtilities.CurrentProcessId}.txt"
                    : $"MSBuild_CommTrace_PID_{EnvironmentUtilities.CurrentProcessId}_node_{nodeId}.txt";
 
                string filePath = Path.Combine(s_debugDumpPath, fileName);
 
                using (StreamWriter writer = FileUtilities.OpenWrite(filePath, append: true))
                {
                    long now = DateTime.UtcNow.Ticks;
                    float millisecondsSinceLastLog = (float)(now - s_lastLoggedTicks) / 10000L;
                    s_lastLoggedTicks = now;
 
                    writer.WriteLine($"{Thread.CurrentThread.Name} (TID {Environment.CurrentManagedThreadId}) {now,15} +{millisecondsSinceLastLog,10}ms: {message}");
                }
            }
            catch (IOException)
            {
                // Ignore
            }
        }
    }
}