diff --git a/src/Build.OM.UnitTests/Microsoft.Build.Engine.OM.UnitTests.csproj b/src/Build.OM.UnitTests/Microsoft.Build.Engine.OM.UnitTests.csproj index 21f20586dbd..fc675e1852f 100644 --- a/src/Build.OM.UnitTests/Microsoft.Build.Engine.OM.UnitTests.csproj +++ b/src/Build.OM.UnitTests/Microsoft.Build.Engine.OM.UnitTests.csproj @@ -51,7 +51,6 @@ - App.config Designer diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 66f78b30ec2..b74013ba0f0 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -32,7 +32,7 @@ namespace Microsoft.Build.BackEnd /// Contains the shared pieces of code from NodeProviderOutOfProc /// and NodeProviderOutOfProcTaskHost. /// - internal abstract class NodeProviderOutOfProcBase + internal abstract partial class NodeProviderOutOfProcBase { /// /// The maximum number of bytes to write @@ -218,6 +218,10 @@ protected IList GetNodes( } bool nodeReuseRequested = Handshake.IsHandshakeOptionEnabled(hostHandshake.HandshakeOptions, HandshakeOptions.NodeReuse); + + // Extract the expected NodeMode from the command line arguments + NodeMode? expectedNodeMode = NodeModeHelper.ExtractFromCommandLine(commandLineArgs); + // Get all process of possible running node processes for reuse and put them into ConcurrentQueue. // Processes from this queue will be concurrently consumed by TryReusePossibleRunningNodes while // trying to connect to them and reuse them. When queue is empty, no process to reuse left @@ -229,7 +233,7 @@ protected IList GetNodes( if (nodeReuseRequested) { IList possibleRunningNodesList; - (expectedProcessName, possibleRunningNodesList) = GetPossibleRunningNodes(msbuildLocation); + (expectedProcessName, possibleRunningNodesList) = GetPossibleRunningNodes(msbuildLocation, expectedNodeMode); possibleRunningNodes = new ConcurrentQueue(possibleRunningNodesList); if (possibleRunningNodesList.Count > 0) @@ -395,14 +399,17 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } /// - /// Finds processes named after either msbuild or msbuildtaskhost. + /// Finds processes that could be reusable MSBuild nodes. + /// Discovers both msbuild.exe processes and dotnet processes hosting MSBuild.dll. + /// Filters candidates by NodeMode when available. /// - /// + /// The location of the MSBuild executable + /// The NodeMode to filter for, or null to include all /// - /// Item 1 is the name of the process being searched for. - /// Item 2 is the ConcurrentQueue of ordered processes themselves. + /// Item 1 is a descriptive name of the processes being searched for. + /// Item 2 is the list of matching processes, sorted by ID. /// - private (string expectedProcessName, IList nodeProcesses) GetPossibleRunningNodes(string msbuildLocation = null) + private (string expectedProcessName, IList nodeProcesses) GetPossibleRunningNodes(string msbuildLocation = null, NodeMode? expectedNodeMode = null) { if (String.IsNullOrEmpty(msbuildLocation)) { @@ -410,10 +417,84 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } var expectedProcessName = Path.GetFileNameWithoutExtension(CurrentHost.GetCurrentHost() ?? msbuildLocation); + + // Get all processes with the expected MSBuild executable name + Process[] processes; + try + { + processes = Process.GetProcessesByName(expectedProcessName); + } + catch + { + // Process enumeration failed, return empty list + return (expectedProcessName, Array.Empty()); + } - var processes = Process.GetProcessesByName(expectedProcessName); - Array.Sort(processes, (left, right) => left.Id.CompareTo(right.Id)); + // If we have an expected NodeMode, filter by command line parsing + if (expectedNodeMode.HasValue && ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_5)) + { + CommunicationsUtilities.Trace("Filtering {0} candidate processes by NodeMode {1} for process name '{2}'", + processes.Length, expectedNodeMode.Value, expectedProcessName); + List filteredProcesses = []; + bool isDotnetProcess = expectedProcessName.Equals(Path.GetFileNameWithoutExtension(Constants.DotnetProcessName), StringComparison.OrdinalIgnoreCase); + + foreach (var process in processes) + { + try + { + if (!process.TryGetCommandLine(out string commandLine)) + { + // If we can't get the command line, skip this process + CommunicationsUtilities.Trace("Skipping process {0} - unable to retrieve command line", process.Id); + continue; + } + + if (commandLine is null) + { + // If we can't get the command line, then allow it as a candidate. This allows reuse to work on platforms where command line retrieval isn't supported, but still filters by NodeMode on platforms where it is supported. + CommunicationsUtilities.Trace("Including process {0} with unknown NodeMode because command line retrieval is not supported on this platform", process.Id); + filteredProcesses.Add(process); + continue; + } + + // If expected process is dotnet, filter to only those hosting MSBuild.dll + if (isDotnetProcess && !commandLine.Contains("MSBuild.dll", StringComparison.OrdinalIgnoreCase)) + { + CommunicationsUtilities.Trace("Skipping dotnet process {0} - not hosting MSBuild.dll. Command line: {1}", process.Id, commandLine); + continue; + } + + // Extract NodeMode from command line + NodeMode? processNodeMode = NodeModeHelper.ExtractFromCommandLine(commandLine); + + // Only include processes that match the expected NodeMode + if (processNodeMode.HasValue && processNodeMode.Value == expectedNodeMode.Value) + { + CommunicationsUtilities.Trace("Including process {0} with matching NodeMode {1}", process.Id, processNodeMode.Value); + filteredProcesses.Add(process); + } + else + { + CommunicationsUtilities.Trace("Skipping process {0} - NodeMode mismatch. Expected: {1}, Found: {2}. Command line: {3}", + process.Id, expectedNodeMode.Value, processNodeMode?.ToString() ?? "", commandLine); + } + } + catch (Exception ex) + { + // If we encounter any error processing this process, skip it but log + CommunicationsUtilities.Trace("Failed to get command line for process {0}: {1}", process.Id, ex.Message); + continue; + } + } + + // Sort by process ID for consistent ordering + filteredProcesses.Sort((left, right) => left.Id.CompareTo(right.Id)); + CommunicationsUtilities.Trace("Filtered to {0} processes matching NodeMode {1}", filteredProcesses.Count, expectedNodeMode.Value); + return (expectedProcessName, filteredProcesses); + } + // No NodeMode filtering, return all processes sorted by ID + Array.Sort(processes, (left, right) => left.Id.CompareTo(right.Id)); return (expectedProcessName, processes); } diff --git a/src/Framework.UnitTests/Microsoft.Build.Framework.UnitTests.csproj b/src/Framework.UnitTests/Microsoft.Build.Framework.UnitTests.csproj index aba4ae6c2d2..d31ef573b24 100644 --- a/src/Framework.UnitTests/Microsoft.Build.Framework.UnitTests.csproj +++ b/src/Framework.UnitTests/Microsoft.Build.Framework.UnitTests.csproj @@ -29,7 +29,6 @@ - diff --git a/src/Framework/NativeMethods.cs b/src/Framework/NativeMethods.cs index e5858135cf0..4ae854ba1b4 100644 --- a/src/Framework/NativeMethods.cs +++ b/src/Framework/NativeMethods.cs @@ -772,6 +772,7 @@ internal static bool IsLinux /// /// Gets a flag indicating if we are running under flavor of BSD (NetBSD, OpenBSD, FreeBSD) /// + [SupportedOSPlatformGuard("freebsd")] internal static bool IsBSD { #if CLR2COMPATIBILITY @@ -813,6 +814,7 @@ internal static bool IsWindows /// /// Gets a flag indicating if we are running under Mac OSX /// + [SupportedOSPlatformGuard("macos")] internal static bool IsOSX { #if CLR2COMPATIBILITY diff --git a/src/Framework/NodeMode.cs b/src/Framework/NodeMode.cs index 97c60854006..87d5d5bdb05 100644 --- a/src/Framework/NodeMode.cs +++ b/src/Framework/NodeMode.cs @@ -36,7 +36,7 @@ internal enum NodeMode /// /// Helper methods for the NodeMode enum. /// - internal static class NodeModeHelper + internal static partial class NodeModeHelper { /// /// Converts a NodeMode value to a command line argument string. @@ -53,12 +53,45 @@ internal static class NodeModeHelper /// True if parsing succeeded, false otherwise public static bool TryParse(string value, [NotNullWhen(true)] out NodeMode? nodeMode) { +#if NET + return TryParseImpl(value.AsSpan(), out nodeMode); +#else + return TryParseImpl(value, out nodeMode); +#endif + } + +#if NET + /// + /// Tries to parse a node mode value from a span, supporting both integer values and enum names (case-insensitive). + /// + /// The value to parse (can be an integer or enum name) + /// The parsed NodeMode value if successful + /// True if parsing succeeded, false otherwise + public static bool TryParse(ReadOnlySpan value, [NotNullWhen(true)] out NodeMode? nodeMode) + { + return TryParseImpl(value, out nodeMode); + } +#endif + +#if NET + private static bool TryParseImpl(ReadOnlySpan value, [NotNullWhen(true)] out NodeMode? nodeMode) +#else + private static bool TryParseImpl(string value, [NotNullWhen(true)] out NodeMode? nodeMode) +#endif + { nodeMode = null; +#if NET + if (value.IsEmpty || value.IsWhiteSpace()) + { + return false; + } +#else if (string.IsNullOrWhiteSpace(value)) { return false; } +#endif // First try to parse as an integer for backward compatibility if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue)) @@ -69,7 +102,7 @@ public static bool TryParse(string value, [NotNullWhen(true)] out NodeMode? node nodeMode = (NodeMode)intValue; return true; } - + return false; } @@ -82,5 +115,47 @@ public static bool TryParse(string value, [NotNullWhen(true)] out NodeMode? node return false; } + + /// + /// Extracts the NodeMode from a command line string using regex pattern matching. + /// + /// The command line to parse. Note that this can't be a span because generated regex don't have a Span Match overload + /// The NodeMode if found, otherwise null + public static NodeMode? ExtractFromCommandLine(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + { + return null; + } + + var match = CommandLineNodeModeRegex.Match(commandLine); + + if (!match.Success) + { + return null; + } + +#if NET + if (TryParse(match.Groups["nodemode"].ValueSpan, out NodeMode? nodeMode)) +#else + if (TryParse(match.Groups["nodemode"].Value, out NodeMode? nodeMode)) +#endif + { + return nodeMode; + } + + return null; + } + + private const string CommandLineNodeModePattern = @"/nodemode:(?[a-zA-Z0-9]+)(?:\s|$)"; + +#if NET + [System.Text.RegularExpressions.GeneratedRegex(CommandLineNodeModePattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex CommandLineNodeModeRegex { get; } +#else + private static System.Text.RegularExpressions.Regex CommandLineNodeModeRegex { get; } = new( + CommandLineNodeModePattern, + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); +#endif } } diff --git a/src/MSBuild.UnitTests/Microsoft.Build.CommandLine.UnitTests.csproj b/src/MSBuild.UnitTests/Microsoft.Build.CommandLine.UnitTests.csproj index 4ba8bcc482e..378a9068711 100644 --- a/src/MSBuild.UnitTests/Microsoft.Build.CommandLine.UnitTests.csproj +++ b/src/MSBuild.UnitTests/Microsoft.Build.CommandLine.UnitTests.csproj @@ -4,6 +4,7 @@ $(RuntimeOutputTargetFrameworks) $(RuntimeOutputPlatformTarget) false + true diff --git a/src/Shared/Debugging/DebugUtils.cs b/src/Shared/Debugging/DebugUtils.cs index 87ed7798304..5dfb1573ae4 100644 --- a/src/Shared/Debugging/DebugUtils.cs +++ b/src/Shared/Debugging/DebugUtils.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; using System.IO; -using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Shared.FileSystem; @@ -57,32 +55,7 @@ internal static void SetDebugPath() } private static readonly Lazy ProcessNodeMode = new( - () => - { - return ScanNodeMode(Environment.CommandLine); - - NodeMode? ScanNodeMode(string input) - { - var match = Regex.Match(input, @"/nodemode:(?[1-9]\d*)(\s|$)", RegexOptions.IgnoreCase); - - if (!match.Success) - { - return null; // Central/main process (not running as a node) - } - var nodeMode = match.Groups["nodemode"].Value; - - Trace.Assert(!string.IsNullOrEmpty(nodeMode)); - - // Try to parse using the shared NodeModeHelper - if (NodeModeHelper.TryParse(nodeMode, out NodeMode? parsedMode)) - { - return parsedMode; - } - - // If parsing fails, this is an unknown/unsupported node mode - return null; - } - }); + () => NodeModeHelper.ExtractFromCommandLine(Environment.CommandLine)); private static bool CurrentProcessMatchesDebugName() { diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 91addd48495..7ddd8dc6ab6 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -1,12 +1,20 @@ // 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.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +#if NET +using System.Buffers; +using System.Text; +using System.IO; +#endif namespace Microsoft.Build.Shared { - internal static class ProcessExtensions + internal static partial class ProcessExtensions { public static void KillTree(this Process process, int timeoutMilliseconds) { @@ -17,10 +25,9 @@ public static void KillTree(this Process process, int timeoutMilliseconds) { try { - // issue the kill command NativeMethodsShared.KillTree(process.Id); } - catch (System.InvalidOperationException) + catch (InvalidOperationException) { // The process already exited, which is fine, // just continue. @@ -28,13 +35,806 @@ public static void KillTree(this Process process, int timeoutMilliseconds) } else { - throw new System.NotSupportedException(); + throw new NotSupportedException(); } #endif - // wait until the process finishes exiting/getting killed. - // We don't want to wait forever here because the task is already supposed to be dieing, we just want to give it long enough + // Wait until the process finishes exiting/getting killed. + // We don't want to wait forever here because the task is already supposed to be dying, we just want to give it long enough // to try and flush what it can and stop. If it cannot do that in a reasonable time frame then we will just ignore it. process.WaitForExit(timeoutMilliseconds); } + + /// + /// Retrieves the full command line for a process in a cross-platform manner. + /// + /// The process to get the command line for. + /// The command line string, or null if it cannot be retrieved. + /// True if the command line was successfully retrieved or the current platform doesn't support retrieving command lines, false if there was an error retrieving the command line. + public static bool TryGetCommandLine(this Process? process, out string? commandLine) + { + commandLine = null; + + if (process?.HasExited != false) + { + return false; + } + + try + { +#if NET + if (NativeMethodsShared.IsWindows) + { + commandLine = Windows.GetCommandLine(process.Id); + return true; + } + else if (NativeMethodsShared.IsOSX || NativeMethodsShared.IsBSD) + { + commandLine = BSD.GetCommandLine(process.Id); + return true; + } + else if (NativeMethodsShared.IsLinux) + { + commandLine = Linux.GetCommandLine(process.Id); + return true; + } + else + { + // Unsupported OS - return false to fall back to prior behavior + commandLine = null; + return true; + } +#else + commandLine = Windows.GetCommandLine(process.Id); + return true; +#endif + } + catch + { + return false; + } + } + +#if NET + /// + /// Parses a null-separated byte buffer into a space-joined argument string using span-based slicing. + /// Used by both Linux (/proc/pid/cmdline) and macOS/BSD (sysctl KERN_PROCARGS2) parsing. + /// Uses ArrayPool to rent char buffers for efficient UTF-8 decoding without intermediate string allocations. + /// + private static string ParseNullSeparatedArguments(ReadOnlySpan data, int maxArgs = int.MaxValue) + { + if (data.IsEmpty) + { + return string.Empty; + } + + // Rent a char buffer for UTF-8 decoding (max char count equals byte count for ASCII-like content) + char[] charBuffer = ArrayPool.Shared.Rent(data.Length); + try + { + int totalChars = 0; + int argsFound = 0; + + while (!data.IsEmpty && argsFound < maxArgs) + { + int nullIndex = data.IndexOf((byte)0); + ReadOnlySpan segment = nullIndex >= 0 ? data.Slice(0, nullIndex) : data; + + if (!segment.IsEmpty) + { + // Add space separator between arguments + if (totalChars > 0) + { + charBuffer[totalChars++] = ' '; + } + + // Decode UTF-8 directly into the char buffer + int charsWritten = Encoding.UTF8.GetChars(segment, charBuffer.AsSpan(totalChars)); + + // UTF-8 decoder converts null bytes to null chars - replace them with spaces for safety + Span decodedChars = charBuffer.AsSpan(totalChars, charsWritten); + for (int i = 0; i < decodedChars.Length; i++) + { + if (decodedChars[i] == '\0') + { + decodedChars[i] = ' '; + } + } + + totalChars += charsWritten; + argsFound++; + } + + if (nullIndex < 0) + { + break; + } + + data = data.Slice(nullIndex + 1); + } + + return new string(charBuffer, 0, totalChars); + } + finally + { + ArrayPool.Shared.Return(charBuffer); + } + } +#endif + + /// + /// Windows-specific command line retrieval via WMI COM interfaces. + /// Queries Win32_Process for the CommandLine property using IWbemLocator/IWbemServices. + /// + [SupportedOSPlatform("windows")] + private static class Windows + { + // WMI COM interface GUIDs + private static readonly Guid CLSID_WbemLocator = new Guid("4590F811-1D3A-11D0-891F-00AA004B2E24"); + private static readonly Guid IID_IWbemLocator = new Guid("DC12A687-737F-11CF-884D-00AA004B2E24"); + + // WBEM status codes + private const int WBEM_S_NO_ERROR = 0; + private const int WBEM_S_FALSE = 1; // No more objects in enumeration + private const int WBEM_FLAG_FORWARD_ONLY = 0x00000020; + private const int WBEM_FLAG_RETURN_IMMEDIATELY = 0x00000010; + private const int WBEM_INFINITE = -1; + + + // RPC authentication/impersonation constants (used by CoInitializeSecurity and CoSetProxyBlanket) + private const int RPC_C_AUTHN_LEVEL_DEFAULT = 0; + private const int RPC_C_AUTHN_LEVEL_CALL = 3; + private const int RPC_C_IMP_LEVEL_IMPERSONATE = 3; + private const int RPC_C_AUTHN_WINNT = 10; + private const int RPC_C_AUTHZ_NONE = 0; + private const int EOAC_NONE = 0; + + // CoCreateInstance: in-process server + private const int CLSCTX_INPROC_SERVER = 1; + + // HRESULTs for conditions that are not fatal failures + private const int RPC_E_TOO_LATE = unchecked((int)0x80010119); // CoInitializeSecurity already called + + [DllImport("ole32.dll")] + private static extern int CoInitializeEx(IntPtr pvReserved, int dwCoInit); + + [DllImport("ole32.dll")] + private static extern int CoInitializeSecurity( + IntPtr pSecDesc, + int cAuthSvc, + IntPtr asAuthSvc, + IntPtr pReserved, + int dwAuthnLevel, + int dwImpLevel, + IntPtr pAuthList, + int dwCapabilities, + IntPtr pReserved3); + + [DllImport("ole32.dll")] + private static extern int CoCreateInstance( + ref Guid rclsid, + IntPtr pUnkOuter, + int dwClsContext, + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out IWbemLocator ppv); + + [DllImport("ole32.dll")] + private static extern int CoSetProxyBlanket( + [MarshalAs(UnmanagedType.IUnknown)] object pProxy, + int dwAuthnSvc, + int dwAuthzSvc, + IntPtr pServerPrincName, + int dwAuthnLevel, + int dwImpLevel, + IntPtr pAuthInfo, + int dwCapabilities); + +#if !NET + [DllImport("ole32.dll", EntryPoint = "CoSetProxyBlanket")] + private static extern int CoSetProxyBlanketPtr( + IntPtr pProxy, + int dwAuthnSvc, + int dwAuthzSvc, + IntPtr pServerPrincName, + int dwAuthnLevel, + int dwImpLevel, + IntPtr pAuthInfo, + int dwCapabilities); +#endif + + [ComImport] + [Guid("DC12A687-737F-11CF-884D-00AA004B2E24")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IWbemLocator + { + [PreserveSig] + int ConnectServer( + [MarshalAs(UnmanagedType.BStr)] string strNetworkResource, + [MarshalAs(UnmanagedType.BStr)] string? strUser, + [MarshalAs(UnmanagedType.BStr)] string? strPassword, + [MarshalAs(UnmanagedType.BStr)] string? strLocale, + int lSecurityFlags, + [MarshalAs(UnmanagedType.BStr)] string? strAuthority, + IntPtr pCtx, + [MarshalAs(UnmanagedType.Interface)] out IWbemServices ppNamespace); + } + + [Guid("44ACA674-E8FC-11D0-A07C-00C04FB68820")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [ComImport] + internal interface IWbemContext + { + [PreserveSig] + int Clone([MarshalAs(UnmanagedType.Interface)] out IWbemContext ppNewCopy); + + [PreserveSig] + int GetNames(int lFlags, IntPtr pNames); + + [PreserveSig] + int BeginEnumeration(int lFlags); + + [PreserveSig] + int Next(int lFlags, [MarshalAs(UnmanagedType.BStr)] out string pstrName, IntPtr pValue); + + [PreserveSig] + int EndEnumeration(); + + [PreserveSig] + int SetValue([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags, IntPtr pValue); + + [PreserveSig] + int GetValue([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags, IntPtr pValue); + + [PreserveSig] + int DeleteValue([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags); + + [PreserveSig] + int DeleteAll(); + } + + [ComImport] + [Guid("9556DC99-828C-11CF-A37E-00AA003240C7")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IWbemServices + { + [PreserveSig] + int OpenNamespace( + [MarshalAs(UnmanagedType.BStr)] string strNamespace, + int lFlags, + IntPtr pCtx, + IntPtr ppWorkingNamespace, + IntPtr ppResult); + + [PreserveSig] + int CancelAsyncCall(IntPtr pSink); + + [PreserveSig] + int QueryObjectSink(int lFlags, IntPtr ppResponseHandler); + + [PreserveSig] + int GetObject( + [MarshalAs(UnmanagedType.BStr)] string strObjectPath, + int lFlags, + IntPtr pCtx, + IntPtr ppObject, + IntPtr ppCallResult); + + [PreserveSig] + int GetObjectAsync( + [MarshalAs(UnmanagedType.BStr)] string strObjectPath, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int PutClass(IntPtr pObject, int lFlags, IntPtr pCtx, IntPtr ppCallResult); + + [PreserveSig] + int PutClassAsync(IntPtr pObject, int lFlags, IntPtr pCtx, IntPtr pResponseHandler); + + [PreserveSig] + int DeleteClass( + [MarshalAs(UnmanagedType.BStr)] string strClass, + int lFlags, + IntPtr pCtx, + IntPtr ppCallResult); + + [PreserveSig] + int DeleteClassAsync( + [MarshalAs(UnmanagedType.BStr)] string strClass, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int CreateClassEnum( + [MarshalAs(UnmanagedType.BStr)] string strSuperclass, + int lFlags, + IntPtr pCtx, + [MarshalAs(UnmanagedType.Interface)] out IEnumWbemClassObject ppEnum); + + [PreserveSig] + int CreateClassEnumAsync( + [MarshalAs(UnmanagedType.BStr)] string strSuperclass, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int PutInstance(IntPtr pInst, int lFlags, IntPtr pCtx, IntPtr ppCallResult); + + [PreserveSig] + int PutInstanceAsync(IntPtr pInst, int lFlags, IntPtr pCtx, IntPtr pResponseHandler); + + [PreserveSig] + int DeleteInstance( + [MarshalAs(UnmanagedType.BStr)] string strObjectPath, + int lFlags, + IntPtr pCtx, + IntPtr ppCallResult); + + [PreserveSig] + int DeleteInstanceAsync( + [MarshalAs(UnmanagedType.BStr)] string strObjectPath, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int CreateInstanceEnum( + [MarshalAs(UnmanagedType.BStr)] string strFilter, + int lFlags, + IntPtr pCtx, + [MarshalAs(UnmanagedType.Interface)] out IEnumWbemClassObject ppEnum); + + [PreserveSig] + int CreateInstanceEnumAsync( + [MarshalAs(UnmanagedType.BStr)] string strFilter, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int ExecQuery( + [In][MarshalAs(UnmanagedType.BStr)] string strQueryLanguage, + [In][MarshalAs(UnmanagedType.BStr)] string strQuery, + [In] int lFlags, + [In] IWbemContext? pCtx, + [MarshalAs(UnmanagedType.Interface)] out IEnumWbemClassObject ppEnum); + + [PreserveSig] + int ExecQueryAsync( + [MarshalAs(UnmanagedType.BStr)] string strQueryLanguage, + [MarshalAs(UnmanagedType.BStr)] string strQuery, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int ExecNotificationQuery( + [MarshalAs(UnmanagedType.BStr)] string strQueryLanguage, + [MarshalAs(UnmanagedType.BStr)] string strQuery, + int lFlags, + IntPtr pCtx, + IntPtr ppEnum); + + [PreserveSig] + int ExecNotificationQueryAsync( + [MarshalAs(UnmanagedType.BStr)] string strQueryLanguage, + [MarshalAs(UnmanagedType.BStr)] string strQuery, + int lFlags, + IntPtr pCtx, + IntPtr pResponseHandler); + + [PreserveSig] + int ExecMethod( + [MarshalAs(UnmanagedType.BStr)] string strObjectPath, + [MarshalAs(UnmanagedType.BStr)] string strMethodName, + int lFlags, + IntPtr pCtx, + IntPtr pInParams, + IntPtr ppOutParams, + IntPtr ppCallResult); + + [PreserveSig] + int ExecMethodAsync( + [MarshalAs(UnmanagedType.BStr)] string strObjectPath, + [MarshalAs(UnmanagedType.BStr)] string strMethodName, + int lFlags, + IntPtr pCtx, + IntPtr pInParams, + IntPtr pResponseHandler); + } + + [ComImport] + [Guid("027947E1-D731-11CE-A357-000000000001")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IEnumWbemClassObject + { + [PreserveSig] + int Reset(); + + [PreserveSig] + int Next( + int lTimeout, + uint uCount, + [MarshalAs(UnmanagedType.Interface)] out IWbemClassObject apObjects, + out uint puReturned); + + [PreserveSig] + int NextAsync(uint uCount, IntPtr pSink); + + [PreserveSig] + int Clone([MarshalAs(UnmanagedType.Interface)] out IEnumWbemClassObject ppEnum); + + [PreserveSig] + int Skip(int lTimeout, uint nCount); + } + + [ComImport] + [Guid("DC12A681-737F-11CF-884D-00AA004B2E24")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IWbemClassObject + { + [PreserveSig] + int GetQualifierSet(IntPtr ppQualSet); + + [PreserveSig] + int Get( + [MarshalAs(UnmanagedType.LPWStr)] string wszName, + int lFlags, + ref object pVal, + IntPtr pType, + IntPtr plFlavor); + + [PreserveSig] + int Put([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags, ref object pVal, int type); + + [PreserveSig] + int Delete([MarshalAs(UnmanagedType.LPWStr)] string wszName); + + [PreserveSig] + int GetNames([MarshalAs(UnmanagedType.LPWStr)] string wszQualifierName, int lFlags, ref object pQualifierVal, IntPtr pNames); + + [PreserveSig] + int BeginEnumeration(int lEnumFlags); + + [PreserveSig] + int Next(int lFlags, [MarshalAs(UnmanagedType.BStr)] out string strName, ref object pVal, IntPtr pType, IntPtr plFlavor); + + [PreserveSig] + int EndEnumeration(); + + [PreserveSig] + int GetPropertyQualifierSet([MarshalAs(UnmanagedType.LPWStr)] string wszProperty, IntPtr ppQualSet); + + [PreserveSig] + int Clone([MarshalAs(UnmanagedType.Interface)] out IWbemClassObject ppCopy); + + [PreserveSig] + int GetObjectText(int lFlags, [MarshalAs(UnmanagedType.BStr)] out string pstrObjectText); + + [PreserveSig] + int SpawnDerivedClass(int lFlags, IntPtr ppNewClass); + + [PreserveSig] + int SpawnInstance(int lFlags, IntPtr ppNewInstance); + + [PreserveSig] + int CompareTo(int lFlags, IntPtr pCompareTo); + + [PreserveSig] + int GetPropertyOrigin([MarshalAs(UnmanagedType.LPWStr)] string wszName, [MarshalAs(UnmanagedType.BStr)] out string pstrClassName); + + [PreserveSig] + int InheritsFrom([MarshalAs(UnmanagedType.LPWStr)] string strAncestor); + + [PreserveSig] + int GetMethod([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags, IntPtr ppInSignature, IntPtr ppOutSignature); + + [PreserveSig] + int PutMethod([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags, IntPtr pInSignature, IntPtr pOutSignature); + + [PreserveSig] + int DeleteMethod([MarshalAs(UnmanagedType.LPWStr)] string wszName); + + [PreserveSig] + int BeginMethodEnumeration(int lEnumFlags); + + [PreserveSig] + int NextMethod(int lFlags, [MarshalAs(UnmanagedType.BStr)] out string pstrName, IntPtr ppInSignature, IntPtr ppOutSignature); + + [PreserveSig] + int EndMethodEnumeration(); + + [PreserveSig] + int GetMethodQualifierSet([MarshalAs(UnmanagedType.LPWStr)] string wszMethod, IntPtr ppQualSet); + + [PreserveSig] + int GetMethodOrigin([MarshalAs(UnmanagedType.LPWStr)] string wszMethodName, [MarshalAs(UnmanagedType.BStr)] out string pstrClassName); + } + +#if !NET + /// + /// Sets the proxy blanket on a COM proxy via raw interface pointers. + /// On .NET Framework, the RCW caches separate proxy stubs for IUnknown and each + /// specific COM interface. CoSetProxyBlanket must be called on both the IUnknown + /// pointer and the specific interface pointer, otherwise WMI calls fail with + /// WBEM_E_ACCESS_DENIED (0x80041003). + /// + private static void SetProxyBlanketForNetFx(object comProxy) + { + IntPtr pUnk = Marshal.GetIUnknownForObject(comProxy); + try + { + CoSetProxyBlanketPtr( + pUnk, + RPC_C_AUTHN_WINNT, + RPC_C_AUTHZ_NONE, + IntPtr.Zero, + RPC_C_AUTHN_LEVEL_CALL, + RPC_C_IMP_LEVEL_IMPERSONATE, + IntPtr.Zero, + EOAC_NONE); + } + finally + { + Marshal.Release(pUnk); + } + + IntPtr pInterface = Marshal.GetComInterfaceForObject(comProxy, typeof(T)); + try + { + CoSetProxyBlanketPtr( + pInterface, + RPC_C_AUTHN_WINNT, + RPC_C_AUTHZ_NONE, + IntPtr.Zero, + RPC_C_AUTHN_LEVEL_CALL, + RPC_C_IMP_LEVEL_IMPERSONATE, + IntPtr.Zero, + EOAC_NONE); + } + finally + { + Marshal.Release(pInterface); + } + } +#endif + + /// + /// Retrieves the command line for a process by querying WMI Win32_Process via COM. + /// Runs: SELECT CommandLine FROM Win32_Process WHERE ProcessId='' + /// + internal static string? GetCommandLine(int processId) + { + int hr = CoInitializeSecurity( + IntPtr.Zero, + -1, + IntPtr.Zero, + IntPtr.Zero, + RPC_C_AUTHN_LEVEL_DEFAULT, + RPC_C_IMP_LEVEL_IMPERSONATE, + IntPtr.Zero, + EOAC_NONE, + IntPtr.Zero); + // RPC_E_TOO_LATE (0x80010119) means another call already set security — not fatal. + if (hr != WBEM_S_NO_ERROR && hr != RPC_E_TOO_LATE) + { + throw new InvalidOperationException( + $"WMI CoInitializeSecurity failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } + + Guid clsid = CLSID_WbemLocator; + Guid iid = IID_IWbemLocator; + hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref iid, out IWbemLocator locator); + if (hr != WBEM_S_NO_ERROR) + { + throw new InvalidOperationException( + $"WMI CoCreateInstance failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } + + hr = locator.ConnectServer( + @"ROOT\CIMV2", + strUser: null, strPassword: null, strLocale: null, + lSecurityFlags: 0, strAuthority: null, + pCtx: IntPtr.Zero, + out IWbemServices services); + if (hr != WBEM_S_NO_ERROR) + { + throw new InvalidOperationException( + $"WMI ConnectServer failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } + +#if NET + hr = CoSetProxyBlanket( + services, + RPC_C_AUTHN_WINNT, + RPC_C_AUTHZ_NONE, + IntPtr.Zero, + RPC_C_AUTHN_LEVEL_CALL, + RPC_C_IMP_LEVEL_IMPERSONATE, + IntPtr.Zero, + EOAC_NONE); + if (hr != WBEM_S_NO_ERROR) + { + throw new InvalidOperationException( + $"WMI CoSetProxyBlanket failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } +#else + // On .NET Framework, the RCW caches separate proxy stubs for IUnknown and + // each specific COM interface. CoSetProxyBlanket must be called on BOTH + // the IUnknown pointer AND the specific interface pointer, because the + // blanket set on IUnknown doesn't propagate to the specific interface's proxy. + SetProxyBlanketForNetFx(services); +#endif + + string query = $"SELECT CommandLine FROM Win32_Process WHERE ProcessId='{processId}'"; + hr = services.ExecQuery( + "WQL", + query, + WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, + null, + out IEnumWbemClassObject enumerator); + if (hr != WBEM_S_NO_ERROR) + { + throw new InvalidOperationException( + $"WMI ExecQuery failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } + +#if !NET + // The enumerator is a separate COM proxy that also needs its security blanket set. + SetProxyBlanketForNetFx(enumerator); +#endif + + hr = enumerator.Next(WBEM_INFINITE, 1, out IWbemClassObject obj, out uint returned); + if (hr == WBEM_S_FALSE || returned == 0) + { + // No matching process found. + return null; + } + if (hr != WBEM_S_NO_ERROR) + { + throw new InvalidOperationException( + $"WMI IEnumWbemClassObject.Next failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } + + object val = null!; + hr = obj.Get("CommandLine", 0, ref val, IntPtr.Zero, IntPtr.Zero); + if (hr != WBEM_S_NO_ERROR) + { + throw new InvalidOperationException( + $"WMI IWbemClassObject.Get(\"CommandLine\") failed for PID {processId}. HRESULT: 0x{hr:X8}"); + } + + return val as string; + } + } + +#if NET + /// + /// Linux-specific command line retrieval via /proc/{pid}/cmdline. + /// + [SupportedOSPlatform("linux")] + private static class Linux + { + /// + /// Reads /proc/{pid}/cmdline where arguments are null-byte separated, + /// and joins them with spaces. + /// + internal static string? GetCommandLine(int processId) + { + try + { + string cmdlinePath = $"/proc/{processId}/cmdline"; + byte[] cmdlineBytes = File.ReadAllBytes(cmdlinePath); + if (cmdlineBytes.Length == 0) + { + return null; + } + + return ParseNullSeparatedArguments(cmdlineBytes); + } + catch + { + return null; + } + } + } + + /// + /// macOS/BSD-specific P/Invoke bindings and command line retrieval via sysctl KERN_PROCARGS2. + /// + [SupportedOSPlatform("macos")] + [SupportedOSPlatform("freebsd")] + private static partial class BSD + { + [LibraryImport("libc", SetLastError = true)] + private static partial int sysctl( + ReadOnlySpan name, + uint namelen, + Span oldp, + ref nuint oldlenp, + ReadOnlySpan newp, + nuint newlen); + + /// + /// Wrapper over the raw sysctl P/Invoke that is optimized for reading values, not writing. + /// + private static int Sysctl(ReadOnlySpan name, Span oldp, ref nuint oldlenp) + => sysctl(name, (uint)name.Length, oldp, ref oldlenp, ReadOnlySpan.Empty, 0); + + private const int CTL_KERN = 1; + private const int KERN_PROCARGS2 = 49; + + /// + /// Uses sysctl with KERN_PROCARGS2 to read the process arguments, + /// then parses the null-separated buffer using span-based slicing with ArrayPool for efficient memory management. + /// Related: https://github.com/dotnet/runtime/issues/101837 + /// + internal static string? GetCommandLine(int processId) + { + ReadOnlySpan mib = [CTL_KERN, KERN_PROCARGS2, processId]; + nuint size = 0; + + // Get the required buffer size + if (Sysctl(mib, Span.Empty, ref size) != 0 || size == 0) + { + return null; + } + + // Rent a buffer from ArrayPool and pin it for sysctl + byte[] buffer = ArrayPool.Shared.Rent((int)size); + try + { + if (Sysctl(mib, buffer.AsSpan(0, (int)size), ref size) != 0) + { + return null; + } + + // Buffer format (KERN_PROCARGS2): + // int argc (number of arguments including executable) + // fully-qualified executable path (null-terminated) + // padding null bytes + // argv[0] .. argv[argc-1] (each null-terminated) + // environment variables (not needed) + ReadOnlySpan data = buffer.AsSpan(0, (int)size); + + if (data.Length < sizeof(int)) + { + return null; + } + + int argc = MemoryMarshal.Read(data); + if (argc <= 0) + { + return null; + } + + data = data.Slice(sizeof(int)); + + // Skip past the executable path (first null terminator) + int execPathEnd = data.IndexOf((byte)0); + if (execPathEnd < 0) + { + return null; + } + + data = data.Slice(execPathEnd + 1); + + // Skip padding null bytes between executable path and argv[0] + while (!data.IsEmpty && data[0] == 0) + { + data = data.Slice(1); + } + + return ParseNullSeparatedArguments(data, argc); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } +#endif } } diff --git a/src/Tasks.UnitTests/Microsoft.Build.Tasks.UnitTests.csproj b/src/Tasks.UnitTests/Microsoft.Build.Tasks.UnitTests.csproj index 81cb05cc550..0ae65d5be14 100644 --- a/src/Tasks.UnitTests/Microsoft.Build.Tasks.UnitTests.csproj +++ b/src/Tasks.UnitTests/Microsoft.Build.Tasks.UnitTests.csproj @@ -52,7 +52,6 @@ - diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index e92f6c7fffd..8f52ca1a0dd 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -6,6 +6,7 @@ using Microsoft.Build.Shared; using Shouldly; using Xunit; +using Xunit.Abstractions; #nullable disable @@ -13,6 +14,22 @@ namespace Microsoft.Build.UnitTests { public class ProcessExtensions_Tests { + private readonly ITestOutputHelper _output; + + public ProcessExtensions_Tests(ITestOutputHelper output) + { + _output = output; + } + + private static Process StartLongRunningProcess() + { + var psi = NativeMethodsShared.IsWindows + ? new ProcessStartInfo("ping", "-n 31 127.0.0.1") + : new ProcessStartInfo("sleep", "30"); + psi.UseShellExecute = false; + return Process.Start(psi); + } + [Fact] public async Task KillTree() { @@ -32,5 +49,66 @@ public async Task KillTree() p.HasExited.ShouldBe(true); p.ExitCode.ShouldNotBe(0); } + + [Fact] + public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() + { + using Process p = StartLongRunningProcess(); + try + { + await Task.Delay(300); + var sw = Stopwatch.StartNew(); + p.TryGetCommandLine(out string commandLine).ShouldBeTrue(); + sw.Stop(); + _output.WriteLine($"TryGetCommandLine elapsed: {sw.Elapsed.TotalMilliseconds:F2} ms"); + + if (NativeMethodsShared.IsWindows) + { + commandLine.ShouldContain("ping", Case.Insensitive); + } + else + { + commandLine.ShouldContain("sleep"); + } + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } + + [Fact] + public async Task TryGetCommandLine_RunningProcess_ContainsArguments() + { + using Process p = StartLongRunningProcess(); + try + { + await Task.Delay(300); + var sw = Stopwatch.StartNew(); + p.TryGetCommandLine(out string commandLine); + sw.Stop(); + _output.WriteLine($"TryGetCommandLine elapsed: {sw.Elapsed.TotalMilliseconds:F2} ms"); + + if (NativeMethodsShared.IsWindows) + { + // ping -n 31 127.0.0.1 – at minimum "127.0.0.1" or "31" should appear + commandLine.ShouldMatch(@"(127\.0\.0\.1|31)"); + } + else + { + commandLine.ShouldContain("30"); + } + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } } }