From e72e177afa337b489666135c46a68429a7169e16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:31:55 +0000 Subject: [PATCH 01/33] Add cross-platform process command line retrieval and improved node discovery Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 152 ++++++++++++++++-- src/Build/Microsoft.Build.csproj | 1 + src/Shared/ProcessExtensions.cs | 122 +++++++++++++- 3 files changed, 265 insertions(+), 10 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 63b7c559e11..9f91809f928 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -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 = ExtractNodeModeFromCommandLine(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) @@ -396,14 +400,65 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } /// - /// Finds processes named after either msbuild or msbuildtaskhost. + /// Extracts the NodeMode from a command line string. /// - /// + /// The command line to parse + /// The NodeMode if found, otherwise null + private static NodeMode? ExtractNodeModeFromCommandLine(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + { + return null; + } + + // Look for /nodemode: or /nodemode= + int nodeModeIndex = commandLine.IndexOf("/nodemode:", StringComparison.OrdinalIgnoreCase); + if (nodeModeIndex < 0) + { + nodeModeIndex = commandLine.IndexOf("/nodemode=", StringComparison.OrdinalIgnoreCase); + } + + if (nodeModeIndex < 0) + { + return null; + } + + // Skip past "/nodemode:" or "/nodemode=" + int valueStart = nodeModeIndex + "/nodemode:".Length; + if (valueStart >= commandLine.Length) + { + return null; + } + + // Find the end of the value (next space or end of string) + int valueEnd = commandLine.IndexOf(' ', valueStart); + if (valueEnd < 0) + { + valueEnd = commandLine.Length; + } + + string nodeModeValue = commandLine.Substring(valueStart, valueEnd - valueStart).Trim(); + + if (NodeModeHelper.TryParse(nodeModeValue, out NodeMode? nodeMode)) + { + return nodeMode; + } + + return null; + } + + /// + /// 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)) { @@ -411,11 +466,90 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } var expectedProcessName = Path.GetFileNameWithoutExtension(CurrentHost.GetCurrentHost() ?? msbuildLocation); + List candidateProcesses = []; + + // First, get all processes with the expected MSBuild executable name + try + { + var msbuildProcesses = Process.GetProcessesByName(expectedProcessName); + candidateProcesses.AddRange(msbuildProcesses); + } + catch + { + // Process enumeration failed, continue with empty list + } + + // Also get all dotnet processes that might be hosting MSBuild.dll + try + { + var dotnetProcesses = Process.GetProcessesByName("dotnet"); + candidateProcesses.AddRange(dotnetProcesses); + } + catch + { + // Process enumeration failed for dotnet, continue + } + + // Filter processes by NodeMode if we have an expected value + List filteredProcesses = []; + foreach (var process in candidateProcesses) + { + try + { + string? commandLine = process.GetCommandLine(); + if (commandLine is null) + { + // If we can't get the command line, skip this process + continue; + } + + // Check if this is an MSBuild process + // For dotnet processes, check if they're hosting MSBuild.dll + if (process.ProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + // Check if command line contains MSBuild.dll + if (!commandLine.Contains("MSBuild.dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + + // Extract NodeMode from command line + NodeMode? processNodeMode = ExtractNodeModeFromCommandLine(commandLine); + + // If we have an expected NodeMode, only include processes that match + if (expectedNodeMode.HasValue) + { + if (!processNodeMode.HasValue || processNodeMode.Value != expectedNodeMode.Value) + { + // NodeMode doesn't match or couldn't be determined, skip + continue; + } + } + else if (!processNodeMode.HasValue) + { + // No expected NodeMode, but couldn't determine this process's NodeMode, skip it + continue; + } + + // This process is a valid candidate + filteredProcesses.Add(process); + } + catch + { + // If we encounter any error processing this process, skip it + continue; + } + } + + // Sort by process ID for consistent ordering + filteredProcesses.Sort((left, right) => left.Id.CompareTo(right.Id)); - var processes = Process.GetProcessesByName(expectedProcessName); - Array.Sort(processes, (left, right) => left.Id.CompareTo(right.Id)); + string description = expectedNodeMode.HasValue + ? $"{expectedProcessName} or dotnet (NodeMode={expectedNodeMode.Value})" + : $"{expectedProcessName} or dotnet"; - return (expectedProcessName, processes); + return (description, filteredProcesses); } /// diff --git a/src/Build/Microsoft.Build.csproj b/src/Build/Microsoft.Build.csproj index 157bc6fe2bb..0aa9ec80a48 100644 --- a/src/Build/Microsoft.Build.csproj +++ b/src/Build/Microsoft.Build.csproj @@ -73,6 +73,7 @@ + diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 91addd48495..51b39c064b5 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -1,8 +1,10 @@ // 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.IO; +using System.Text; namespace Microsoft.Build.Shared { @@ -36,5 +38,123 @@ public static void KillTree(this Process process, int timeoutMilliseconds) // 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 + public static string? GetCommandLine(this Process process) + { + if (process is null) + { + return null; + } + + try + { + // Check if the process has exited + if (process.HasExited) + { + return null; + } + } + catch + { + // Process might have exited between null check and HasExited check + return null; + } + + try + { + if (NativeMethodsShared.IsWindows) + { + return GetCommandLineWindows(process); + } + else + { + return GetCommandLineUnix(process.Id); + } + } + catch + { + // If we can't retrieve the command line, return null + return null; + } + } + + /// + /// Retrieves the command line on Windows using WMI. + /// + private static string? GetCommandLineWindows(Process process) + { +#if NETFRAMEWORK + try + { + // On .NET Framework, we can use WMI via System.Management + using var searcher = new System.Management.ManagementObjectSearcher( + $"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {process.Id}"); + + using System.Management.ManagementObjectCollection objects = searcher.Get(); + foreach (System.Management.ManagementBaseObject obj in objects) + { + return obj["CommandLine"]?.ToString(); + } + } + catch + { + // WMI query failed, fall through to return null + } + return null; +#else + // On .NET Core/5+, WMI via System.Management requires a separate package. + // For now, we'll use an alternative approach or return null. + // TODO: Consider using native Windows API calls or System.Management package + return null; +#endif + } + + /// + /// Retrieves the command line on Unix/Linux by reading /proc/{pid}/cmdline. + /// + private static string? GetCommandLineUnix(int processId) + { + try + { + string cmdlinePath = $"/proc/{processId}/cmdline"; + if (!File.Exists(cmdlinePath)) + { + return null; + } + + // Read the cmdline file. Arguments are separated by null characters + byte[] cmdlineBytes = File.ReadAllBytes(cmdlinePath); + if (cmdlineBytes.Length == 0) + { + return null; + } + + // Replace null terminators with spaces to reconstruct the command line + StringBuilder sb = new(cmdlineBytes.Length); + foreach (byte b in cmdlineBytes) + { + if (b == 0) + { + sb.Append(' '); + } + else + { + sb.Append((char)b); + } + } + + // Trim trailing spaces (from trailing null terminators) + return sb.ToString().TrimEnd(); + } + catch + { + return null; + } + } } } From c34b0ab44c3706344d9b5af51167b35b63455e36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:40:45 +0000 Subject: [PATCH 02/33] Fix build errors - add nullable disable and internal test methods Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 2 +- src/Framework.UnitTests/NodeMode_Tests.cs | 63 ++++++++++++ src/Shared/ProcessExtensions.cs | 9 +- .../ProcessExtensions_Tests.cs | 96 +++++++++++++++++++ .../Microsoft.Build.Utilities.csproj | 4 + 5 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 src/Framework.UnitTests/NodeMode_Tests.cs diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 9f91809f928..6e402f9f156 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -496,7 +496,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte { try { - string? commandLine = process.GetCommandLine(); + string commandLine = process.GetCommandLine(); if (commandLine is null) { // If we can't get the command line, skip this process diff --git a/src/Framework.UnitTests/NodeMode_Tests.cs b/src/Framework.UnitTests/NodeMode_Tests.cs new file mode 100644 index 00000000000..ed25231661f --- /dev/null +++ b/src/Framework.UnitTests/NodeMode_Tests.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.UnitTests +{ + public class NodeMode_Tests + { + [Theory] + [InlineData(NodeMode.OutOfProcNode, "/nodemode:1")] + [InlineData(NodeMode.OutOfProcTaskHostNode, "/nodemode:2")] + [InlineData(NodeMode.OutOfProcRarNode, "/nodemode:3")] + [InlineData(NodeMode.OutOfProcServerNode, "/nodemode:8")] + internal void ToCommandLineArgument_ReturnsCorrectFormat(NodeMode nodeMode, string expected) + { + string result = NodeModeHelper.ToCommandLineArgument(nodeMode); + result.ShouldBe(expected); + } + + [Theory] + [InlineData("1", NodeMode.OutOfProcNode)] + [InlineData("2", NodeMode.OutOfProcTaskHostNode)] + [InlineData("3", NodeMode.OutOfProcRarNode)] + [InlineData("8", NodeMode.OutOfProcServerNode)] + internal void TryParse_ParsesIntegerValues(string value, NodeMode expected) + { + bool result = NodeModeHelper.TryParse(value, out NodeMode? nodeMode); + result.ShouldBeTrue(); + nodeMode.ShouldBe(expected); + } + + [Theory] + [InlineData("OutOfProcNode", NodeMode.OutOfProcNode)] + [InlineData("outofprocnode", NodeMode.OutOfProcNode)] + [InlineData("OUTOFPROCNODE", NodeMode.OutOfProcNode)] + [InlineData("OutOfProcTaskHostNode", NodeMode.OutOfProcTaskHostNode)] + [InlineData("OutOfProcRarNode", NodeMode.OutOfProcRarNode)] + [InlineData("OutOfProcServerNode", NodeMode.OutOfProcServerNode)] + internal void TryParse_ParsesEnumNames(string value, NodeMode expected) + { + bool result = NodeModeHelper.TryParse(value, out NodeMode? nodeMode); + result.ShouldBeTrue(); + nodeMode.ShouldBe(expected); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid")] + [InlineData("999")] + [InlineData("0")] + [InlineData("-1")] + public void TryParse_ReturnsFalseForInvalidValues(string value) + { + bool result = NodeModeHelper.TryParse(value, out NodeMode? nodeMode); + result.ShouldBeFalse(); + nodeMode.ShouldBeNull(); + } + } +} diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 51b39c064b5..8be4f1ad2b7 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -1,11 +1,12 @@ // 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.IO; using System.Text; +#nullable disable + namespace Microsoft.Build.Shared { internal static class ProcessExtensions @@ -44,7 +45,7 @@ public static void KillTree(this Process process, int timeoutMilliseconds) /// /// The process to get the command line for /// The command line string, or null if it cannot be retrieved - public static string? GetCommandLine(this Process process) + public static string GetCommandLine(this Process process) { if (process is null) { @@ -86,7 +87,7 @@ public static void KillTree(this Process process, int timeoutMilliseconds) /// /// Retrieves the command line on Windows using WMI. /// - private static string? GetCommandLineWindows(Process process) + private static string GetCommandLineWindows(Process process) { #if NETFRAMEWORK try @@ -117,7 +118,7 @@ public static void KillTree(this Process process, int timeoutMilliseconds) /// /// Retrieves the command line on Unix/Linux by reading /proc/{pid}/cmdline. /// - private static string? GetCommandLineUnix(int processId) + private static string GetCommandLineUnix(int processId) { try { diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index e92f6c7fffd..157a5a57de5 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -32,5 +32,101 @@ public async Task KillTree() p.HasExited.ShouldBe(true); p.ExitCode.ShouldNotBe(0); } + + [Fact] + public void GetCommandLine_ReturnsNullForNullProcess() + { + Process process = null; + string commandLine = process.GetCommandLine(); + commandLine.ShouldBeNull(); + } + + [Fact] + public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() + { + // Start a simple process that will run for a bit + var psi = NativeMethodsShared.IsWindows + ? new ProcessStartInfo("cmd.exe", "/c timeout 10") + : new ProcessStartInfo("sleep", "10"); + + psi.UseShellExecute = false; + + using Process p = Process.Start(psi); + try + { + // Give the process time to start + await Task.Delay(500); + + string commandLine = p.GetCommandLine(); + + // On some platforms/configurations, we might not be able to get the command line + // (e.g., .NET Core on Windows without WMI package) + // So we just verify it doesn't throw and returns either a string or null + if (commandLine != null) + { + commandLine.ShouldNotBeEmpty(); + + // On Unix, we should be able to get the command line from /proc + if (!NativeMethodsShared.IsWindows) + { + commandLine.ShouldContain("sleep"); + } + } + } + finally + { + // Clean up + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } + + [WindowsOnlyFact] + public void GetCommandLine_ReturnsNullForExitedProcess() + { + // Start and immediately exit a process + var psi = new ProcessStartInfo("cmd.exe", "/c exit 0") + { + UseShellExecute = false + }; + + using Process p = Process.Start(psi); + p.WaitForExit(5000); + + string commandLine = p.GetCommandLine(); + + // Command line should be null for an exited process + commandLine.ShouldBeNull(); + } + + [UnixOnlyFact] + public async Task GetCommandLine_WorksOnUnix() + { + // On Unix, we should be able to read from /proc + var psi = new ProcessStartInfo("sleep", "10") + { + UseShellExecute = false + }; + + using Process p = Process.Start(psi); + try + { + await Task.Delay(500); + + string commandLine = p.GetCommandLine(); + commandLine.ShouldNotBeNull(); + commandLine.ShouldNotBeEmpty(); + commandLine.ShouldContain("sleep"); + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } } } diff --git a/src/Utilities/Microsoft.Build.Utilities.csproj b/src/Utilities/Microsoft.Build.Utilities.csproj index c287f5b9f6a..ab9648077aa 100644 --- a/src/Utilities/Microsoft.Build.Utilities.csproj +++ b/src/Utilities/Microsoft.Build.Utilities.csproj @@ -31,6 +31,10 @@ + + + + From e949c96f1b1bf852fa9e9105c0e4317f16f3ea0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:45:59 +0000 Subject: [PATCH 03/33] Address code review feedback - fix encoding and race conditions Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 41 +++++++++++++++---- .../ProcessExtensions_Tests.cs | 4 +- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 8be4f1ad2b7..cb46026ed6a 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -128,29 +128,52 @@ private static string GetCommandLineUnix(int processId) return null; } - // Read the cmdline file. Arguments are separated by null characters + // Read the cmdline file. Arguments are separated by null characters. + // The file is typically encoded in the system's default encoding (usually UTF-8 on modern Linux). byte[] cmdlineBytes = File.ReadAllBytes(cmdlinePath); if (cmdlineBytes.Length == 0) { return null; } - // Replace null terminators with spaces to reconstruct the command line + // Convert bytes to string, replacing null terminators with spaces. + // We need to handle null bytes specially since they're argument separators in /proc/pid/cmdline. StringBuilder sb = new(cmdlineBytes.Length); - foreach (byte b in cmdlineBytes) + + int start = 0; + for (int i = 0; i < cmdlineBytes.Length; i++) { - if (b == 0) + if (cmdlineBytes[i] == 0) { - sb.Append(' '); + if (i > start) + { + // Decode the argument using UTF-8 encoding + string arg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, i - start); + if (sb.Length > 0) + { + sb.Append(' '); + } + sb.Append(arg); + } + start = i + 1; } - else + } + + // Handle any remaining bytes after the last null terminator + if (start < cmdlineBytes.Length) + { + string arg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, cmdlineBytes.Length - start); + if (arg.Length > 0) { - sb.Append((char)b); + if (sb.Length > 0) + { + sb.Append(' '); + } + sb.Append(arg); } } - // Trim trailing spaces (from trailing null terminators) - return sb.ToString().TrimEnd(); + return sb.ToString(); } catch { diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index 157a5a57de5..5a4915ec7cb 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -86,8 +86,8 @@ public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() [WindowsOnlyFact] public void GetCommandLine_ReturnsNullForExitedProcess() { - // Start and immediately exit a process - var psi = new ProcessStartInfo("cmd.exe", "/c exit 0") + // Start a process with a brief delay before exit + var psi = new ProcessStartInfo("cmd.exe", "/c timeout /t 1 /nobreak >nul") { UseShellExecute = false }; From 7d8fbbd0c35f7d8213a22128a3472ade9140d164 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:49:23 +0000 Subject: [PATCH 04/33] Improve code clarity with better variable names Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index cb46026ed6a..4fa3b7f7296 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -148,12 +148,12 @@ private static string GetCommandLineUnix(int processId) if (i > start) { // Decode the argument using UTF-8 encoding - string arg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, i - start); + string currentArg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, i - start); if (sb.Length > 0) { sb.Append(' '); } - sb.Append(arg); + sb.Append(currentArg); } start = i + 1; } @@ -162,14 +162,14 @@ private static string GetCommandLineUnix(int processId) // Handle any remaining bytes after the last null terminator if (start < cmdlineBytes.Length) { - string arg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, cmdlineBytes.Length - start); - if (arg.Length > 0) + string remainingArg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, cmdlineBytes.Length - start); + if (remainingArg.Length > 0) { if (sb.Length > 0) { sb.Append(' '); } - sb.Append(arg); + sb.Append(remainingArg); } } From 723007112b11d1bab441a9f2eeb64c62a2dc76df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:59:43 +0000 Subject: [PATCH 05/33] Implement Windows command line retrieval for .NET Core and use regex for parsing Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 32 +--- src/Shared/ProcessExtensions.cs | 163 +++++++++++++++++- 2 files changed, 167 insertions(+), 28 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 6e402f9f156..a560c3db659 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -400,7 +400,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } /// - /// Extracts the NodeMode from a command line string. + /// Extracts the NodeMode from a command line string using regex pattern matching. /// /// The command line to parse /// The NodeMode if found, otherwise null @@ -411,33 +411,19 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte return null; } - // Look for /nodemode: or /nodemode= - int nodeModeIndex = commandLine.IndexOf("/nodemode:", StringComparison.OrdinalIgnoreCase); - if (nodeModeIndex < 0) - { - nodeModeIndex = commandLine.IndexOf("/nodemode=", StringComparison.OrdinalIgnoreCase); - } + // Use regex pattern matching similar to DebugUtils.ScanNodeMode + // Pattern: /nodemode: followed by whitespace or end of string + var match = System.Text.RegularExpressions.Regex.Match( + commandLine, + @"/nodemode:(?[1-9]\d*)(\s|$)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (nodeModeIndex < 0) + if (!match.Success) { return null; } - // Skip past "/nodemode:" or "/nodemode=" - int valueStart = nodeModeIndex + "/nodemode:".Length; - if (valueStart >= commandLine.Length) - { - return null; - } - - // Find the end of the value (next space or end of string) - int valueEnd = commandLine.IndexOf(' ', valueStart); - if (valueEnd < 0) - { - valueEnd = commandLine.Length; - } - - string nodeModeValue = commandLine.Substring(valueStart, valueEnd - valueStart).Trim(); + string nodeModeValue = match.Groups["nodemode"].Value; if (NodeModeHelper.TryParse(nodeModeValue, out NodeMode? nodeMode)) { diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 4fa3b7f7296..b51beb2766f 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -4,6 +4,10 @@ using System.Diagnostics; using System.IO; using System.Text; +#if NET +using System; +using System.Runtime.InteropServices; +#endif #nullable disable @@ -11,6 +15,53 @@ namespace Microsoft.Build.Shared { internal static class ProcessExtensions { +#if NET + // P/Invoke declarations for getting process command line on Windows (.NET Core) + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + ref PROCESS_BASIC_INFORMATION processInformation, + int processInformationLength, + out int returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + [Out] byte[] lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_BASIC_INFORMATION + { + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + public IntPtr Reserved2_0; + public IntPtr Reserved2_1; + public IntPtr UniqueProcessId; + public IntPtr InheritedFromUniqueProcessId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + private const int PROCESS_QUERY_INFORMATION = 0x0400; + private const int PROCESS_VM_READ = 0x0010; +#endif + public static void KillTree(this Process process, int timeoutMilliseconds) { #if NET @@ -85,7 +136,9 @@ public static string GetCommandLine(this Process process) } /// - /// Retrieves the command line on Windows using WMI. + /// Retrieves the command line on Windows. + /// On .NET Framework: Uses WMI (System.Management). + /// On .NET Core+: Uses Windows API P/Invoke to read from PEB. /// private static string GetCommandLineWindows(Process process) { @@ -108,13 +161,113 @@ private static string GetCommandLineWindows(Process process) } return null; #else - // On .NET Core/5+, WMI via System.Management requires a separate package. - // For now, we'll use an alternative approach or return null. - // TODO: Consider using native Windows API calls or System.Management package - return null; + // On .NET Core/5+, use native Windows API to read command line from process PEB + try + { + return GetCommandLineWindowsNative(process.Id); + } + catch + { + // Native API calls failed + return null; + } #endif } +#if NET + /// + /// Retrieves the command line for a Windows process using native APIs. + /// This reads the command line from the Process Environment Block (PEB). + /// + private static string GetCommandLineWindowsNative(int processId) + { + IntPtr hProcess = IntPtr.Zero; + try + { + // Open the process with query and read permissions + hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); + if (hProcess == IntPtr.Zero) + { + return null; + } + + // Get process basic information to locate PEB + PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); + int returnLength; + int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); + if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) + { + return null; + } + + // Read the PEB to get the ProcessParameters pointer + // In 64-bit: PEB + 0x20 = ProcessParameters + // In 32-bit: PEB + 0x10 = ProcessParameters + int processParametersOffset = IntPtr.Size == 8 ? 0x20 : 0x10; + IntPtr processParametersPtr = IntPtr.Zero; + + byte[] ptrBuffer = new byte[IntPtr.Size]; + if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), ptrBuffer, ptrBuffer.Length, out _)) + { + return null; + } + processParametersPtr = IntPtr.Size == 8 + ? new IntPtr(BitConverter.ToInt64(ptrBuffer, 0)) + : new IntPtr(BitConverter.ToInt32(ptrBuffer, 0)); + + if (processParametersPtr == IntPtr.Zero) + { + return null; + } + + // Read the CommandLine UNICODE_STRING from ProcessParameters + // CommandLine is at offset 0x70 in 64-bit and 0x40 in 32-bit + int commandLineOffset = IntPtr.Size == 8 ? 0x70 : 0x40; + byte[] unicodeStringBuffer = new byte[Marshal.SizeOf(typeof(UNICODE_STRING))]; + if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), unicodeStringBuffer, unicodeStringBuffer.Length, out _)) + { + return null; + } + + // Parse UNICODE_STRING structure + UNICODE_STRING commandLineUnicode = new UNICODE_STRING + { + Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), + MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), + Buffer = IntPtr.Size == 8 + ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 4 + (IntPtr.Size - 4))) // Account for padding + : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) + }; + + if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) + { + return null; + } + + // Read the actual command line string + byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; + if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) + { + return null; + } + + return Encoding.Unicode.GetString(commandLineBuffer); + } + catch + { + return null; + } + finally + { + if (hProcess != IntPtr.Zero) + { + CloseHandle(hProcess); + } + } + } +#endif + + /// /// Retrieves the command line on Unix/Linux by reading /proc/{pid}/cmdline. /// From 4fb31d2966dc02abef3d58dc837e1ff63ff5eb80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:03:20 +0000 Subject: [PATCH 06/33] Simplify UNICODE_STRING buffer offset calculation with clear comments Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index b51beb2766f..1b34e071ee4 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -230,13 +230,14 @@ private static string GetCommandLineWindowsNative(int processId) } // Parse UNICODE_STRING structure + // Layout: ushort Length (2 bytes), ushort MaximumLength (2 bytes), [4 bytes padding on 64-bit], IntPtr Buffer UNICODE_STRING commandLineUnicode = new UNICODE_STRING { Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), Buffer = IntPtr.Size == 8 - ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 4 + (IntPtr.Size - 4))) // Account for padding - : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) + ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 8)) // 4 bytes for ushorts + 4 bytes padding + : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) // 4 bytes for ushorts, no padding }; if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) From 11fd08478b6c4064d52a2858d38be884240a1a16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:13:51 +0000 Subject: [PATCH 07/33] Add macOS support for process command line retrieval using sysctl Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 115 +++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 1b34e071ee4..2102351854d 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -60,6 +60,19 @@ private struct UNICODE_STRING private const int PROCESS_QUERY_INFORMATION = 0x0400; private const int PROCESS_VM_READ = 0x0010; + + // P/Invoke declarations for getting process command line on macOS + [DllImport("libc", SetLastError = true)] + private static extern int sysctl( + [In] int[] name, + uint namelen, + IntPtr oldp, + ref ulong oldlenp, + IntPtr newp, + ulong newlen); + + private const int CTL_KERN = 1; + private const int KERN_PROCARGS2 = 49; #endif public static void KillTree(this Process process, int timeoutMilliseconds) @@ -123,9 +136,15 @@ public static string GetCommandLine(this Process process) { return GetCommandLineWindows(process); } +#if NET + else if (NativeMethodsShared.IsOSX) + { + return GetCommandLineMacOS(process.Id); + } +#endif else { - return GetCommandLineUnix(process.Id); + return GetCommandLineLinux(process.Id); } } catch @@ -270,9 +289,9 @@ private static string GetCommandLineWindowsNative(int processId) /// - /// Retrieves the command line on Unix/Linux by reading /proc/{pid}/cmdline. + /// Retrieves the command line on Linux by reading /proc/{pid}/cmdline. /// - private static string GetCommandLineUnix(int processId) + private static string GetCommandLineLinux(int processId) { try { @@ -334,5 +353,95 @@ private static string GetCommandLineUnix(int processId) return null; } } + +#if NET + /// + /// Retrieves the command line on macOS using sysctl with KERN_PROCARGS2. + /// + private static string GetCommandLineMacOS(int processId) + { + try + { + // Use sysctl with CTL_KERN, KERN_PROCARGS2 to get process arguments + int[] mib = [CTL_KERN, KERN_PROCARGS2, processId]; + ulong size = 0; + + // First call to get the size of the buffer needed + if (sysctl(mib, (uint)mib.Length, IntPtr.Zero, ref size, IntPtr.Zero, 0) != 0) + { + return null; + } + + if (size == 0) + { + return null; + } + + // Allocate buffer and get the actual data + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + if (sysctl(mib, (uint)mib.Length, buffer, ref size, IntPtr.Zero, 0) != 0) + { + return null; + } + + // The buffer format is: + // - int argc (number of arguments) + // - executable path (null-terminated) + // - arguments (null-terminated strings) + + // Read argc + int argc = Marshal.ReadInt32(buffer); + if (argc <= 0) + { + return null; + } + + // Copy the buffer to a byte array for easier parsing + byte[] data = new byte[size]; + Marshal.Copy(buffer, data, 0, (int)size); + + // Skip the argc (4 bytes) and parse null-terminated strings + StringBuilder sb = new(); + int offset = sizeof(int); + + // Parse all arguments (executable + arguments) + for (int argIndex = 0; argIndex < argc && offset < data.Length; argIndex++) + { + // Find the next null terminator + int nullIndex = offset; + while (nullIndex < data.Length && data[nullIndex] != 0) + { + nullIndex++; + } + + if (nullIndex > offset) + { + string arg = System.Text.Encoding.UTF8.GetString(data, offset, nullIndex - offset); + if (sb.Length > 0) + { + sb.Append(' '); + } + sb.Append(arg); + } + + // Move past the null terminator + offset = nullIndex + 1; + } + + return sb.ToString(); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + catch + { + return null; + } + } +#endif } } From 17d593bd1bf5de94b31fed747a71d12c77613055 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:05:51 +0000 Subject: [PATCH 08/33] Remove NodeMode_Tests.cs per review feedback Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Framework.UnitTests/NodeMode_Tests.cs | 63 ----------------------- 1 file changed, 63 deletions(-) delete mode 100644 src/Framework.UnitTests/NodeMode_Tests.cs diff --git a/src/Framework.UnitTests/NodeMode_Tests.cs b/src/Framework.UnitTests/NodeMode_Tests.cs deleted file mode 100644 index ed25231661f..00000000000 --- a/src/Framework.UnitTests/NodeMode_Tests.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Build.Framework; -using Shouldly; -using Xunit; - -namespace Microsoft.Build.UnitTests -{ - public class NodeMode_Tests - { - [Theory] - [InlineData(NodeMode.OutOfProcNode, "/nodemode:1")] - [InlineData(NodeMode.OutOfProcTaskHostNode, "/nodemode:2")] - [InlineData(NodeMode.OutOfProcRarNode, "/nodemode:3")] - [InlineData(NodeMode.OutOfProcServerNode, "/nodemode:8")] - internal void ToCommandLineArgument_ReturnsCorrectFormat(NodeMode nodeMode, string expected) - { - string result = NodeModeHelper.ToCommandLineArgument(nodeMode); - result.ShouldBe(expected); - } - - [Theory] - [InlineData("1", NodeMode.OutOfProcNode)] - [InlineData("2", NodeMode.OutOfProcTaskHostNode)] - [InlineData("3", NodeMode.OutOfProcRarNode)] - [InlineData("8", NodeMode.OutOfProcServerNode)] - internal void TryParse_ParsesIntegerValues(string value, NodeMode expected) - { - bool result = NodeModeHelper.TryParse(value, out NodeMode? nodeMode); - result.ShouldBeTrue(); - nodeMode.ShouldBe(expected); - } - - [Theory] - [InlineData("OutOfProcNode", NodeMode.OutOfProcNode)] - [InlineData("outofprocnode", NodeMode.OutOfProcNode)] - [InlineData("OUTOFPROCNODE", NodeMode.OutOfProcNode)] - [InlineData("OutOfProcTaskHostNode", NodeMode.OutOfProcTaskHostNode)] - [InlineData("OutOfProcRarNode", NodeMode.OutOfProcRarNode)] - [InlineData("OutOfProcServerNode", NodeMode.OutOfProcServerNode)] - internal void TryParse_ParsesEnumNames(string value, NodeMode expected) - { - bool result = NodeModeHelper.TryParse(value, out NodeMode? nodeMode); - result.ShouldBeTrue(); - nodeMode.ShouldBe(expected); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("invalid")] - [InlineData("999")] - [InlineData("0")] - [InlineData("-1")] - public void TryParse_ReturnsFalseForInvalidValues(string value) - { - bool result = NodeModeHelper.TryParse(value, out NodeMode? nodeMode); - result.ShouldBeFalse(); - nodeMode.ShouldBeNull(); - } - } -} From e4cbd0375e3a542e992f36ca6f4c6d023d7ad1a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:14:36 +0000 Subject: [PATCH 09/33] Enable nullable, fix resource leaks, optimize regex, and remove trivial tests Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 37 +++++++++++------- src/Shared/ProcessExtensions.cs | 38 ++++++++----------- .../ProcessExtensions_Tests.cs | 35 +++-------------- 3 files changed, 44 insertions(+), 66 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index a560c3db659..a31cdd0d49e 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -54,6 +54,14 @@ internal abstract class NodeProviderOutOfProcBase /// private const int TimeoutForWaitForExit = 30000; + /// + /// Regex pattern for extracting /nodemode parameter from command lines. + /// Uses the same pattern as DebugUtils.ScanNodeMode for consistency. + /// + private static readonly System.Text.RegularExpressions.Regex NodeModeRegex = new( + @"/nodemode:(?[1-9]\d*)(\s|$)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); + #if !FEATURE_PIPEOPTIONS_CURRENTUSERONLY private static readonly WindowsIdentity s_currentWindowsIdentity = WindowsIdentity.GetCurrent(); #endif @@ -411,12 +419,8 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte return null; } - // Use regex pattern matching similar to DebugUtils.ScanNodeMode - // Pattern: /nodemode: followed by whitespace or end of string - var match = System.Text.RegularExpressions.Regex.Match( - commandLine, - @"/nodemode:(?[1-9]\d*)(\s|$)", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); + // Use static compiled regex for better performance + var match = NodeModeRegex.Match(commandLine); if (!match.Success) { @@ -480,6 +484,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte List filteredProcesses = []; foreach (var process in candidateProcesses) { + bool shouldKeep = false; try { string commandLine = process.GetCommandLine(); @@ -491,13 +496,11 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte // Check if this is an MSBuild process // For dotnet processes, check if they're hosting MSBuild.dll - if (process.ProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + if (process.ProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase) && + !commandLine.Contains("MSBuild.dll", StringComparison.OrdinalIgnoreCase)) { - // Check if command line contains MSBuild.dll - if (!commandLine.Contains("MSBuild.dll", StringComparison.OrdinalIgnoreCase)) - { - continue; - } + // Not hosting MSBuild.dll, skip + continue; } // Extract NodeMode from command line @@ -520,11 +523,19 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte // This process is a valid candidate filteredProcesses.Add(process); + shouldKeep = true; } catch { // If we encounter any error processing this process, skip it - continue; + } + finally + { + // Dispose processes that we're not keeping to prevent resource leaks + if (!shouldKeep) + { + process?.Dispose(); + } } } diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 2102351854d..cfcd9524dfd 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -9,8 +9,6 @@ using System.Runtime.InteropServices; #endif -#nullable disable - namespace Microsoft.Build.Shared { internal static class ProcessExtensions @@ -109,7 +107,7 @@ public static void KillTree(this Process process, int timeoutMilliseconds) /// /// The process to get the command line for /// The command line string, or null if it cannot be retrieved - public static string GetCommandLine(this Process process) + public static string? GetCommandLine(this Process? process) { if (process is null) { @@ -132,20 +130,11 @@ public static string GetCommandLine(this Process process) try { - if (NativeMethodsShared.IsWindows) - { - return GetCommandLineWindows(process); - } + return NativeMethodsShared.IsWindows ? GetCommandLineWindows(process) : #if NET - else if (NativeMethodsShared.IsOSX) - { - return GetCommandLineMacOS(process.Id); - } + NativeMethodsShared.IsOSX ? GetCommandLineMacOS(process.Id) : #endif - else - { - return GetCommandLineLinux(process.Id); - } + GetCommandLineLinux(process.Id); } catch { @@ -159,7 +148,7 @@ public static string GetCommandLine(this Process process) /// On .NET Framework: Uses WMI (System.Management). /// On .NET Core+: Uses Windows API P/Invoke to read from PEB. /// - private static string GetCommandLineWindows(Process process) + private static string? GetCommandLineWindows(Process process) { #if NETFRAMEWORK try @@ -171,7 +160,10 @@ private static string GetCommandLineWindows(Process process) using System.Management.ManagementObjectCollection objects = searcher.Get(); foreach (System.Management.ManagementBaseObject obj in objects) { - return obj["CommandLine"]?.ToString(); + using (obj) + { + return obj["CommandLine"]?.ToString(); + } } } catch @@ -198,7 +190,7 @@ private static string GetCommandLineWindows(Process process) /// Retrieves the command line for a Windows process using native APIs. /// This reads the command line from the Process Environment Block (PEB). /// - private static string GetCommandLineWindowsNative(int processId) + private static string? GetCommandLineWindowsNative(int processId) { IntPtr hProcess = IntPtr.Zero; try @@ -291,7 +283,7 @@ private static string GetCommandLineWindowsNative(int processId) /// /// Retrieves the command line on Linux by reading /proc/{pid}/cmdline. /// - private static string GetCommandLineLinux(int processId) + private static string? GetCommandLineLinux(int processId) { try { @@ -358,7 +350,7 @@ private static string GetCommandLineLinux(int processId) /// /// Retrieves the command line on macOS using sysctl with KERN_PROCARGS2. /// - private static string GetCommandLineMacOS(int processId) + private static string? GetCommandLineMacOS(int processId) { try { @@ -387,9 +379,9 @@ private static string GetCommandLineMacOS(int processId) } // The buffer format is: - // - int argc (number of arguments) - // - executable path (null-terminated) - // - arguments (null-terminated strings) + // - int argc (number of arguments, including the executable path as argv[0]) + // - argv[0] (executable path, null-terminated) + // - argv[1] .. argv[argc-1] (arguments, each a null-terminated string) // Read argc int argc = Marshal.ReadInt32(buffer); diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index 5a4915ec7cb..81085555db0 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -33,14 +33,6 @@ public async Task KillTree() p.ExitCode.ShouldNotBe(0); } - [Fact] - public void GetCommandLine_ReturnsNullForNullProcess() - { - Process process = null; - string commandLine = process.GetCommandLine(); - commandLine.ShouldBeNull(); - } - [Fact] public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() { @@ -59,14 +51,15 @@ public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() string commandLine = p.GetCommandLine(); - // On some platforms/configurations, we might not be able to get the command line - // (e.g., .NET Core on Windows without WMI package) - // So we just verify it doesn't throw and returns either a string or null + // The current implementation uses native Windows APIs on .NET Core+ and /proc or sysctl on Unix, + // so command line retrieval should generally work on all supported platforms. + // However, to remain robust in constrained environments, we only verify it does not throw + // and that any non-null result is non-empty. if (commandLine != null) { commandLine.ShouldNotBeEmpty(); - // On Unix, we should be able to get the command line from /proc + // On Unix, we should be able to get the command line if (!NativeMethodsShared.IsWindows) { commandLine.ShouldContain("sleep"); @@ -83,24 +76,6 @@ public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() } } - [WindowsOnlyFact] - public void GetCommandLine_ReturnsNullForExitedProcess() - { - // Start a process with a brief delay before exit - var psi = new ProcessStartInfo("cmd.exe", "/c timeout /t 1 /nobreak >nul") - { - UseShellExecute = false - }; - - using Process p = Process.Start(psi); - p.WaitForExit(5000); - - string commandLine = p.GetCommandLine(); - - // Command line should be null for an exited process - commandLine.ShouldBeNull(); - } - [UnixOnlyFact] public async Task GetCommandLine_WorksOnUnix() { From 7660837e9f93b022ca5e6baa4982c4d2d34fdb5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:09:58 +0000 Subject: [PATCH 10/33] Add macOS-specific test and OSXOnlyFact attribute, remove Process disposal Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 11 +------ src/UnitTests.Shared/OSXOnlyFactAttribute.cs | 25 +++++++++++++++ .../ProcessExtensions_Tests.cs | 31 ++++++++++++++++++- 3 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 src/UnitTests.Shared/OSXOnlyFactAttribute.cs diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index a31cdd0d49e..6f3033fc2d7 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -484,7 +484,6 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte List filteredProcesses = []; foreach (var process in candidateProcesses) { - bool shouldKeep = false; try { string commandLine = process.GetCommandLine(); @@ -523,19 +522,11 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte // This process is a valid candidate filteredProcesses.Add(process); - shouldKeep = true; } catch { // If we encounter any error processing this process, skip it - } - finally - { - // Dispose processes that we're not keeping to prevent resource leaks - if (!shouldKeep) - { - process?.Dispose(); - } + continue; } } diff --git a/src/UnitTests.Shared/OSXOnlyFactAttribute.cs b/src/UnitTests.Shared/OSXOnlyFactAttribute.cs new file mode 100644 index 00000000000..0ac5714bbeb --- /dev/null +++ b/src/UnitTests.Shared/OSXOnlyFactAttribute.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using Microsoft.Build.Shared; +using Xunit; + +namespace Microsoft.Build.UnitTests +{ + /// + /// A custom that skips the test if not running on macOS. + /// + public sealed class OSXOnlyFactAttribute : FactAttribute + { + public OSXOnlyFactAttribute(string? additionalMessage = null) + { + if (!NativeMethodsShared.IsOSX) + { + Skip = additionalMessage != null + ? $"This test only runs on macOS. {additionalMessage}" + : "This test only runs on macOS."; + } + } + } +} diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index 81085555db0..a44132e0d13 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -79,7 +79,7 @@ public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() [UnixOnlyFact] public async Task GetCommandLine_WorksOnUnix() { - // On Unix, we should be able to read from /proc + // On Unix (Linux and macOS), we should be able to read command lines var psi = new ProcessStartInfo("sleep", "10") { UseShellExecute = false @@ -103,5 +103,34 @@ public async Task GetCommandLine_WorksOnUnix() } } } + + [OSXOnlyFact] + public async Task GetCommandLine_WorksOnMacOS() + { + // On macOS, verify that sysctl-based command line retrieval works + var psi = new ProcessStartInfo("sleep", "15") + { + UseShellExecute = false + }; + + using Process p = Process.Start(psi); + try + { + await Task.Delay(500); + + string commandLine = p.GetCommandLine(); + commandLine.ShouldNotBeNull(); + commandLine.ShouldNotBeEmpty(); + commandLine.ShouldContain("sleep"); + commandLine.ShouldContain("15"); + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } } } From 230482e052465e5ff5160fc8cd1b43d838e8ba9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:10:43 +0000 Subject: [PATCH 11/33] Remove dotnet process expansion and use P/Invoke for all Windows builds Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 94 +++++++------------ src/Build/Microsoft.Build.csproj | 1 - src/Shared/ProcessExtensions.cs | 41 ++------ .../Microsoft.Build.Utilities.csproj | 4 - 4 files changed, 38 insertions(+), 102 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 6f3033fc2d7..0576c134ff0 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -456,88 +456,58 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } var expectedProcessName = Path.GetFileNameWithoutExtension(CurrentHost.GetCurrentHost() ?? msbuildLocation); - List candidateProcesses = []; - - // First, get all processes with the expected MSBuild executable name - try - { - var msbuildProcesses = Process.GetProcessesByName(expectedProcessName); - candidateProcesses.AddRange(msbuildProcesses); - } - catch - { - // Process enumeration failed, continue with empty list - } - - // Also get all dotnet processes that might be hosting MSBuild.dll + + // Get all processes with the expected MSBuild executable name + Process[] processes; try { - var dotnetProcesses = Process.GetProcessesByName("dotnet"); - candidateProcesses.AddRange(dotnetProcesses); + processes = Process.GetProcessesByName(expectedProcessName); } catch { - // Process enumeration failed for dotnet, continue + // Process enumeration failed, return empty list + return (expectedProcessName, Array.Empty()); } - // Filter processes by NodeMode if we have an expected value - List filteredProcesses = []; - foreach (var process in candidateProcesses) + // If we have an expected NodeMode, filter by command line parsing + if (expectedNodeMode.HasValue) { - try + List filteredProcesses = []; + foreach (var process in processes) { - string commandLine = process.GetCommandLine(); - if (commandLine is null) - { - // If we can't get the command line, skip this process - continue; - } - - // Check if this is an MSBuild process - // For dotnet processes, check if they're hosting MSBuild.dll - if (process.ProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase) && - !commandLine.Contains("MSBuild.dll", StringComparison.OrdinalIgnoreCase)) - { - // Not hosting MSBuild.dll, skip - continue; - } - - // Extract NodeMode from command line - NodeMode? processNodeMode = ExtractNodeModeFromCommandLine(commandLine); - - // If we have an expected NodeMode, only include processes that match - if (expectedNodeMode.HasValue) + try { - if (!processNodeMode.HasValue || processNodeMode.Value != expectedNodeMode.Value) + string commandLine = process.GetCommandLine(); + if (commandLine is null) { - // NodeMode doesn't match or couldn't be determined, skip + // If we can't get the command line, skip this process continue; } + + // Extract NodeMode from command line + NodeMode? processNodeMode = ExtractNodeModeFromCommandLine(commandLine); + + // Only include processes that match the expected NodeMode + if (processNodeMode.HasValue && processNodeMode.Value == expectedNodeMode.Value) + { + filteredProcesses.Add(process); + } } - else if (!processNodeMode.HasValue) + catch { - // No expected NodeMode, but couldn't determine this process's NodeMode, skip it + // If we encounter any error processing this process, skip it continue; } - - // This process is a valid candidate - filteredProcesses.Add(process); - } - catch - { - // If we encounter any error processing this process, skip it - continue; } - } - - // Sort by process ID for consistent ordering - filteredProcesses.Sort((left, right) => left.Id.CompareTo(right.Id)); - string description = expectedNodeMode.HasValue - ? $"{expectedProcessName} or dotnet (NodeMode={expectedNodeMode.Value})" - : $"{expectedProcessName} or dotnet"; + // Sort by process ID for consistent ordering + filteredProcesses.Sort((left, right) => left.Id.CompareTo(right.Id)); + return (expectedProcessName, filteredProcesses); + } - return (description, 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/Build/Microsoft.Build.csproj b/src/Build/Microsoft.Build.csproj index 0aa9ec80a48..157bc6fe2bb 100644 --- a/src/Build/Microsoft.Build.csproj +++ b/src/Build/Microsoft.Build.csproj @@ -73,7 +73,6 @@ - diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index cfcd9524dfd..52efb6bf7b5 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -1,20 +1,17 @@ // 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.IO; -using System.Text; -#if NET -using System; using System.Runtime.InteropServices; -#endif +using System.Text; namespace Microsoft.Build.Shared { internal static class ProcessExtensions { -#if NET - // P/Invoke declarations for getting process command line on Windows (.NET Core) + // P/Invoke declarations for getting process command line on Windows [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); @@ -59,6 +56,7 @@ private struct UNICODE_STRING private const int PROCESS_QUERY_INFORMATION = 0x0400; private const int PROCESS_VM_READ = 0x0010; +#if NET // P/Invoke declarations for getting process command line on macOS [DllImport("libc", SetLastError = true)] private static extern int sysctl( @@ -144,35 +142,11 @@ public static void KillTree(this Process process, int timeoutMilliseconds) } /// - /// Retrieves the command line on Windows. - /// On .NET Framework: Uses WMI (System.Management). - /// On .NET Core+: Uses Windows API P/Invoke to read from PEB. + /// Retrieves the command line on Windows using native Windows APIs. + /// Reads the command line from the Process Environment Block (PEB). /// private static string? GetCommandLineWindows(Process process) { -#if NETFRAMEWORK - try - { - // On .NET Framework, we can use WMI via System.Management - using var searcher = new System.Management.ManagementObjectSearcher( - $"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {process.Id}"); - - using System.Management.ManagementObjectCollection objects = searcher.Get(); - foreach (System.Management.ManagementBaseObject obj in objects) - { - using (obj) - { - return obj["CommandLine"]?.ToString(); - } - } - } - catch - { - // WMI query failed, fall through to return null - } - return null; -#else - // On .NET Core/5+, use native Windows API to read command line from process PEB try { return GetCommandLineWindowsNative(process.Id); @@ -182,10 +156,8 @@ public static void KillTree(this Process process, int timeoutMilliseconds) // Native API calls failed return null; } -#endif } -#if NET /// /// Retrieves the command line for a Windows process using native APIs. /// This reads the command line from the Process Environment Block (PEB). @@ -277,7 +249,6 @@ public static void KillTree(this Process process, int timeoutMilliseconds) } } } -#endif /// diff --git a/src/Utilities/Microsoft.Build.Utilities.csproj b/src/Utilities/Microsoft.Build.Utilities.csproj index ab9648077aa..c287f5b9f6a 100644 --- a/src/Utilities/Microsoft.Build.Utilities.csproj +++ b/src/Utilities/Microsoft.Build.Utilities.csproj @@ -31,10 +31,6 @@ - - - - From 0f5f861df4fd037034eb5b2746ddc4886ec5cb7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:19:21 +0000 Subject: [PATCH 12/33] Add MSBuild.dll filtering for dotnet processes Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../Communications/NodeProviderOutOfProcBase.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 0576c134ff0..3d003d065e5 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -473,6 +473,8 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte if (expectedNodeMode.HasValue) { List filteredProcesses = []; + bool isDotnetProcess = expectedProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase); + foreach (var process in processes) { try @@ -484,6 +486,12 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte continue; } + // If expected process is dotnet, filter to only those hosting MSBuild.dll + if (isDotnetProcess && !commandLine.Contains("MSBuild.dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + // Extract NodeMode from command line NodeMode? processNodeMode = ExtractNodeModeFromCommandLine(commandLine); From 1421cf74eff49ea84dd6b4f90cc0421db6122cb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:28:29 +0000 Subject: [PATCH 13/33] Fix macOS KERN_PROCARGS2 buffer parsing to skip padding nulls Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 37 +++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 52efb6bf7b5..17b65530bc9 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -351,8 +351,11 @@ public static void KillTree(this Process process, int timeoutMilliseconds) // The buffer format is: // - int argc (number of arguments, including the executable path as argv[0]) - // - argv[0] (executable path, null-terminated) - // - argv[1] .. argv[argc-1] (arguments, each a null-terminated string) + // - executable path (null-terminated, full path like /bin/sleep) + // - padding null bytes + // - argv[0] (executable name, e.g., "sleep") + // - argv[1] .. argv[argc-1] (arguments, each null-terminated) + // - environment variables (null-terminated strings, not needed) // Read argc int argc = Marshal.ReadInt32(buffer); @@ -365,23 +368,35 @@ public static void KillTree(this Process process, int timeoutMilliseconds) byte[] data = new byte[size]; Marshal.Copy(buffer, data, 0, (int)size); - // Skip the argc (4 bytes) and parse null-terminated strings - StringBuilder sb = new(); + // Skip the argc (4 bytes) and find the executable path int offset = sizeof(int); - // Parse all arguments (executable + arguments) + // Skip past the executable path (ends at first null) + while (offset < data.Length && data[offset] != 0) + { + offset++; + } + + // Skip all padding null bytes to reach the actual arguments + while (offset < data.Length && data[offset] == 0) + { + offset++; + } + + // Now parse argc null-terminated argument strings + StringBuilder sb = new(); for (int argIndex = 0; argIndex < argc && offset < data.Length; argIndex++) { // Find the next null terminator - int nullIndex = offset; - while (nullIndex < data.Length && data[nullIndex] != 0) + int start = offset; + while (offset < data.Length && data[offset] != 0) { - nullIndex++; + offset++; } - if (nullIndex > offset) + if (offset > start) { - string arg = System.Text.Encoding.UTF8.GetString(data, offset, nullIndex - offset); + string arg = System.Text.Encoding.UTF8.GetString(data, start, offset - start); if (sb.Length > 0) { sb.Append(' '); @@ -390,7 +405,7 @@ public static void KillTree(this Process process, int timeoutMilliseconds) } // Move past the null terminator - offset = nullIndex + 1; + offset++; } return sb.ToString(); From efe0d8ca06f67c78139e5660e28567bc56dd7040 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Tue, 17 Feb 2026 11:29:02 -0600 Subject: [PATCH 14/33] refactor native invocations a bit using LibraryImport instead of DllImport --- ...Microsoft.Build.Engine.OM.UnitTests.csproj | 1 - ...Microsoft.Build.Framework.UnitTests.csproj | 1 - src/Framework/NativeMethods.cs | 1 + ...crosoft.Build.CommandLine.UnitTests.csproj | 1 + src/Shared/ProcessExtensions.cs | 578 +++++++++--------- .../Microsoft.Build.Tasks.UnitTests.csproj | 1 - 6 files changed, 296 insertions(+), 287 deletions(-) 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/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..f6052f622c5 100644 --- a/src/Framework/NativeMethods.cs +++ b/src/Framework/NativeMethods.cs @@ -813,6 +813,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/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/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 17b65530bc9..bae18ee3812 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -3,74 +3,18 @@ using System; using System.Diagnostics; -using System.IO; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text; -namespace Microsoft.Build.Shared -{ - internal static class ProcessExtensions - { - // P/Invoke declarations for getting process command line on Windows - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool CloseHandle(IntPtr hObject); - - [DllImport("ntdll.dll")] - private static extern int NtQueryInformationProcess( - IntPtr processHandle, - int processInformationClass, - ref PROCESS_BASIC_INFORMATION processInformation, - int processInformationLength, - out int returnLength); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - [Out] byte[] lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); - - [StructLayout(LayoutKind.Sequential)] - private struct PROCESS_BASIC_INFORMATION - { - public IntPtr Reserved1; - public IntPtr PebBaseAddress; - public IntPtr Reserved2_0; - public IntPtr Reserved2_1; - public IntPtr UniqueProcessId; - public IntPtr InheritedFromUniqueProcessId; - } - - [StructLayout(LayoutKind.Sequential)] - private struct UNICODE_STRING - { - public ushort Length; - public ushort MaximumLength; - public IntPtr Buffer; - } - - private const int PROCESS_QUERY_INFORMATION = 0x0400; - private const int PROCESS_VM_READ = 0x0010; - #if NET - // P/Invoke declarations for getting process command line on macOS - [DllImport("libc", SetLastError = true)] - private static extern int sysctl( - [In] int[] name, - uint namelen, - IntPtr oldp, - ref ulong oldlenp, - IntPtr newp, - ulong newlen); - - private const int CTL_KERN = 1; - private const int KERN_PROCARGS2 = 49; +using System.IO; #endif +namespace Microsoft.Build.Shared +{ + internal static partial class ProcessExtensions + { public static void KillTree(this Process process, int timeoutMilliseconds) { #if NET @@ -80,10 +24,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. @@ -91,11 +34,11 @@ 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); } @@ -103,8 +46,8 @@ public static void KillTree(this Process process, int 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 + /// The process to get the command line for. + /// The command line string, or null if it cannot be retrieved. public static string? GetCommandLine(this Process? process) { if (process is null) @@ -114,7 +57,6 @@ public static void KillTree(this Process process, int timeoutMilliseconds) try { - // Check if the process has exited if (process.HasExited) { return null; @@ -122,303 +64,371 @@ public static void KillTree(this Process process, int timeoutMilliseconds) } catch { - // Process might have exited between null check and HasExited check + // Process might have exited between null check and HasExited check. return null; } try { - return NativeMethodsShared.IsWindows ? GetCommandLineWindows(process) : #if NET - NativeMethodsShared.IsOSX ? GetCommandLineMacOS(process.Id) : + return NativeMethodsShared.IsWindows ? Windows.GetCommandLine(process.Id) : + NativeMethodsShared.IsOSX ? MacOS.GetCommandLine(process.Id) : + NativeMethodsShared.IsLinux ? Linux.GetCommandLine(process.Id) : + throw new NotSupportedException(); +#else + return Windows.GetCommandLine(process.Id); #endif - GetCommandLineLinux(process.Id); } catch { - // If we can't retrieve the command line, return null return null; } } +#if NET /// - /// Retrieves the command line on Windows using native Windows APIs. - /// Reads the command line from the Process Environment Block (PEB). + /// 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 (sysctl KERN_PROCARGS2) parsing. /// - private static string? GetCommandLineWindows(Process process) + private static string ParseNullSeparatedArguments(ReadOnlySpan data, int maxArgs = int.MaxValue) { - try - { - return GetCommandLineWindowsNative(process.Id); - } - catch - { - // Native API calls failed - return null; - } - } + StringBuilder sb = new(data.Length); + int argsFound = 0; - /// - /// Retrieves the command line for a Windows process using native APIs. - /// This reads the command line from the Process Environment Block (PEB). - /// - private static string? GetCommandLineWindowsNative(int processId) - { - IntPtr hProcess = IntPtr.Zero; - try + while (!data.IsEmpty && argsFound < maxArgs) { - // Open the process with query and read permissions - hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); - if (hProcess == IntPtr.Zero) - { - return null; - } + int nullIndex = data.IndexOf((byte)0); + ReadOnlySpan segment = nullIndex >= 0 ? data.Slice(0, nullIndex) : data; - // Get process basic information to locate PEB - PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); - int returnLength; - int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); - if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) + if (!segment.IsEmpty) { - return null; - } + if (sb.Length > 0) + { + sb.Append(' '); + } - // Read the PEB to get the ProcessParameters pointer - // In 64-bit: PEB + 0x20 = ProcessParameters - // In 32-bit: PEB + 0x10 = ProcessParameters - int processParametersOffset = IntPtr.Size == 8 ? 0x20 : 0x10; - IntPtr processParametersPtr = IntPtr.Zero; - - byte[] ptrBuffer = new byte[IntPtr.Size]; - if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), ptrBuffer, ptrBuffer.Length, out _)) - { - return null; + sb.Append(Encoding.UTF8.GetString(segment)); + argsFound++; } - processParametersPtr = IntPtr.Size == 8 - ? new IntPtr(BitConverter.ToInt64(ptrBuffer, 0)) - : new IntPtr(BitConverter.ToInt32(ptrBuffer, 0)); - if (processParametersPtr == IntPtr.Zero) + if (nullIndex < 0) { - return null; + break; } - // Read the CommandLine UNICODE_STRING from ProcessParameters - // CommandLine is at offset 0x70 in 64-bit and 0x40 in 32-bit - int commandLineOffset = IntPtr.Size == 8 ? 0x70 : 0x40; - byte[] unicodeStringBuffer = new byte[Marshal.SizeOf(typeof(UNICODE_STRING))]; - if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), unicodeStringBuffer, unicodeStringBuffer.Length, out _)) - { - return null; - } + data = data.Slice(nullIndex + 1); + } - // Parse UNICODE_STRING structure - // Layout: ushort Length (2 bytes), ushort MaximumLength (2 bytes), [4 bytes padding on 64-bit], IntPtr Buffer - UNICODE_STRING commandLineUnicode = new UNICODE_STRING - { - Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), - MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), - Buffer = IntPtr.Size == 8 - ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 8)) // 4 bytes for ushorts + 4 bytes padding - : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) // 4 bytes for ushorts, no padding - }; - - if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) - { - return null; - } + return sb.ToString(); + } +#endif - // Read the actual command line string - byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; - if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) - { - return null; - } + /// + /// Windows-specific P/Invoke bindings and command line retrieval via the Process Environment Block (PEB). + /// + [SupportedOSPlatform("windows")] + private static partial class Windows + { +#if NET + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial IntPtr OpenProcess(int dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int dwProcessId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CloseHandle(IntPtr hObject); + + [LibraryImport("ntdll.dll")] + private static partial int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + ref PROCESS_BASIC_INFORMATION processInformation, + int processInformationLength, + out int returnLength); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + out IntPtr lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + out UNICODE_STRING lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + Span lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); +#else + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + ref PROCESS_BASIC_INFORMATION processInformation, + int processInformationLength, + out int returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + out IntPtr lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + out UNICODE_STRING lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + [Out] byte[] lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); +#endif - return Encoding.Unicode.GetString(commandLineBuffer); - } - catch + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_BASIC_INFORMATION { - return null; + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + public IntPtr Reserved2_0; + public IntPtr Reserved2_1; + public IntPtr UniqueProcessId; + public IntPtr InheritedFromUniqueProcessId; } - finally + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING { - if (hProcess != IntPtr.Zero) - { - CloseHandle(hProcess); - } + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; } - } + private const int PROCESS_QUERY_INFORMATION = 0x0400; + private const int PROCESS_VM_READ = 0x0010; - /// - /// Retrieves the command line on Linux by reading /proc/{pid}/cmdline. - /// - private static string? GetCommandLineLinux(int processId) - { - try + /// + /// Reads the command line from the Process Environment Block (PEB) of a Windows process. + /// Uses typed ReadProcessMemory overloads to read structured data directly, + /// avoiding manual byte[] allocation and BitConverter deserialization. + /// + internal static string? GetCommandLine(int processId) { - string cmdlinePath = $"/proc/{processId}/cmdline"; - if (!File.Exists(cmdlinePath)) + IntPtr hProcess = IntPtr.Zero; + try { - return null; - } + hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); + if (hProcess == IntPtr.Zero) + { + return null; + } - // Read the cmdline file. Arguments are separated by null characters. - // The file is typically encoded in the system's default encoding (usually UTF-8 on modern Linux). - byte[] cmdlineBytes = File.ReadAllBytes(cmdlinePath); - if (cmdlineBytes.Length == 0) - { - return null; - } + PROCESS_BASIC_INFORMATION pbi = default; + int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(), out _); + if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) + { + return null; + } - // Convert bytes to string, replacing null terminators with spaces. - // We need to handle null bytes specially since they're argument separators in /proc/pid/cmdline. - StringBuilder sb = new(cmdlineBytes.Length); - - int start = 0; - for (int i = 0; i < cmdlineBytes.Length; i++) - { - if (cmdlineBytes[i] == 0) + // Read the ProcessParameters pointer directly from PEB. + // Offset: 0x20 on 64-bit, 0x10 on 32-bit. + int processParametersOffset = IntPtr.Size == 8 ? 0x20 : 0x10; + if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), out IntPtr processParametersPtr, IntPtr.Size, out _) + || processParametersPtr == IntPtr.Zero) + { + return null; + } + + // Read the CommandLine UNICODE_STRING struct directly from ProcessParameters. + // Offset: 0x70 on 64-bit, 0x40 on 32-bit. + // The CLR handles struct alignment (including 4-byte padding on 64-bit) automatically + // via [StructLayout(LayoutKind.Sequential)], so no manual layout parsing is needed. + int commandLineOffset = IntPtr.Size == 8 ? 0x70 : 0x40; + if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), out UNICODE_STRING commandLineUnicode, Marshal.SizeOf(), out _) + || commandLineUnicode.Buffer == IntPtr.Zero + || commandLineUnicode.Length == 0) + { + return null; + } + + byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; + if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) { - if (i > start) - { - // Decode the argument using UTF-8 encoding - string currentArg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, i - start); - if (sb.Length > 0) - { - sb.Append(' '); - } - sb.Append(currentArg); - } - start = i + 1; + return null; } + + return Encoding.Unicode.GetString(commandLineBuffer); } - - // Handle any remaining bytes after the last null terminator - if (start < cmdlineBytes.Length) + catch { - string remainingArg = System.Text.Encoding.UTF8.GetString(cmdlineBytes, start, cmdlineBytes.Length - start); - if (remainingArg.Length > 0) + return null; + } + finally + { + if (hProcess != IntPtr.Zero) { - if (sb.Length > 0) - { - sb.Append(' '); - } - sb.Append(remainingArg); + CloseHandle(hProcess); } } - - return sb.ToString(); - } - catch - { - return null; } } #if NET /// - /// Retrieves the command line on macOS using sysctl with KERN_PROCARGS2. + /// Linux-specific command line retrieval via /proc/{pid}/cmdline. /// - private static string? GetCommandLineMacOS(int processId) + [SupportedOSPlatform("linux")] + private static class Linux { - try + /// + /// Reads /proc/{pid}/cmdline where arguments are null-byte separated, + /// and joins them with spaces. + /// + internal static string? GetCommandLine(int processId) { - // Use sysctl with CTL_KERN, KERN_PROCARGS2 to get process arguments - int[] mib = [CTL_KERN, KERN_PROCARGS2, processId]; - ulong size = 0; - - // First call to get the size of the buffer needed - if (sysctl(mib, (uint)mib.Length, IntPtr.Zero, ref size, IntPtr.Zero, 0) != 0) + try { - return null; - } + string cmdlinePath = $"/proc/{processId}/cmdline"; + if (!File.Exists(cmdlinePath)) + { + return null; + } + + byte[] cmdlineBytes = File.ReadAllBytes(cmdlinePath); + if (cmdlineBytes.Length == 0) + { + return null; + } - if (size == 0) + return ParseNullSeparatedArguments(cmdlineBytes); + } + catch { return null; } + } + } - // Allocate buffer and get the actual data - IntPtr buffer = Marshal.AllocHGlobal((int)size); + /// + /// macOS-specific P/Invoke bindings and command line retrieval via sysctl KERN_PROCARGS2. + /// + [SupportedOSPlatform("macos")] + private static partial class MacOS + { + [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. + /// + internal static string? GetCommandLine(int processId) + { try { - if (sysctl(mib, (uint)mib.Length, buffer, ref size, IntPtr.Zero, 0) != 0) + ReadOnlySpan mib = [CTL_KERN, KERN_PROCARGS2, processId]; + nuint size = 0; + + if (Sysctl(mib, Span.Empty, ref size) != 0) { return null; } - // The buffer format is: - // - int argc (number of arguments, including the executable path as argv[0]) - // - executable path (null-terminated, full path like /bin/sleep) - // - padding null bytes - // - argv[0] (executable name, e.g., "sleep") - // - argv[1] .. argv[argc-1] (arguments, each null-terminated) - // - environment variables (null-terminated strings, not needed) - - // Read argc - int argc = Marshal.ReadInt32(buffer); - if (argc <= 0) + if (size == 0) + { + return null; + } + + byte[] buffer = new byte[size]; + if (Sysctl(mib, buffer, ref size) != 0) { return null; } - // Copy the buffer to a byte array for easier parsing - byte[] data = new byte[size]; - Marshal.Copy(buffer, data, 0, (int)size); + // Buffer format: + // int argc + // 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); - // Skip the argc (4 bytes) and find the executable path - int offset = sizeof(int); - - // Skip past the executable path (ends at first null) - while (offset < data.Length && data[offset] != 0) + if (data.Length < sizeof(int)) { - offset++; + return null; } - - // Skip all padding null bytes to reach the actual arguments - while (offset < data.Length && data[offset] == 0) + + int argc = MemoryMarshal.Read(data); + if (argc <= 0) { - offset++; + return null; } - - // Now parse argc null-terminated argument strings - StringBuilder sb = new(); - for (int argIndex = 0; argIndex < argc && offset < data.Length; argIndex++) + + data = data.Slice(sizeof(int)); + + // Skip past the executable path (first null terminator) + int nullIndex = data.IndexOf((byte)0); + if (nullIndex < 0) { - // Find the next null terminator - int start = offset; - while (offset < data.Length && data[offset] != 0) - { - offset++; - } - - if (offset > start) - { - string arg = System.Text.Encoding.UTF8.GetString(data, start, offset - start); - if (sb.Length > 0) - { - sb.Append(' '); - } - sb.Append(arg); - } - - // Move past the null terminator - offset++; + return null; } - return sb.ToString(); + data = data.Slice(nullIndex + 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 + catch { - Marshal.FreeHGlobal(buffer); + return null; } } - catch - { - return null; - } } #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 @@ - From eb7e443e12d766573f5eb50c07370c02f2d355c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:11:22 +0000 Subject: [PATCH 15/33] Address review feedback: regex source gen, MacOSOnlyFact, change wave, logging, TOCTOU fix Co-authored-by: rainersigwald <3347530+rainersigwald@users.noreply.github.com> --- .../NodeProviderOutOfProcBase.cs | 29 ++++--- src/Shared/ProcessExtensions.cs | 21 +---- src/UnitTests.Shared/OSXOnlyFactAttribute.cs | 25 ------ .../ProcessExtensions_Tests.cs | 80 +++---------------- 4 files changed, 33 insertions(+), 122 deletions(-) delete mode 100644 src/UnitTests.Shared/OSXOnlyFactAttribute.cs diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 3d003d065e5..ce792a62639 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -10,6 +10,7 @@ using System.IO; using System.IO.Pipes; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Build.BackEnd.Logging; @@ -32,7 +33,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 @@ -57,10 +58,17 @@ internal abstract class NodeProviderOutOfProcBase /// /// Regex pattern for extracting /nodemode parameter from command lines. /// Uses the same pattern as DebugUtils.ScanNodeMode for consistency. + /// Non-capturing group for whitespace/end-of-string to avoid unnecessary captures. /// - private static readonly System.Text.RegularExpressions.Regex NodeModeRegex = new( - @"/nodemode:(?[1-9]\d*)(\s|$)", - System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); +#if NET + [GeneratedRegex(@"/nodemode:(?[1-9]\d*)(?:\s|$)", RegexOptions.IgnoreCase)] + private static partial Regex NodeModeRegex(); +#else + private static Regex NodeModeRegex() => NodeModeRegexInstance; + private static readonly Regex NodeModeRegexInstance = new( + @"/nodemode:(?[1-9]\d*)(?:\s|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); +#endif #if !FEATURE_PIPEOPTIONS_CURRENTUSERONLY private static readonly WindowsIdentity s_currentWindowsIdentity = WindowsIdentity.GetCurrent(); @@ -419,8 +427,8 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte return null; } - // Use static compiled regex for better performance - var match = NodeModeRegex.Match(commandLine); + // Use regex to extract nodemode parameter + var match = NodeModeRegex().Match(commandLine); if (!match.Success) { @@ -470,10 +478,10 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } // If we have an expected NodeMode, filter by command line parsing - if (expectedNodeMode.HasValue) + if (expectedNodeMode.HasValue && ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_4)) { List filteredProcesses = []; - bool isDotnetProcess = expectedProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase); + bool isDotnetProcess = expectedProcessName.Equals(Path.GetFileNameWithoutExtension(Constants.DotnetProcessName), StringComparison.OrdinalIgnoreCase); foreach (var process in processes) { @@ -501,9 +509,10 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte filteredProcesses.Add(process); } } - catch + catch (Exception ex) { - // If we encounter any error processing this process, skip it + // 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; } } diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index bae18ee3812..087bbb83b16 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -50,24 +50,12 @@ public static void KillTree(this Process process, int timeoutMilliseconds) /// The command line string, or null if it cannot be retrieved. public static string? GetCommandLine(this Process? process) { - if (process is null) + // Check if process is null or has exited + if (process?.HasExited != false) { return null; } - try - { - if (process.HasExited) - { - return null; - } - } - catch - { - // Process might have exited between null check and HasExited check. - return null; - } - try { #if NET @@ -315,11 +303,6 @@ private static class Linux try { string cmdlinePath = $"/proc/{processId}/cmdline"; - if (!File.Exists(cmdlinePath)) - { - return null; - } - byte[] cmdlineBytes = File.ReadAllBytes(cmdlinePath); if (cmdlineBytes.Length == 0) { diff --git a/src/UnitTests.Shared/OSXOnlyFactAttribute.cs b/src/UnitTests.Shared/OSXOnlyFactAttribute.cs deleted file mode 100644 index 0ac5714bbeb..00000000000 --- a/src/UnitTests.Shared/OSXOnlyFactAttribute.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Runtime.InteropServices; -using Microsoft.Build.Shared; -using Xunit; - -namespace Microsoft.Build.UnitTests -{ - /// - /// A custom that skips the test if not running on macOS. - /// - public sealed class OSXOnlyFactAttribute : FactAttribute - { - public OSXOnlyFactAttribute(string? additionalMessage = null) - { - if (!NativeMethodsShared.IsOSX) - { - Skip = additionalMessage != null - ? $"This test only runs on macOS. {additionalMessage}" - : "This test only runs on macOS."; - } - } - } -} diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index a44132e0d13..f21d4075cf2 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Build.Shared; +using Microsoft.DotNet.XUnitExtensions; using Shouldly; using Xunit; @@ -51,81 +52,24 @@ public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() string commandLine = p.GetCommandLine(); - // The current implementation uses native Windows APIs on .NET Core+ and /proc or sysctl on Unix, - // so command line retrieval should generally work on all supported platforms. - // However, to remain robust in constrained environments, we only verify it does not throw - // and that any non-null result is non-empty. - if (commandLine != null) - { - commandLine.ShouldNotBeEmpty(); - - // On Unix, we should be able to get the command line - if (!NativeMethodsShared.IsWindows) - { - commandLine.ShouldContain("sleep"); - } - } - } - finally - { - // Clean up - if (!p.HasExited) - { - p.KillTree(5000); - } - } - } - - [UnixOnlyFact] - public async Task GetCommandLine_WorksOnUnix() - { - // On Unix (Linux and macOS), we should be able to read command lines - var psi = new ProcessStartInfo("sleep", "10") - { - UseShellExecute = false - }; - - using Process p = Process.Start(psi); - try - { - await Task.Delay(500); - - string commandLine = p.GetCommandLine(); + // Command line retrieval should work on all platforms commandLine.ShouldNotBeNull(); commandLine.ShouldNotBeEmpty(); - commandLine.ShouldContain("sleep"); - } - finally - { - if (!p.HasExited) + + // Verify we get the expected process name + if (NativeMethodsShared.IsWindows) { - p.KillTree(5000); + commandLine.ShouldContain("cmd"); + } + else + { + commandLine.ShouldContain("sleep"); + commandLine.ShouldContain("10"); } - } - } - - [OSXOnlyFact] - public async Task GetCommandLine_WorksOnMacOS() - { - // On macOS, verify that sysctl-based command line retrieval works - var psi = new ProcessStartInfo("sleep", "15") - { - UseShellExecute = false - }; - - using Process p = Process.Start(psi); - try - { - await Task.Delay(500); - - string commandLine = p.GetCommandLine(); - commandLine.ShouldNotBeNull(); - commandLine.ShouldNotBeEmpty(); - commandLine.ShouldContain("sleep"); - commandLine.ShouldContain("15"); } finally { + // Clean up if (!p.HasExited) { p.KillTree(5000); From 0751a1bd29b8526fb12da12c1d2c83c24277c998 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Tue, 17 Feb 2026 14:39:19 -0600 Subject: [PATCH 16/33] add span-based nodemode parser method --- src/Framework/NodeMode.cs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Framework/NodeMode.cs b/src/Framework/NodeMode.cs index 97c60854006..5fdb83eb2f9 100644 --- a/src/Framework/NodeMode.cs +++ b/src/Framework/NodeMode.cs @@ -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 TryParseCore(value.AsSpan(), out nodeMode); +#else + return TryParseCore(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 TryParseCore(value, out nodeMode); + } +#endif + +#if NET + private static bool TryParseCore(ReadOnlySpan value, [NotNullWhen(true)] out NodeMode? nodeMode) +#else + private static bool TryParseCore(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; } From 1c3bc0eb83ef7f971218fd30e8a546a38a5d7ce0 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Tue, 17 Feb 2026 14:59:12 -0600 Subject: [PATCH 17/33] add string nodemode detection to regex, make it generated, and share it across both locations that tried to parse node modes --- .../NodeProviderOutOfProcBase.cs | 37 +------------- src/Framework/NodeMode.cs | 48 ++++++++++++++++++- src/Shared/Debugging/DebugUtils.cs | 27 +---------- 3 files changed, 50 insertions(+), 62 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index ce792a62639..5f96f64bbb6 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -55,20 +55,7 @@ internal abstract partial class NodeProviderOutOfProcBase /// private const int TimeoutForWaitForExit = 30000; - /// - /// Regex pattern for extracting /nodemode parameter from command lines. - /// Uses the same pattern as DebugUtils.ScanNodeMode for consistency. - /// Non-capturing group for whitespace/end-of-string to avoid unnecessary captures. - /// -#if NET - [GeneratedRegex(@"/nodemode:(?[1-9]\d*)(?:\s|$)", RegexOptions.IgnoreCase)] - private static partial Regex NodeModeRegex(); -#else - private static Regex NodeModeRegex() => NodeModeRegexInstance; - private static readonly Regex NodeModeRegexInstance = new( - @"/nodemode:(?[1-9]\d*)(?:\s|$)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); -#endif + #if !FEATURE_PIPEOPTIONS_CURRENTUSERONLY private static readonly WindowsIdentity s_currentWindowsIdentity = WindowsIdentity.GetCurrent(); @@ -422,27 +409,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte /// The NodeMode if found, otherwise null private static NodeMode? ExtractNodeModeFromCommandLine(string commandLine) { - if (string.IsNullOrWhiteSpace(commandLine)) - { - return null; - } - - // Use regex to extract nodemode parameter - var match = NodeModeRegex().Match(commandLine); - - if (!match.Success) - { - return null; - } - - string nodeModeValue = match.Groups["nodemode"].Value; - - if (NodeModeHelper.TryParse(nodeModeValue, out NodeMode? nodeMode)) - { - return nodeMode; - } - - return null; + return NodeModeHelper.ExtractFromCommandLine(commandLine); } /// diff --git a/src/Framework/NodeMode.cs b/src/Framework/NodeMode.cs index 5fdb83eb2f9..e2f8f13995a 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. @@ -115,5 +115,51 @@ private static bool TryParseCore(string value, [NotNullWhen(true)] out NodeMode? 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; + } +#if NET + // Use compiled regex for better performance on .NET + var match = CommandLineNodeModeRegex().Match(commandLine); +#else + var match = CommandLineNodeModeRegexInstance.Match(commandLine); +#endif + + if (!match.Success) + { + return null; + } + + string nodeModeValue = match.Groups["nodemode"].Value; + +#if NET + if (TryParse(nodeModeValue.AsSpan(), out NodeMode? nodeMode)) +#else + if (TryParse(nodeModeValue, out NodeMode? nodeMode)) +#endif + { + return nodeMode; + } + + return null; + } + +#if NET + [System.Text.RegularExpressions.GeneratedRegex(@"/nodemode:(?[a-zA-Z0-9]+)(?:\s|$)", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex CommandLineNodeModeRegex(); +#else + private static readonly System.Text.RegularExpressions.Regex CommandLineNodeModeRegexInstance = new( + @"/nodemode:(?[a-zA-Z0-9]+)(?:\s|$)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); +#endif } } diff --git a/src/Shared/Debugging/DebugUtils.cs b/src/Shared/Debugging/DebugUtils.cs index 87ed7798304..ded3288235c 100644 --- a/src/Shared/Debugging/DebugUtils.cs +++ b/src/Shared/Debugging/DebugUtils.cs @@ -57,32 +57,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() { From d4d029057f3472586657fe79fac8b1592320cf36 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Tue, 17 Feb 2026 15:02:44 -0600 Subject: [PATCH 18/33] minimize diffs --- .../Communications/NodeProviderOutOfProcBase.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 5f96f64bbb6..1a86de0214a 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -55,8 +55,6 @@ internal abstract partial class NodeProviderOutOfProcBase /// private const int TimeoutForWaitForExit = 30000; - - #if !FEATURE_PIPEOPTIONS_CURRENTUSERONLY private static readonly WindowsIdentity s_currentWindowsIdentity = WindowsIdentity.GetCurrent(); #endif @@ -402,16 +400,6 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } } - /// - /// Extracts the NodeMode from a command line string using regex pattern matching. - /// - /// The command line to parse - /// The NodeMode if found, otherwise null - private static NodeMode? ExtractNodeModeFromCommandLine(string commandLine) - { - return NodeModeHelper.ExtractFromCommandLine(commandLine); - } - /// /// Finds processes that could be reusable MSBuild nodes. /// Discovers both msbuild.exe processes and dotnet processes hosting MSBuild.dll. @@ -468,7 +456,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } // Extract NodeMode from command line - NodeMode? processNodeMode = ExtractNodeModeFromCommandLine(commandLine); + NodeMode? processNodeMode = NodeModeHelper.ExtractFromCommandLine(commandLine) // Only include processes that match the expected NodeMode if (processNodeMode.HasValue && processNodeMode.Value == expectedNodeMode.Value) From ae5368a2d46e8c07b353c0d89a2e27660a9b8b90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:31:53 +0000 Subject: [PATCH 19/33] Create Wave18_6 and use it for node discovery feature Co-authored-by: rainersigwald <3347530+rainersigwald@users.noreply.github.com> --- .../Components/Communications/NodeProviderOutOfProcBase.cs | 7 +++---- src/Framework/ChangeWaves.cs | 3 ++- src/Shared/Debugging/DebugUtils.cs | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 1a86de0214a..af9f2ba2a9f 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -10,7 +10,6 @@ using System.IO; using System.IO.Pipes; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Build.BackEnd.Logging; @@ -221,7 +220,7 @@ protected IList GetNodes( bool nodeReuseRequested = Handshake.IsHandshakeOptionEnabled(hostHandshake.HandshakeOptions, HandshakeOptions.NodeReuse); // Extract the expected NodeMode from the command line arguments - NodeMode? expectedNodeMode = ExtractNodeModeFromCommandLine(commandLineArgs); + 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 @@ -433,7 +432,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } // If we have an expected NodeMode, filter by command line parsing - if (expectedNodeMode.HasValue && ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_4)) + if (expectedNodeMode.HasValue && ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) { List filteredProcesses = []; bool isDotnetProcess = expectedProcessName.Equals(Path.GetFileNameWithoutExtension(Constants.DotnetProcessName), StringComparison.OrdinalIgnoreCase); @@ -456,7 +455,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } // Extract NodeMode from command line - NodeMode? processNodeMode = NodeModeHelper.ExtractFromCommandLine(commandLine) + NodeMode? processNodeMode = NodeModeHelper.ExtractFromCommandLine(commandLine); // Only include processes that match the expected NodeMode if (processNodeMode.HasValue && processNodeMode.Value == expectedNodeMode.Value) diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs index 22049fdfc65..c575107d672 100644 --- a/src/Framework/ChangeWaves.cs +++ b/src/Framework/ChangeWaves.cs @@ -32,7 +32,8 @@ internal static class ChangeWaves internal static readonly Version Wave17_14 = new Version(17, 14); internal static readonly Version Wave18_3 = new Version(18, 3); internal static readonly Version Wave18_4 = new Version(18, 4); - internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4]; + internal static readonly Version Wave18_6 = new Version(18, 6); + internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_6]; /// /// Special value indicating that all features behind all Change Waves should be enabled. diff --git a/src/Shared/Debugging/DebugUtils.cs b/src/Shared/Debugging/DebugUtils.cs index ded3288235c..1ecf861dc1c 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,7 +55,7 @@ internal static void SetDebugPath() } private static readonly Lazy ProcessNodeMode = new( - () => NodeModeHelper.ExtractFromCommandLine(Environment.CommandLine); + () => NodeModeHelper.ExtractFromCommandLine(Environment.CommandLine)); private static bool CurrentProcessMatchesDebugName() { From 3835e439bc6249f9b3969a5d69d9c58ad698ea0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:38:01 +0000 Subject: [PATCH 20/33] Fix build error and optimize regex: add missing semicolon, extract pattern constant, use ValueSpan Co-authored-by: rainersigwald <3347530+rainersigwald@users.noreply.github.com> --- src/Framework/NodeMode.cs | 24 ++++++++++-------------- src/Shared/Debugging/DebugUtils.cs | 2 +- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Framework/NodeMode.cs b/src/Framework/NodeMode.cs index e2f8f13995a..15f62f5b022 100644 --- a/src/Framework/NodeMode.cs +++ b/src/Framework/NodeMode.cs @@ -127,24 +127,18 @@ private static bool TryParseCore(string value, [NotNullWhen(true)] out NodeMode? { return null; } -#if NET - // Use compiled regex for better performance on .NET - var match = CommandLineNodeModeRegex().Match(commandLine); -#else - var match = CommandLineNodeModeRegexInstance.Match(commandLine); -#endif + + var match = CommandLineNodeModeRegex.Match(commandLine); if (!match.Success) { return null; } - string nodeModeValue = match.Groups["nodemode"].Value; - #if NET - if (TryParse(nodeModeValue.AsSpan(), out NodeMode? nodeMode)) + if (TryParse(match.Groups["nodemode"].ValueSpan, out NodeMode? nodeMode)) #else - if (TryParse(nodeModeValue, out NodeMode? nodeMode)) + if (TryParse(match.Groups["nodemode"].Value, out NodeMode? nodeMode)) #endif { return nodeMode; @@ -153,12 +147,14 @@ private static bool TryParseCore(string value, [NotNullWhen(true)] out NodeMode? return null; } + private const string CommandLineNodeModePattern = @"/nodemode:(?[a-zA-Z0-9]+)(?:\s|$)"; + #if NET - [System.Text.RegularExpressions.GeneratedRegex(@"/nodemode:(?[a-zA-Z0-9]+)(?:\s|$)", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] - private static partial System.Text.RegularExpressions.Regex CommandLineNodeModeRegex(); + [System.Text.RegularExpressions.GeneratedRegex(CommandLineNodeModePattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex CommandLineNodeModeRegex { get; } #else - private static readonly System.Text.RegularExpressions.Regex CommandLineNodeModeRegexInstance = new( - @"/nodemode:(?[a-zA-Z0-9]+)(?:\s|$)", + 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/Shared/Debugging/DebugUtils.cs b/src/Shared/Debugging/DebugUtils.cs index 1ecf861dc1c..5dfb1573ae4 100644 --- a/src/Shared/Debugging/DebugUtils.cs +++ b/src/Shared/Debugging/DebugUtils.cs @@ -55,7 +55,7 @@ internal static void SetDebugPath() } private static readonly Lazy ProcessNodeMode = new( - () => NodeModeHelper.ExtractFromCommandLine(Environment.CommandLine)); + () => NodeModeHelper.ExtractFromCommandLine(Environment.CommandLine)); private static bool CurrentProcessMatchesDebugName() { From 9730a548d15cd906f320a5406c070bf3cee3637f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:28:50 +0000 Subject: [PATCH 21/33] Add comprehensive trace logging for node discovery filtering Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../Communications/NodeProviderOutOfProcBase.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index af9f2ba2a9f..28bf7fb699c 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -434,6 +434,8 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte // If we have an expected NodeMode, filter by command line parsing if (expectedNodeMode.HasValue && ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) { + 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); @@ -445,12 +447,14 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte if (commandLine is null) { // 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 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; } @@ -460,8 +464,14 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte // 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) { @@ -473,6 +483,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte // 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); } From f9ae2923a2eef0f07309bf7bfcf048effa708200 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Tue, 17 Feb 2026 17:10:52 -0600 Subject: [PATCH 22/33] Support fallback on BSD systems --- .../NodeProviderOutOfProcBase.cs | 11 ++++++++-- src/Shared/ProcessExtensions.cs | 22 +++++++++++-------- .../ProcessExtensions_Tests.cs | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index 28bf7fb699c..733dd57377b 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -443,14 +443,21 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte { try { - string commandLine = process.GetCommandLine(); - if (commandLine is null) + 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("Skipping process {0} - command line is null, unsupported 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)) { diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 087bbb83b16..69f1d2943d6 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -47,29 +47,33 @@ public static void KillTree(this Process process, int 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. - public static string? GetCommandLine(this Process? process) + /// 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) { - // Check if process is null or has exited + commandLine = null; + if (process?.HasExited != false) { - return null; + return false; } try { #if NET - return NativeMethodsShared.IsWindows ? Windows.GetCommandLine(process.Id) : + commandLine = NativeMethodsShared.IsWindows ? Windows.GetCommandLine(process.Id) : NativeMethodsShared.IsOSX ? MacOS.GetCommandLine(process.Id) : - NativeMethodsShared.IsLinux ? Linux.GetCommandLine(process.Id) : - throw new NotSupportedException(); + NativeMethodsShared.IsLinux ? Linux.GetCommandLine(process.Id) : + null; // If we don't have a platform-specific implementation, just return true with a null command line, since the caller should be able to handle that case. + return true; #else - return Windows.GetCommandLine(process.Id); + commandLine = Windows.GetCommandLine(process.Id); + return true; #endif } catch { - return null; + return false; } } diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index f21d4075cf2..295d3cad63f 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -50,7 +50,7 @@ public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() // Give the process time to start await Task.Delay(500); - string commandLine = p.GetCommandLine(); + p.TryGetCommandLine(out string commandLine).ShouldBeTrue(); // Command line retrieval should work on all platforms commandLine.ShouldNotBeNull(); From dfa51d72f6db587ae3149392fb19b31ec508c5d3 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Wed, 18 Feb 2026 11:45:31 -0600 Subject: [PATCH 23/33] Return to basics because I am bad at low-level marshalling --- src/Shared/ProcessExtensions.cs | 148 +++++++++++++++----------------- 1 file changed, 71 insertions(+), 77 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 69f1d2943d6..7d3dbee63af 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -137,24 +137,6 @@ private static partial int NtQueryInformationProcess( int processInformationLength, out int returnLength); - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - out IntPtr lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - out UNICODE_STRING lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); - [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool ReadProcessMemory( @@ -178,22 +160,6 @@ private static extern int NtQueryInformationProcess( int processInformationLength, out int returnLength); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - out IntPtr lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - out UNICODE_STRING lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); - [DllImport("kernel32.dll", SetLastError = true)] private static extern bool ReadProcessMemory( IntPtr hProcess, @@ -233,62 +199,90 @@ private struct UNICODE_STRING internal static string? GetCommandLine(int processId) { IntPtr hProcess = IntPtr.Zero; - try + try + { + // Open the process with query and read permissions + hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); + if (hProcess == IntPtr.Zero) { - hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); - if (hProcess == IntPtr.Zero) - { - return null; - } + return null; + } - PROCESS_BASIC_INFORMATION pbi = default; - int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(), out _); - if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) - { - return null; - } + // Get process basic information to locate PEB + PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); + int returnLength; + int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); + if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) + { + return null; + } - // Read the ProcessParameters pointer directly from PEB. - // Offset: 0x20 on 64-bit, 0x10 on 32-bit. - int processParametersOffset = IntPtr.Size == 8 ? 0x20 : 0x10; - if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), out IntPtr processParametersPtr, IntPtr.Size, out _) - || processParametersPtr == IntPtr.Zero) - { - return null; - } + // Read the PEB to get the ProcessParameters pointer + // In 64-bit: PEB + 0x20 = ProcessParameters + // In 32-bit: PEB + 0x10 = ProcessParameters + int processParametersOffset = IntPtr.Size == 8 ? 0x20 : 0x10; + IntPtr processParametersPtr = IntPtr.Zero; + + byte[] ptrBuffer = new byte[IntPtr.Size]; + if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), ptrBuffer, ptrBuffer.Length, out _)) + { + return null; + } + processParametersPtr = IntPtr.Size == 8 + ? new IntPtr(BitConverter.ToInt64(ptrBuffer, 0)) + : new IntPtr(BitConverter.ToInt32(ptrBuffer, 0)); - // Read the CommandLine UNICODE_STRING struct directly from ProcessParameters. - // Offset: 0x70 on 64-bit, 0x40 on 32-bit. - // The CLR handles struct alignment (including 4-byte padding on 64-bit) automatically - // via [StructLayout(LayoutKind.Sequential)], so no manual layout parsing is needed. - int commandLineOffset = IntPtr.Size == 8 ? 0x70 : 0x40; - if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), out UNICODE_STRING commandLineUnicode, Marshal.SizeOf(), out _) - || commandLineUnicode.Buffer == IntPtr.Zero - || commandLineUnicode.Length == 0) - { - return null; - } + if (processParametersPtr == IntPtr.Zero) + { + return null; + } - byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; - if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) - { - return null; - } + // Read the CommandLine UNICODE_STRING from ProcessParameters + // CommandLine is at offset 0x70 in 64-bit and 0x40 in 32-bit + int commandLineOffset = IntPtr.Size == 8 ? 0x70 : 0x40; + byte[] unicodeStringBuffer = new byte[Marshal.SizeOf(typeof(UNICODE_STRING))]; + if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), unicodeStringBuffer, unicodeStringBuffer.Length, out _)) + { + return null; + } - return Encoding.Unicode.GetString(commandLineBuffer); + // Parse UNICODE_STRING structure + // Layout: ushort Length (2 bytes), ushort MaximumLength (2 bytes), [4 bytes padding on 64-bit], IntPtr Buffer + UNICODE_STRING commandLineUnicode = new UNICODE_STRING + { + Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), + MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), + Buffer = IntPtr.Size == 8 + ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 8)) // 4 bytes for ushorts + 4 bytes padding + : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) // 4 bytes for ushorts, no padding + }; + + if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) + { + return null; } - catch + + // Read the actual command line string + byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; + if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) { return null; } - finally + + return Encoding.Unicode.GetString(commandLineBuffer); + } + catch + { + return null; + } + finally + { + if (hProcess != IntPtr.Zero) { - if (hProcess != IntPtr.Zero) - { - CloseHandle(hProcess); - } + CloseHandle(hProcess); } } + } } #if NET From 95da05c9ebf5e4b7e1aac33fe777894a4bee92bf Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Wed, 18 Feb 2026 15:33:28 -0600 Subject: [PATCH 24/33] support checking for target process architecture --- src/Shared/ProcessExtensions.cs | 173 +++++++++++++++++++------------- 1 file changed, 102 insertions(+), 71 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 7d3dbee63af..8696cad0147 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -125,6 +125,13 @@ private static partial class Windows [LibraryImport("kernel32.dll", SetLastError = true)] private static partial IntPtr OpenProcess(int dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int dwProcessId); + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool IsWow64Process2( + IntPtr hProcess, + out ushort processMachine, + out ushort nativeMachine); + [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool CloseHandle(IntPtr hObject); @@ -149,6 +156,12 @@ private static partial bool ReadProcessMemory( [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsWow64Process2( + IntPtr hProcess, + out ushort processMachine, + out ushort nativeMachine); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr hObject); @@ -191,6 +204,15 @@ private struct UNICODE_STRING private const int PROCESS_QUERY_INFORMATION = 0x0400; private const int PROCESS_VM_READ = 0x0010; + /// + /// The process is the same architecture as the executing host (e.g. 64-bit process on 64-bit Windows or 32-bit process on 32-bit Windows). + /// + private const int IMAGE_FILE_MACHINE_UNKNOWN = 0x0000; + /// + /// The process is a 32-bit process running under WOW64 on 64-bit Windows. + /// + private const int IMAGE_FILE_MACHINE_I386 = 0x014c; + /// /// Reads the command line from the Process Environment Block (PEB) of a Windows process. /// Uses typed ReadProcessMemory overloads to read structured data directly, @@ -199,90 +221,99 @@ private struct UNICODE_STRING internal static string? GetCommandLine(int processId) { IntPtr hProcess = IntPtr.Zero; - try - { - // Open the process with query and read permissions - hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); - if (hProcess == IntPtr.Zero) + try { - return null; - } + // Open the process with query and read permissions + hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); + if (hProcess == IntPtr.Zero) + { + return null; + } - // Get process basic information to locate PEB - PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); - int returnLength; - int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); - if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) - { - return null; - } + // Get process basic information to locate PEB + PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); + int returnLength; + int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); + if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) + { + return null; + } - // Read the PEB to get the ProcessParameters pointer - // In 64-bit: PEB + 0x20 = ProcessParameters - // In 32-bit: PEB + 0x10 = ProcessParameters - int processParametersOffset = IntPtr.Size == 8 ? 0x20 : 0x10; - IntPtr processParametersPtr = IntPtr.Zero; - - byte[] ptrBuffer = new byte[IntPtr.Size]; - if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), ptrBuffer, ptrBuffer.Length, out _)) - { - return null; - } - processParametersPtr = IntPtr.Size == 8 - ? new IntPtr(BitConverter.ToInt64(ptrBuffer, 0)) - : new IntPtr(BitConverter.ToInt32(ptrBuffer, 0)); + bool is64BitProcess = true; + if (IsWow64Process2(hProcess, out ushort processMachine, out ushort nativeMachine)) + { + if (processMachine == IMAGE_FILE_MACHINE_I386 || (IntPtr.Size == 4 && processMachine == IMAGE_FILE_MACHINE_UNKNOWN)) + { + is64BitProcess = false; + } + } - if (processParametersPtr == IntPtr.Zero) - { - return null; - } + // Read the PEB to get the ProcessParameters pointer + // In 64-bit: PEB + 0x20 = ProcessParameters + // In 32-bit: PEB + 0x10 = ProcessParameters + int processParametersOffset = is64BitProcess ? 0x20 : 0x10; + IntPtr processParametersPtr = IntPtr.Zero; - // Read the CommandLine UNICODE_STRING from ProcessParameters - // CommandLine is at offset 0x70 in 64-bit and 0x40 in 32-bit - int commandLineOffset = IntPtr.Size == 8 ? 0x70 : 0x40; - byte[] unicodeStringBuffer = new byte[Marshal.SizeOf(typeof(UNICODE_STRING))]; - if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), unicodeStringBuffer, unicodeStringBuffer.Length, out _)) - { - return null; - } + byte[] ptrBuffer = new byte[IntPtr.Size]; + if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), ptrBuffer, ptrBuffer.Length, out _)) + { + return null; + } + processParametersPtr = is64BitProcess + ? new IntPtr(BitConverter.ToInt64(ptrBuffer, 0)) + : new IntPtr(BitConverter.ToInt32(ptrBuffer, 0)); - // Parse UNICODE_STRING structure - // Layout: ushort Length (2 bytes), ushort MaximumLength (2 bytes), [4 bytes padding on 64-bit], IntPtr Buffer - UNICODE_STRING commandLineUnicode = new UNICODE_STRING - { - Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), - MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), - Buffer = IntPtr.Size == 8 - ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 8)) // 4 bytes for ushorts + 4 bytes padding - : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) // 4 bytes for ushorts, no padding - }; - - if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) - { - return null; - } + if (processParametersPtr == IntPtr.Zero) + { + return null; + } - // Read the actual command line string - byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; - if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) + // Read the CommandLine UNICODE_STRING from ProcessParameters + // CommandLine is at offset 0x70 in 64-bit and 0x40 in 32-bit + int commandLineOffset = is64BitProcess ? 0x70 : 0x40; + byte[] unicodeStringBuffer = new byte[Marshal.SizeOf(typeof(UNICODE_STRING))]; + if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), unicodeStringBuffer, unicodeStringBuffer.Length, out _)) + { + return null; + } + + // Parse UNICODE_STRING structure + // Layout: ushort Length (2 bytes), ushort MaximumLength (2 bytes), [4 bytes padding on 64-bit], IntPtr Buffer + UNICODE_STRING commandLineUnicode = new UNICODE_STRING + { + Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), + MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), + Buffer = is64BitProcess + ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 8)) // 4 bytes for ushorts + 4 bytes padding + : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) // 4 bytes for ushorts, no padding + }; + + if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) + { + return null; + } + + // Read the actual command line string + byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; + if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) + { + return null; + } + + return Encoding.Unicode.GetString(commandLineBuffer); + } + catch { return null; } - - return Encoding.Unicode.GetString(commandLineBuffer); - } - catch - { - return null; - } - finally - { - if (hProcess != IntPtr.Zero) + finally { - CloseHandle(hProcess); + if (hProcess != IntPtr.Zero) + { + CloseHandle(hProcess); + } } } - } } #if NET From e29eb81b1122595ac914660554459857a7cb9b1a Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 20 Feb 2026 16:49:50 -0600 Subject: [PATCH 25/33] wmi for windows --- src/Shared/ProcessExtensions.cs | 624 +++++++++++++----- .../ProcessExtensions_GetCommandLine_Tests.cs | 257 ++++++++ 2 files changed, 711 insertions(+), 170 deletions(-) create mode 100644 src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 8696cad0147..62747358261 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -5,9 +5,9 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; -using System.Text; #if NET +using System.Text; using System.IO; #endif @@ -116,203 +116,487 @@ private static string ParseNullSeparatedArguments(ReadOnlySpan data, int m #endif /// - /// Windows-specific P/Invoke bindings and command line retrieval via the Process Environment Block (PEB). + /// Windows-specific command line retrieval via WMI COM interfaces. + /// Queries Win32_Process for the CommandLine property using IWbemLocator/IWbemServices. /// [SupportedOSPlatform("windows")] - private static partial class Windows + private static class Windows { -#if NET - [LibraryImport("kernel32.dll", SetLastError = true)] - private static partial IntPtr OpenProcess(int dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int dwProcessId); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool IsWow64Process2( - IntPtr hProcess, - out ushort processMachine, - out ushort nativeMachine); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool CloseHandle(IntPtr hObject); - - [LibraryImport("ntdll.dll")] - private static partial int NtQueryInformationProcess( - IntPtr processHandle, - int processInformationClass, - ref PROCESS_BASIC_INFORMATION processInformation, - int processInformationLength, - out int returnLength); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - Span lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); -#else - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool IsWow64Process2( - IntPtr hProcess, - out ushort processMachine, - out ushort nativeMachine); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool CloseHandle(IntPtr hObject); - - [DllImport("ntdll.dll")] - private static extern int NtQueryInformationProcess( - IntPtr processHandle, - int processInformationClass, - ref PROCESS_BASIC_INFORMATION processInformation, - int processInformationLength, - out int returnLength); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool ReadProcessMemory( - IntPtr hProcess, - IntPtr lpBaseAddress, - [Out] byte[] lpBuffer, - int dwSize, - out int lpNumberOfBytesRead); -#endif + // 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); + + [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); + } - [StructLayout(LayoutKind.Sequential)] - private struct PROCESS_BASIC_INFORMATION + [Guid("44ACA674-E8FC-11D0-A07C-00C04FB68820")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [ComImport] + internal interface IWbemContext { - public IntPtr Reserved1; - public IntPtr PebBaseAddress; - public IntPtr Reserved2_0; - public IntPtr Reserved2_1; - public IntPtr UniqueProcessId; - public IntPtr InheritedFromUniqueProcessId; + [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(); } - [StructLayout(LayoutKind.Sequential)] - private struct UNICODE_STRING + [ComImport] + [Guid("9556DC99-828C-11CF-A37E-00AA003240C7")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IWbemServices { - public ushort Length; - public ushort MaximumLength; - public IntPtr Buffer; + [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); } - private const int PROCESS_QUERY_INFORMATION = 0x0400; - private const int PROCESS_VM_READ = 0x0010; + [ComImport] + [Guid("027947E1-D731-11CE-A357-000000000001")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IEnumWbemClassObject + { + [PreserveSig] + int Reset(); - /// - /// The process is the same architecture as the executing host (e.g. 64-bit process on 64-bit Windows or 32-bit process on 32-bit Windows). - /// - private const int IMAGE_FILE_MACHINE_UNKNOWN = 0x0000; - /// - /// The process is a 32-bit process running under WOW64 on 64-bit Windows. - /// - private const int IMAGE_FILE_MACHINE_I386 = 0x014c; + [PreserveSig] + int Next( + int lTimeout, + uint uCount, + [MarshalAs(UnmanagedType.Interface)] out IWbemClassObject apObjects, + out uint puReturned); - /// - /// Reads the command line from the Process Environment Block (PEB) of a Windows process. - /// Uses typed ReadProcessMemory overloads to read structured data directly, - /// avoiding manual byte[] allocation and BitConverter deserialization. - /// - internal static string? GetCommandLine(int processId) + [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 { - IntPtr hProcess = IntPtr.Zero; - try - { - // Open the process with query and read permissions - hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); - if (hProcess == IntPtr.Zero) - { - return null; - } + [PreserveSig] + int GetQualifierSet(IntPtr ppQualSet); - // Get process basic information to locate PEB - PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); - int returnLength; - int status = NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); - if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero) - { - return null; - } + [PreserveSig] + int Get( + [MarshalAs(UnmanagedType.LPWStr)] string wszName, + int lFlags, + ref object pVal, + IntPtr pType, + IntPtr plFlavor); - bool is64BitProcess = true; - if (IsWow64Process2(hProcess, out ushort processMachine, out ushort nativeMachine)) - { - if (processMachine == IMAGE_FILE_MACHINE_I386 || (IntPtr.Size == 4 && processMachine == IMAGE_FILE_MACHINE_UNKNOWN)) - { - is64BitProcess = false; - } - } + [PreserveSig] + int Put([MarshalAs(UnmanagedType.LPWStr)] string wszName, int lFlags, ref object pVal, int type); - // Read the PEB to get the ProcessParameters pointer - // In 64-bit: PEB + 0x20 = ProcessParameters - // In 32-bit: PEB + 0x10 = ProcessParameters - int processParametersOffset = is64BitProcess ? 0x20 : 0x10; - IntPtr processParametersPtr = IntPtr.Zero; + [PreserveSig] + int Delete([MarshalAs(UnmanagedType.LPWStr)] string wszName); - byte[] ptrBuffer = new byte[IntPtr.Size]; - if (!ReadProcessMemory(hProcess, IntPtr.Add(pbi.PebBaseAddress, processParametersOffset), ptrBuffer, ptrBuffer.Length, out _)) - { - return null; - } - processParametersPtr = is64BitProcess - ? new IntPtr(BitConverter.ToInt64(ptrBuffer, 0)) - : new IntPtr(BitConverter.ToInt32(ptrBuffer, 0)); + [PreserveSig] + int GetNames([MarshalAs(UnmanagedType.LPWStr)] string wszQualifierName, int lFlags, ref object pQualifierVal, IntPtr pNames); - if (processParametersPtr == IntPtr.Zero) - { - return null; - } + [PreserveSig] + int BeginEnumeration(int lEnumFlags); - // Read the CommandLine UNICODE_STRING from ProcessParameters - // CommandLine is at offset 0x70 in 64-bit and 0x40 in 32-bit - int commandLineOffset = is64BitProcess ? 0x70 : 0x40; - byte[] unicodeStringBuffer = new byte[Marshal.SizeOf(typeof(UNICODE_STRING))]; - if (!ReadProcessMemory(hProcess, IntPtr.Add(processParametersPtr, commandLineOffset), unicodeStringBuffer, unicodeStringBuffer.Length, out _)) - { - return null; - } + [PreserveSig] + int Next(int lFlags, [MarshalAs(UnmanagedType.BStr)] out string strName, ref object pVal, IntPtr pType, IntPtr plFlavor); - // Parse UNICODE_STRING structure - // Layout: ushort Length (2 bytes), ushort MaximumLength (2 bytes), [4 bytes padding on 64-bit], IntPtr Buffer - UNICODE_STRING commandLineUnicode = new UNICODE_STRING - { - Length = BitConverter.ToUInt16(unicodeStringBuffer, 0), - MaximumLength = BitConverter.ToUInt16(unicodeStringBuffer, 2), - Buffer = is64BitProcess - ? new IntPtr(BitConverter.ToInt64(unicodeStringBuffer, 8)) // 4 bytes for ushorts + 4 bytes padding - : new IntPtr(BitConverter.ToInt32(unicodeStringBuffer, 4)) // 4 bytes for ushorts, no padding - }; - - if (commandLineUnicode.Buffer == IntPtr.Zero || commandLineUnicode.Length == 0) - { - return null; - } + [PreserveSig] + int EndEnumeration(); - // Read the actual command line string - byte[] commandLineBuffer = new byte[commandLineUnicode.Length]; - if (!ReadProcessMemory(hProcess, commandLineUnicode.Buffer, commandLineBuffer, commandLineBuffer.Length, out _)) - { - return null; - } + [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); - return Encoding.Unicode.GetString(commandLineBuffer); + [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); + } + + /// + /// 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) + { + // Step 1: Initialize COM. RPC_E_CHANGED_MODE means COM is already initialized + // with a different threading model by the host — not fatal, we can proceed. + int hr = 0; + + // Step 2: Set general COM security levels. + 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}"); } - catch + + // Step 3: Obtain the initial locator to WMI. + 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}"); + } + + // Step 4: Connect to ROOT\CIMV2. + 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}"); + } + + // Step 5: Set proxy security so the WMI service can impersonate the client. + 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}"); + } + + // Step 6: Execute the WQL query. + 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}"); + } + + // Step 7: Retrieve the result. + 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; } - finally + if (hr != WBEM_S_NO_ERROR) { - if (hProcess != IntPtr.Zero) - { - CloseHandle(hProcess); - } + 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; } } diff --git a/src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs new file mode 100644 index 00000000000..0e30a11b1ef --- /dev/null +++ b/src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs @@ -0,0 +1,257 @@ +// 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.Threading.Tasks; +using Microsoft.Build.Shared; +using Shouldly; +using Xunit; + +#nullable disable + +namespace Microsoft.Build.UnitTests +{ + /// + /// Focused tests for the internal Windows.GetCommandLine path, exercised through + /// . + /// + /// Because GetCommandLine now throws with + /// detailed diagnostics on each native-call failure, TryGetCommandLine catches those + /// and returns false. The tests below verify both the happy path and each individual + /// failure mode by asserting on the exception message that bubbles out when the + /// internal method is called directly (Windows only), or by provoking failure through + /// invalid inputs on all platforms. + /// + public class ProcessExtensions_GetCommandLine_Tests + { + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// Starts a long-running process suitable for inspection and returns it. + /// Caller is responsible for terminating it. + /// + private static Process StartLongRunningProcess() + { + var psi = NativeMethodsShared.IsWindows + ? new ProcessStartInfo("cmd.exe", "/c timeout /t 30 /nobreak") + : new ProcessStartInfo("sleep", "30"); + psi.UseShellExecute = false; + return Process.Start(psi); + } + + // ----------------------------------------------------------------------- + // TryGetCommandLine – happy path (all platforms) + // ----------------------------------------------------------------------- + + [Fact] + public async Task TryGetCommandLine_RunningProcess_ReturnsTrue() + { + using Process p = StartLongRunningProcess(); + try + { + await Task.Delay(300); + p.TryGetCommandLine(out _).ShouldBeTrue(); + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } + + [Fact] + public async Task TryGetCommandLine_RunningProcess_CommandLineNotNullOrEmpty() + { + using Process p = StartLongRunningProcess(); + try + { + await Task.Delay(300); + p.TryGetCommandLine(out string commandLine); + commandLine.ShouldNotBeNull(); + commandLine.ShouldNotBeEmpty(); + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } + + [Fact] + public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() + { + using Process p = StartLongRunningProcess(); + try + { + await Task.Delay(300); + p.TryGetCommandLine(out string commandLine); + + if (NativeMethodsShared.IsWindows) + { + commandLine.ShouldContain("cmd", 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); + p.TryGetCommandLine(out string commandLine); + + if (NativeMethodsShared.IsWindows) + { + // cmd /c timeout /t 30 /nobreak – at minimum "timeout" or "30" should appear + commandLine.ShouldMatch(@"(timeout|30)"); + } + else + { + commandLine.ShouldContain("30"); + } + } + finally + { + if (!p.HasExited) + { + p.KillTree(5000); + } + } + } + + // ----------------------------------------------------------------------- + // TryGetCommandLine – null / exited process guards (all platforms) + // ----------------------------------------------------------------------- + + [Fact] + public void TryGetCommandLine_NullProcess_ReturnsFalse() + { + Process nullProcess = null; + nullProcess.TryGetCommandLine(out string commandLine).ShouldBeFalse(); + commandLine.ShouldBeNull(); + } + + [Fact] + public async Task TryGetCommandLine_ExitedProcess_ReturnsFalse() + { + var psi = NativeMethodsShared.IsWindows + ? new ProcessStartInfo("cmd.exe", "/c exit 0") { UseShellExecute = false } + : new ProcessStartInfo("true") { UseShellExecute = false }; + + using Process p = Process.Start(psi); + await Task.Delay(500); // wait for it to exit naturally + p.WaitForExit(2000); + + p.HasExited.ShouldBeTrue(); + p.TryGetCommandLine(out string commandLine).ShouldBeFalse(); + commandLine.ShouldBeNull(); + } + + // ----------------------------------------------------------------------- + // Windows.GetCommandLine – invalid PID returns null (no WMI row found) + // (called via reflection so any exception would propagate) + // ----------------------------------------------------------------------- + + [WindowsOnlyFact] + public void GetCommandLine_InvalidPid_ReturnsNull() + { + // PID int.MaxValue is guaranteed not to exist; WMI returns no rows. + int invalidPid = int.MaxValue; + string? result = InvokeWindowsGetCommandLine(invalidPid); + result.ShouldBeNull(); + } + + // ----------------------------------------------------------------------- + // Windows.GetCommandLine – self-inspection (process can always read itself) + // ----------------------------------------------------------------------- + + [WindowsOnlyFact] + public void GetCommandLine_CurrentProcess_ReturnsNonEmpty() + { + string commandLine = InvokeWindowsGetCommandLine(Process.GetCurrentProcess().Id); + commandLine.ShouldNotBeNull(); + commandLine.ShouldNotBeEmpty(); + } + + [WindowsOnlyFact] + public void GetCommandLine_CurrentProcess_ContainsTestRunnerExecutable() + { + string commandLine = InvokeWindowsGetCommandLine(Process.GetCurrentProcess().Id); + // The test host will be something like "testhost.exe" or "dotnet.exe" + commandLine.ShouldNotBeNullOrWhiteSpace(); + } + + // ----------------------------------------------------------------------- + // Windows.GetCommandLine – WMI query for non-existent PID returns null + // ----------------------------------------------------------------------- + + [WindowsOnlyFact] + public void GetCommandLine_InvalidPid_DoesNotThrow() + { + // WMI returns no rows for an invalid PID; GetCommandLine should return null, not throw. + Should.NotThrow(() => InvokeWindowsGetCommandLine(int.MaxValue)); + } + + // ----------------------------------------------------------------------- + // TryGetCommandLine – verifies exception is swallowed and false returned + // ----------------------------------------------------------------------- + + [WindowsOnlyFact] + public void TryGetCommandLine_NeverThrows_EvenWhenInternalsWould() + { + // Verify: TryGetCommandLine never throws even when internals would + using Process p = Process.GetCurrentProcess(); + Should.NotThrow(() => p.TryGetCommandLine(out _)); + } + + // ----------------------------------------------------------------------- + // Reflection helper to call the internal Windows.GetCommandLine directly. + // ----------------------------------------------------------------------- + + private static string? InvokeWindowsGetCommandLine(int processId) + { + // ProcessExtensions is internal; Windows is a private nested class. + // Use reflection to call it directly so exceptions propagate. + var processExtensionsType = typeof(ProcessExtensions); + var windowsType = processExtensionsType.GetNestedType("Windows", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + windowsType.ShouldNotBeNull("Could not locate ProcessExtensions.Windows via reflection."); + + var method = windowsType.GetMethod("GetCommandLine", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + method.ShouldNotBeNull("Could not locate ProcessExtensions.Windows.GetCommandLine via reflection."); + + try + { + return (string?)method.Invoke(null, new object[] { processId }); + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; // unreachable + } + } + } +} From 81f56b8db5e4d30013086eefc2a6b88e1427c8aa Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 20 Feb 2026 17:06:12 -0600 Subject: [PATCH 26/33] Consolidate tests and add timing information --- .../ProcessExtensions_GetCommandLine_Tests.cs | 257 ------------------ .../ProcessExtensions_Tests.cs | 76 ++++-- 2 files changed, 55 insertions(+), 278 deletions(-) delete mode 100644 src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs diff --git a/src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs deleted file mode 100644 index 0e30a11b1ef..00000000000 --- a/src/Utilities.UnitTests/ProcessExtensions_GetCommandLine_Tests.cs +++ /dev/null @@ -1,257 +0,0 @@ -// 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.Threading.Tasks; -using Microsoft.Build.Shared; -using Shouldly; -using Xunit; - -#nullable disable - -namespace Microsoft.Build.UnitTests -{ - /// - /// Focused tests for the internal Windows.GetCommandLine path, exercised through - /// . - /// - /// Because GetCommandLine now throws with - /// detailed diagnostics on each native-call failure, TryGetCommandLine catches those - /// and returns false. The tests below verify both the happy path and each individual - /// failure mode by asserting on the exception message that bubbles out when the - /// internal method is called directly (Windows only), or by provoking failure through - /// invalid inputs on all platforms. - /// - public class ProcessExtensions_GetCommandLine_Tests - { - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - /// - /// Starts a long-running process suitable for inspection and returns it. - /// Caller is responsible for terminating it. - /// - private static Process StartLongRunningProcess() - { - var psi = NativeMethodsShared.IsWindows - ? new ProcessStartInfo("cmd.exe", "/c timeout /t 30 /nobreak") - : new ProcessStartInfo("sleep", "30"); - psi.UseShellExecute = false; - return Process.Start(psi); - } - - // ----------------------------------------------------------------------- - // TryGetCommandLine – happy path (all platforms) - // ----------------------------------------------------------------------- - - [Fact] - public async Task TryGetCommandLine_RunningProcess_ReturnsTrue() - { - using Process p = StartLongRunningProcess(); - try - { - await Task.Delay(300); - p.TryGetCommandLine(out _).ShouldBeTrue(); - } - finally - { - if (!p.HasExited) - { - p.KillTree(5000); - } - } - } - - [Fact] - public async Task TryGetCommandLine_RunningProcess_CommandLineNotNullOrEmpty() - { - using Process p = StartLongRunningProcess(); - try - { - await Task.Delay(300); - p.TryGetCommandLine(out string commandLine); - commandLine.ShouldNotBeNull(); - commandLine.ShouldNotBeEmpty(); - } - finally - { - if (!p.HasExited) - { - p.KillTree(5000); - } - } - } - - [Fact] - public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() - { - using Process p = StartLongRunningProcess(); - try - { - await Task.Delay(300); - p.TryGetCommandLine(out string commandLine); - - if (NativeMethodsShared.IsWindows) - { - commandLine.ShouldContain("cmd", 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); - p.TryGetCommandLine(out string commandLine); - - if (NativeMethodsShared.IsWindows) - { - // cmd /c timeout /t 30 /nobreak – at minimum "timeout" or "30" should appear - commandLine.ShouldMatch(@"(timeout|30)"); - } - else - { - commandLine.ShouldContain("30"); - } - } - finally - { - if (!p.HasExited) - { - p.KillTree(5000); - } - } - } - - // ----------------------------------------------------------------------- - // TryGetCommandLine – null / exited process guards (all platforms) - // ----------------------------------------------------------------------- - - [Fact] - public void TryGetCommandLine_NullProcess_ReturnsFalse() - { - Process nullProcess = null; - nullProcess.TryGetCommandLine(out string commandLine).ShouldBeFalse(); - commandLine.ShouldBeNull(); - } - - [Fact] - public async Task TryGetCommandLine_ExitedProcess_ReturnsFalse() - { - var psi = NativeMethodsShared.IsWindows - ? new ProcessStartInfo("cmd.exe", "/c exit 0") { UseShellExecute = false } - : new ProcessStartInfo("true") { UseShellExecute = false }; - - using Process p = Process.Start(psi); - await Task.Delay(500); // wait for it to exit naturally - p.WaitForExit(2000); - - p.HasExited.ShouldBeTrue(); - p.TryGetCommandLine(out string commandLine).ShouldBeFalse(); - commandLine.ShouldBeNull(); - } - - // ----------------------------------------------------------------------- - // Windows.GetCommandLine – invalid PID returns null (no WMI row found) - // (called via reflection so any exception would propagate) - // ----------------------------------------------------------------------- - - [WindowsOnlyFact] - public void GetCommandLine_InvalidPid_ReturnsNull() - { - // PID int.MaxValue is guaranteed not to exist; WMI returns no rows. - int invalidPid = int.MaxValue; - string? result = InvokeWindowsGetCommandLine(invalidPid); - result.ShouldBeNull(); - } - - // ----------------------------------------------------------------------- - // Windows.GetCommandLine – self-inspection (process can always read itself) - // ----------------------------------------------------------------------- - - [WindowsOnlyFact] - public void GetCommandLine_CurrentProcess_ReturnsNonEmpty() - { - string commandLine = InvokeWindowsGetCommandLine(Process.GetCurrentProcess().Id); - commandLine.ShouldNotBeNull(); - commandLine.ShouldNotBeEmpty(); - } - - [WindowsOnlyFact] - public void GetCommandLine_CurrentProcess_ContainsTestRunnerExecutable() - { - string commandLine = InvokeWindowsGetCommandLine(Process.GetCurrentProcess().Id); - // The test host will be something like "testhost.exe" or "dotnet.exe" - commandLine.ShouldNotBeNullOrWhiteSpace(); - } - - // ----------------------------------------------------------------------- - // Windows.GetCommandLine – WMI query for non-existent PID returns null - // ----------------------------------------------------------------------- - - [WindowsOnlyFact] - public void GetCommandLine_InvalidPid_DoesNotThrow() - { - // WMI returns no rows for an invalid PID; GetCommandLine should return null, not throw. - Should.NotThrow(() => InvokeWindowsGetCommandLine(int.MaxValue)); - } - - // ----------------------------------------------------------------------- - // TryGetCommandLine – verifies exception is swallowed and false returned - // ----------------------------------------------------------------------- - - [WindowsOnlyFact] - public void TryGetCommandLine_NeverThrows_EvenWhenInternalsWould() - { - // Verify: TryGetCommandLine never throws even when internals would - using Process p = Process.GetCurrentProcess(); - Should.NotThrow(() => p.TryGetCommandLine(out _)); - } - - // ----------------------------------------------------------------------- - // Reflection helper to call the internal Windows.GetCommandLine directly. - // ----------------------------------------------------------------------- - - private static string? InvokeWindowsGetCommandLine(int processId) - { - // ProcessExtensions is internal; Windows is a private nested class. - // Use reflection to call it directly so exceptions propagate. - var processExtensionsType = typeof(ProcessExtensions); - var windowsType = processExtensionsType.GetNestedType("Windows", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - windowsType.ShouldNotBeNull("Could not locate ProcessExtensions.Windows via reflection."); - - var method = windowsType.GetMethod("GetCommandLine", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - method.ShouldNotBeNull("Could not locate ProcessExtensions.Windows.GetCommandLine via reflection."); - - try - { - return (string?)method.Invoke(null, new object[] { processId }); - } - catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) - { - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); - throw; // unreachable - } - } - } -} diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index 295d3cad63f..a8bb12e8a78 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -4,9 +4,9 @@ using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Build.Shared; -using Microsoft.DotNet.XUnitExtensions; using Shouldly; using Xunit; +using Xunit.Abstractions; #nullable disable @@ -14,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("cmd.exe", "/c timeout /t 30 /nobreak") + : new ProcessStartInfo("sleep", "30"); + psi.UseShellExecute = false; + return Process.Start(psi); + } + [Fact] public async Task KillTree() { @@ -35,41 +51,59 @@ public async Task KillTree() } [Fact] - public async Task GetCommandLine_ReturnsCommandLineForRunningProcess() + public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() { - // Start a simple process that will run for a bit - var psi = NativeMethodsShared.IsWindows - ? new ProcessStartInfo("cmd.exe", "/c timeout 10") - : new ProcessStartInfo("sleep", "10"); - - psi.UseShellExecute = false; - - using Process p = Process.Start(psi); + using Process p = StartLongRunningProcess(); try { - // Give the process time to start - await Task.Delay(500); - + 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"); - // Command line retrieval should work on all platforms - commandLine.ShouldNotBeNull(); - commandLine.ShouldNotBeEmpty(); - - // Verify we get the expected process name if (NativeMethodsShared.IsWindows) { - commandLine.ShouldContain("cmd"); + commandLine.ShouldContain("cmd", Case.Insensitive); } else { commandLine.ShouldContain("sleep"); - commandLine.ShouldContain("10"); } } finally { - // Clean up + 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) + { + // cmd /c timeout /t 30 /nobreak – at minimum "timeout" or "30" should appear + commandLine.ShouldMatch(@"(timeout|30)"); + } + else + { + commandLine.ShouldContain("30"); + } + } + finally + { if (!p.HasExited) { p.KillTree(5000); From d393ffb1d1cdb5283e54cb004deb231149838701 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 20 Feb 2026 17:08:17 -0600 Subject: [PATCH 27/33] remove useless test --- .../ProcessExtensions_Tests.cs | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index a8bb12e8a78..88e69c340f1 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -30,26 +30,6 @@ private static Process StartLongRunningProcess() return Process.Start(psi); } - [Fact] - public async Task KillTree() - { - var psi = - NativeMethodsShared.IsWindows ? - new ProcessStartInfo("rundll32", "kernel32.dll, Sleep") : - new ProcessStartInfo("sleep", "600"); - - Process p = Process.Start(psi); // sleep 10m. - - // Verify the process is running. - await Task.Delay(500); - p.HasExited.ShouldBe(false); - - // Kill the process. - p.KillTree(timeoutMilliseconds: 5000); - p.HasExited.ShouldBe(true); - p.ExitCode.ShouldNotBe(0); - } - [Fact] public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() { From f63f6abff4727b99d4efbbabb0588e134c25db37 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 20 Feb 2026 17:15:07 -0600 Subject: [PATCH 28/33] Minor cleanup --- src/Shared/ProcessExtensions.cs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 62747358261..00a10d5775c 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -500,12 +500,7 @@ int Get( /// internal static string? GetCommandLine(int processId) { - // Step 1: Initialize COM. RPC_E_CHANGED_MODE means COM is already initialized - // with a different threading model by the host — not fatal, we can proceed. - int hr = 0; - - // Step 2: Set general COM security levels. - hr = CoInitializeSecurity( + int hr = CoInitializeSecurity( IntPtr.Zero, -1, IntPtr.Zero, @@ -522,7 +517,6 @@ int Get( $"WMI CoInitializeSecurity failed for PID {processId}. HRESULT: 0x{hr:X8}"); } - // Step 3: Obtain the initial locator to WMI. Guid clsid = CLSID_WbemLocator; Guid iid = IID_IWbemLocator; hr = CoCreateInstance(ref clsid, IntPtr.Zero, CLSCTX_INPROC_SERVER, ref iid, out IWbemLocator locator); @@ -532,7 +526,6 @@ int Get( $"WMI CoCreateInstance failed for PID {processId}. HRESULT: 0x{hr:X8}"); } - // Step 4: Connect to ROOT\CIMV2. hr = locator.ConnectServer( @"ROOT\CIMV2", strUser: null, strPassword: null, strLocale: null, @@ -545,7 +538,6 @@ int Get( $"WMI ConnectServer failed for PID {processId}. HRESULT: 0x{hr:X8}"); } - // Step 5: Set proxy security so the WMI service can impersonate the client. hr = CoSetProxyBlanket( services, RPC_C_AUTHN_WINNT, @@ -561,7 +553,6 @@ int Get( $"WMI CoSetProxyBlanket failed for PID {processId}. HRESULT: 0x{hr:X8}"); } - // Step 6: Execute the WQL query. string query = $"SELECT CommandLine FROM Win32_Process WHERE ProcessId='{processId}'"; hr = services.ExecQuery( "WQL", @@ -575,7 +566,6 @@ int Get( $"WMI ExecQuery failed for PID {processId}. HRESULT: 0x{hr:X8}"); } - // Step 7: Retrieve the result. hr = enumerator.Next(WBEM_INFINITE, 1, out IWbemClassObject obj, out uint returned); if (hr == WBEM_S_FALSE || returned == 0) { From 7ea20a67b08a76c8396d3e88a2e0cf57b6641e51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:04:47 +0000 Subject: [PATCH 29/33] Fix unsupported OS handling to return false instead of null command line Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 00a10d5775c..b349a850e1d 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -61,11 +61,27 @@ public static bool TryGetCommandLine(this Process? process, out string? commandL try { #if NET - commandLine = NativeMethodsShared.IsWindows ? Windows.GetCommandLine(process.Id) : - NativeMethodsShared.IsOSX ? MacOS.GetCommandLine(process.Id) : - NativeMethodsShared.IsLinux ? Linux.GetCommandLine(process.Id) : - null; // If we don't have a platform-specific implementation, just return true with a null command line, since the caller should be able to handle that case. - return true; + if (NativeMethodsShared.IsWindows) + { + commandLine = Windows.GetCommandLine(process.Id); + return true; + } + else if (NativeMethodsShared.IsOSX) + { + commandLine = MacOS.GetCommandLine(process.Id); + return true; + } + else if (NativeMethodsShared.IsLinux) + { + commandLine = Linux.GetCommandLine(process.Id); + return true; + } + else + { + // Unsupported OS (e.g., BSD) - return false to fall back to prior behavior + commandLine = null; + return false; + } #else commandLine = Windows.GetCommandLine(process.Id); return true; From fde4df91293aba63592fadfb5dafac1cf7c1487c Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Mon, 23 Feb 2026 12:58:13 -0600 Subject: [PATCH 30/33] Fix both the Windows test scenarios AND the COM code for .NET Framework --- src/Shared/ProcessExtensions.cs | 74 +++++++++++++++++++ .../ProcessExtensions_Tests.cs | 8 +- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index b349a850e1d..80cbe7f8cde 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -198,6 +198,19 @@ private static extern int CoSetProxyBlanket( 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)] @@ -510,6 +523,54 @@ int Get( 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='' @@ -554,6 +615,7 @@ int Get( $"WMI ConnectServer failed for PID {processId}. HRESULT: 0x{hr:X8}"); } +#if NET hr = CoSetProxyBlanket( services, RPC_C_AUTHN_WINNT, @@ -568,6 +630,13 @@ int Get( 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( @@ -582,6 +651,11 @@ int Get( $"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) { diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index 88e69c340f1..4e42f31d901 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -24,7 +24,7 @@ public ProcessExtensions_Tests(ITestOutputHelper output) private static Process StartLongRunningProcess() { var psi = NativeMethodsShared.IsWindows - ? new ProcessStartInfo("cmd.exe", "/c timeout /t 30 /nobreak") + ? new ProcessStartInfo("ping", "-n 31 127.0.0.1") : new ProcessStartInfo("sleep", "30"); psi.UseShellExecute = false; return Process.Start(psi); @@ -44,7 +44,7 @@ public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() if (NativeMethodsShared.IsWindows) { - commandLine.ShouldContain("cmd", Case.Insensitive); + commandLine.ShouldContain("ping", Case.Insensitive); } else { @@ -74,8 +74,8 @@ public async Task TryGetCommandLine_RunningProcess_ContainsArguments() if (NativeMethodsShared.IsWindows) { - // cmd /c timeout /t 30 /nobreak – at minimum "timeout" or "30" should appear - commandLine.ShouldMatch(@"(timeout|30)"); + // 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 { From de6063e7bfb7fb9625d566795c996df556072af6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:11:42 +0000 Subject: [PATCH 31/33] Improve macOS/BSD sysctl implementation with ArrayPool and better span usage Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- src/Shared/ProcessExtensions.cs | 121 ++++++++++++++++++++------------ 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 80cbe7f8cde..153bc7eeacf 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -7,6 +7,7 @@ using System.Runtime.Versioning; #if NET +using System.Buffers; using System.Text; using System.IO; #endif @@ -66,9 +67,13 @@ public static bool TryGetCommandLine(this Process? process, out string? commandL commandLine = Windows.GetCommandLine(process.Id); return true; } - else if (NativeMethodsShared.IsOSX) + else if (NativeMethodsShared.IsOSX || NativeMethodsShared.IsBSD) { + // macOS and BSD both support sysctl with KERN_PROCARGS2 + // Suppress CA1416 because we check IsBSD at runtime but the method is marked [SupportedOSPlatform("macos")] + #pragma warning disable CA1416 commandLine = MacOS.GetCommandLine(process.Id); + #pragma warning restore CA1416 return true; } else if (NativeMethodsShared.IsLinux) @@ -78,7 +83,7 @@ public static bool TryGetCommandLine(this Process? process, out string? commandL } else { - // Unsupported OS (e.g., BSD) - return false to fall back to prior behavior + // Unsupported OS - return false to fall back to prior behavior commandLine = null; return false; } @@ -96,38 +101,67 @@ public static bool TryGetCommandLine(this Process? process, out string? commandL #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 (sysctl KERN_PROCARGS2) parsing. + /// 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) { - StringBuilder sb = new(data.Length); - int argsFound = 0; + if (data.IsEmpty) + { + return string.Empty; + } - while (!data.IsEmpty && argsFound < maxArgs) + // 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 nullIndex = data.IndexOf((byte)0); - ReadOnlySpan segment = nullIndex >= 0 ? data.Slice(0, nullIndex) : data; + int totalChars = 0; + int argsFound = 0; - if (!segment.IsEmpty) + while (!data.IsEmpty && argsFound < maxArgs) { - if (sb.Length > 0) + int nullIndex = data.IndexOf((byte)0); + ReadOnlySpan segment = nullIndex >= 0 ? data.Slice(0, nullIndex) : data; + + if (!segment.IsEmpty) { - sb.Append(' '); + // 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++; } - sb.Append(Encoding.UTF8.GetString(segment)); - argsFound++; - } + if (nullIndex < 0) + { + break; + } - if (nullIndex < 0) - { - break; + data = data.Slice(nullIndex + 1); } - data = data.Slice(nullIndex + 1); + return new string(charBuffer, 0, totalChars); + } + finally + { + ArrayPool.Shared.Return(charBuffer); } - - return sb.ToString(); } #endif @@ -712,9 +746,10 @@ private static class Linux } /// - /// macOS-specific P/Invoke bindings and command line retrieval via sysctl KERN_PROCARGS2. + /// macOS/BSD-specific P/Invoke bindings and command line retrieval via sysctl KERN_PROCARGS2. /// [SupportedOSPlatform("macos")] + [SupportedOSPlatform("freebsd")] private static partial class MacOS { [LibraryImport("libc", SetLastError = true)] @@ -737,33 +772,31 @@ private static int Sysctl(ReadOnlySpan name, Span oldp, ref nuint old /// /// Uses sysctl with KERN_PROCARGS2 to read the process arguments, - /// then parses the null-separated buffer using span-based slicing. + /// 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) { - try - { - ReadOnlySpan mib = [CTL_KERN, KERN_PROCARGS2, processId]; - nuint size = 0; - - if (Sysctl(mib, Span.Empty, ref size) != 0) - { - return null; - } + ReadOnlySpan mib = [CTL_KERN, KERN_PROCARGS2, processId]; + nuint size = 0; - if (size == 0) - { - return null; - } + // Get the required buffer size + if (Sysctl(mib, Span.Empty, ref size) != 0 || size == 0) + { + return null; + } - byte[] buffer = new byte[size]; - if (Sysctl(mib, buffer, ref size) != 0) + // 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: - // int argc + // 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) @@ -784,13 +817,13 @@ private static int Sysctl(ReadOnlySpan name, Span oldp, ref nuint old data = data.Slice(sizeof(int)); // Skip past the executable path (first null terminator) - int nullIndex = data.IndexOf((byte)0); - if (nullIndex < 0) + int execPathEnd = data.IndexOf((byte)0); + if (execPathEnd < 0) { return null; } - data = data.Slice(nullIndex + 1); + data = data.Slice(execPathEnd + 1); // Skip padding null bytes between executable path and argv[0] while (!data.IsEmpty && data[0] == 0) @@ -800,9 +833,9 @@ private static int Sysctl(ReadOnlySpan name, Span oldp, ref nuint old return ParseNullSeparatedArguments(data, argc); } - catch + finally { - return null; + ArrayPool.Shared.Return(buffer); } } } From 8d03bab063bb6e091208150212e97721025dca53 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Tue, 24 Feb 2026 11:21:59 -0600 Subject: [PATCH 32/33] Address feedback from @AR-May --- .../Communications/NodeProviderOutOfProcBase.cs | 4 ++-- src/Framework/ChangeWaves.cs | 3 +-- src/Framework/NativeMethods.cs | 1 + src/Framework/NodeMode.cs | 10 +++++----- src/Shared/ProcessExtensions.cs | 10 +++------- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs index a939469dfa4..b74013ba0f0 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcBase.cs @@ -431,7 +431,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte } // If we have an expected NodeMode, filter by command line parsing - if (expectedNodeMode.HasValue && ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_6)) + 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); @@ -452,7 +452,7 @@ void CreateNodeContext(int nodeId, Process nodeToReuse, Stream nodeStream, byte 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("Skipping process {0} - command line is null, unsupported platform", process.Id); + CommunicationsUtilities.Trace("Including process {0} with unknown NodeMode because command line retrieval is not supported on this platform", process.Id); filteredProcesses.Add(process); continue; } diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs index d2529bed1c3..d40cd86c8f3 100644 --- a/src/Framework/ChangeWaves.cs +++ b/src/Framework/ChangeWaves.cs @@ -33,8 +33,7 @@ internal static class ChangeWaves internal static readonly Version Wave18_3 = new Version(18, 3); internal static readonly Version Wave18_4 = new Version(18, 4); internal static readonly Version Wave18_5 = new Version(18, 5); - internal static readonly Version Wave18_6 = new Version(18, 6); - internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5, Wave18_6]; + internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5]; /// /// Special value indicating that all features behind all Change Waves should be enabled. diff --git a/src/Framework/NativeMethods.cs b/src/Framework/NativeMethods.cs index f6052f622c5..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 diff --git a/src/Framework/NodeMode.cs b/src/Framework/NodeMode.cs index 15f62f5b022..87d5d5bdb05 100644 --- a/src/Framework/NodeMode.cs +++ b/src/Framework/NodeMode.cs @@ -54,9 +54,9 @@ internal static partial class NodeModeHelper public static bool TryParse(string value, [NotNullWhen(true)] out NodeMode? nodeMode) { #if NET - return TryParseCore(value.AsSpan(), out nodeMode); + return TryParseImpl(value.AsSpan(), out nodeMode); #else - return TryParseCore(value, out nodeMode); + return TryParseImpl(value, out nodeMode); #endif } @@ -69,14 +69,14 @@ public static bool TryParse(string value, [NotNullWhen(true)] out NodeMode? node /// True if parsing succeeded, false otherwise public static bool TryParse(ReadOnlySpan value, [NotNullWhen(true)] out NodeMode? nodeMode) { - return TryParseCore(value, out nodeMode); + return TryParseImpl(value, out nodeMode); } #endif #if NET - private static bool TryParseCore(ReadOnlySpan value, [NotNullWhen(true)] out NodeMode? nodeMode) + private static bool TryParseImpl(ReadOnlySpan value, [NotNullWhen(true)] out NodeMode? nodeMode) #else - private static bool TryParseCore(string value, [NotNullWhen(true)] out NodeMode? nodeMode) + private static bool TryParseImpl(string value, [NotNullWhen(true)] out NodeMode? nodeMode) #endif { nodeMode = null; diff --git a/src/Shared/ProcessExtensions.cs b/src/Shared/ProcessExtensions.cs index 153bc7eeacf..7ddd8dc6ab6 100644 --- a/src/Shared/ProcessExtensions.cs +++ b/src/Shared/ProcessExtensions.cs @@ -69,11 +69,7 @@ public static bool TryGetCommandLine(this Process? process, out string? commandL } else if (NativeMethodsShared.IsOSX || NativeMethodsShared.IsBSD) { - // macOS and BSD both support sysctl with KERN_PROCARGS2 - // Suppress CA1416 because we check IsBSD at runtime but the method is marked [SupportedOSPlatform("macos")] - #pragma warning disable CA1416 - commandLine = MacOS.GetCommandLine(process.Id); - #pragma warning restore CA1416 + commandLine = BSD.GetCommandLine(process.Id); return true; } else if (NativeMethodsShared.IsLinux) @@ -85,7 +81,7 @@ public static bool TryGetCommandLine(this Process? process, out string? commandL { // Unsupported OS - return false to fall back to prior behavior commandLine = null; - return false; + return true; } #else commandLine = Windows.GetCommandLine(process.Id); @@ -750,7 +746,7 @@ private static class Linux /// [SupportedOSPlatform("macos")] [SupportedOSPlatform("freebsd")] - private static partial class MacOS + private static partial class BSD { [LibraryImport("libc", SetLastError = true)] private static partial int sysctl( From 374c8ee01da2c0bc14de702261daad9d013b3432 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:25:13 +0000 Subject: [PATCH 33/33] Re-add KillTree test per review feedback Co-authored-by: baronfel <573979+baronfel@users.noreply.github.com> --- .../ProcessExtensions_Tests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs index 4e42f31d901..8f52ca1a0dd 100644 --- a/src/Utilities.UnitTests/ProcessExtensions_Tests.cs +++ b/src/Utilities.UnitTests/ProcessExtensions_Tests.cs @@ -30,6 +30,26 @@ private static Process StartLongRunningProcess() return Process.Start(psi); } + [Fact] + public async Task KillTree() + { + var psi = + NativeMethodsShared.IsWindows ? + new ProcessStartInfo("rundll32", "kernel32.dll, Sleep") : + new ProcessStartInfo("sleep", "600"); + + Process p = Process.Start(psi); // sleep 10m. + + // Verify the process is running. + await Task.Delay(500); + p.HasExited.ShouldBe(false); + + // Kill the process. + p.KillTree(timeoutMilliseconds: 5000); + p.HasExited.ShouldBe(true); + p.ExitCode.ShouldNotBe(0); + } + [Fact] public async Task TryGetCommandLine_RunningProcess_ContainsExpectedExecutable() {