Skip to content
Open
180 changes: 129 additions & 51 deletions src/Tools/dotnet-trace/CommandLine/Commands/CollectLinuxCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,16 @@ internal int CollectLinux(CollectLinuxArgs args)

if (args.ProcessId != 0 || !string.IsNullOrEmpty(args.Name))
{
if (!ProcessSupportsUserEventsIpcCommand(args.ProcessId, args.Name, out int resolvedProcessId, out string resolvedProcessName, out string detectedRuntimeVersion))
CommandUtils.ResolveProcess(args.ProcessId, args.Name, out int resolvedProcessId, out string resolvedProcessName);
UserEventsProbeResult probeResult = ProbeProcess(resolvedProcessId, out string detectedRuntimeVersion);
switch (probeResult)
{
Console.Error.WriteLine($"[ERROR] Process '{resolvedProcessName} ({resolvedProcessId})' cannot be traced by collect-linux. Required runtime: {minRuntimeSupportingUserEventsIPCCommand}. Detected runtime: {detectedRuntimeVersion}");
return (int)ReturnCode.TracingError;
case UserEventsProbeResult.NotSupported:
Console.Error.WriteLine($"[ERROR] Process '{resolvedProcessName} ({resolvedProcessId})' cannot be traced by collect-linux. Required runtime: {minRuntimeSupportingUserEventsIPCCommand}. Detected runtime: {detectedRuntimeVersion}");
return (int)ReturnCode.TracingError;
case UserEventsProbeResult.ConnectionFailed:
Console.Error.WriteLine($"[ERROR] Unable to connect to process '{resolvedProcessName} ({resolvedProcessId})'. The process may have exited, or it doesn't have an accessible .NET diagnostic port.");
return (int)ReturnCode.TracingError;
}
args = args with { Name = resolvedProcessName, ProcessId = resolvedProcessId };
}
Expand Down Expand Up @@ -217,18 +223,28 @@ internal int SupportsCollectLinux(CollectLinuxArgs args)
bool generateCsv = mode == ProbeOutputMode.CsvToConsole || mode == ProbeOutputMode.Csv;
StringBuilder supportedCsv = generateCsv ? new StringBuilder() : null;
StringBuilder unsupportedCsv = generateCsv ? new StringBuilder() : null;
StringBuilder unknownCsv = generateCsv ? new StringBuilder() : null;

if (args.ProcessId != 0 || !string.IsNullOrEmpty(args.Name))
{
bool supports = ProcessSupportsUserEventsIpcCommand(args.ProcessId, args.Name, out int resolvedPid, out string resolvedName, out string detectedRuntimeVersion);
BuildProcessSupportCsv(resolvedPid, resolvedName, supports, supportedCsv, unsupportedCsv);
CommandUtils.ResolveProcess(args.ProcessId, args.Name, out int resolvedPid, out string resolvedName);
UserEventsProbeResult probeResult = ProbeProcess(resolvedPid, out string detectedRuntimeVersion);
BuildProcessSupportCsv(resolvedPid, resolvedName, probeResult, supportedCsv, unsupportedCsv, unknownCsv);

if (mode == ProbeOutputMode.Console)
{
Console.WriteLine($".NET process '{resolvedName} ({resolvedPid})' {(supports ? "supports" : "does NOT support")} the EventPipe UserEvents IPC command used by collect-linux.");
if (!supports)
switch (probeResult)
{
Console.WriteLine($"Required runtime: '{minRuntimeSupportingUserEventsIPCCommand}'. Detected runtime: '{detectedRuntimeVersion}'.");
case UserEventsProbeResult.Supported:
Console.WriteLine($".NET process '{resolvedName} ({resolvedPid})' supports the EventPipe UserEvents IPC command used by collect-linux.");
break;
case UserEventsProbeResult.NotSupported:
Console.WriteLine($".NET process '{resolvedName} ({resolvedPid})' does NOT support the EventPipe UserEvents IPC command used by collect-linux.");
Console.WriteLine($"Required runtime: '{minRuntimeSupportingUserEventsIPCCommand}'. Detected runtime: '{detectedRuntimeVersion}'.");
break;
case UserEventsProbeResult.ConnectionFailed:
Console.WriteLine($"Could not probe process '{resolvedName} ({resolvedPid})'. The process may have exited, or it doesn't have an accessible .NET diagnostic port.");
break;
}
}
}
Expand All @@ -240,49 +256,39 @@ internal int SupportsCollectLinux(CollectLinuxArgs args)
}
StringBuilder supportedProcesses = new();
StringBuilder unsupportedProcesses = new();
StringBuilder unknownProcesses = new();

IEnumerable<int> pids = DiagnosticsClient.GetPublishedProcesses();
foreach (int pid in pids)
{
if (pid == Environment.ProcessId)
{
continue;
}

bool supports = ProcessSupportsUserEventsIpcCommand(pid, string.Empty, out int resolvedPid, out string resolvedName, out string detectedRuntimeVersion);
BuildProcessSupportCsv(resolvedPid, resolvedName, supports, supportedCsv, unsupportedCsv);
if (supports)
{
supportedProcesses.AppendLine($"{resolvedPid} {resolvedName}");
}
else
{
unsupportedProcesses.AppendLine($"{resolvedPid} {resolvedName} - Detected runtime: '{detectedRuntimeVersion}'");
}
}
GetAndProbeAllProcesses(supportedProcesses, unsupportedProcesses, unknownProcesses, supportedCsv, unsupportedCsv, unknownCsv);

if (mode == ProbeOutputMode.Console)
{
Console.WriteLine($".NET processes that support the command:");
Console.WriteLine(supportedProcesses.ToString());
Console.WriteLine($".NET processes that do NOT support the command:");
Console.WriteLine(unsupportedProcesses.ToString());
if (unknownProcesses.Length > 0)
{
Console.WriteLine($".NET processes that could not be probed:");
Console.WriteLine(unknownProcesses.ToString());
}
}
}

if (mode == ProbeOutputMode.CsvToConsole)
{
Console.WriteLine("pid,processName,supportsCollectLinux");
Console.Write(supportedCsv?.ToString());
Console.Write(unsupportedCsv?.ToString());
Console.Write(supportedCsv.ToString());
Console.Write(unsupportedCsv.ToString());
Console.Write(unknownCsv.ToString());
}

if (mode == ProbeOutputMode.Csv)
{
using StreamWriter writer = new(args.Output.FullName, append: false, Encoding.UTF8);
writer.WriteLine("pid,processName,supportsCollectLinux");
writer.Write(supportedCsv?.ToString());
writer.Write(unsupportedCsv?.ToString());
writer.Write(supportedCsv.ToString());
writer.Write(unsupportedCsv.ToString());
writer.Write(unknownCsv.ToString());
Console.WriteLine($"Successfully wrote EventPipe UserEvents IPC command support results to '{args.Output.FullName}'.");
}

Expand Down Expand Up @@ -315,39 +321,104 @@ private static ProbeOutputMode DetermineProbeOutputMode(string outputName)
return ProbeOutputMode.Csv;
}

private bool ProcessSupportsUserEventsIpcCommand(int pid, string processName, out int resolvedPid, out string resolvedName, out string detectedRuntimeVersion)
/// <summary>
/// Probes a resolved process for UserEvents support. Returns ConnectionFailed when unable to
/// connect to the .NET diagnostic port (e.g. process exited between discovery and probe).
/// Callers must resolve the PID/name before calling this method.
/// </summary>
private UserEventsProbeResult ProbeProcess(int resolvedPid, out string detectedRuntimeVersion)
{
CommandUtils.ResolveProcess(pid, processName, out resolvedPid, out resolvedName);
detectedRuntimeVersion = string.Empty;

bool supports = false;
DiagnosticsClient client = new(resolvedPid);
ProcessInfo processInfo = client.GetProcessInfo();
detectedRuntimeVersion = processInfo.ClrProductVersionString;
if (processInfo.TryGetProcessClrVersion(out Version version, out bool isPrerelease) &&
(version > minRuntimeSupportingUserEventsIPCCommand ||
(version == minRuntimeSupportingUserEventsIPCCommand && !isPrerelease)))
try
{
supports = true;
DiagnosticsClient client = new(resolvedPid);
ProcessInfo processInfo = client.GetProcessInfo();
detectedRuntimeVersion = processInfo.ClrProductVersionString;
if (processInfo.TryGetProcessClrVersion(out Version version, out bool isPrerelease) &&
(version > minRuntimeSupportingUserEventsIPCCommand ||
(version == minRuntimeSupportingUserEventsIPCCommand && !isPrerelease)))
{
return UserEventsProbeResult.Supported;
}
return UserEventsProbeResult.NotSupported;
}
catch (ServerNotAvailableException)
{
return UserEventsProbeResult.ConnectionFailed;
}
catch (UnsupportedCommandException)
{
// can be thrown from an older runtime that doesn't even support GetProcessInfo
// treat as NotSupported instead of propagating the exception.
return UserEventsProbeResult.NotSupported;
}
}

return supports;
/// <summary>
/// Gets all published processes and probes them for UserEvents support.
/// </summary>
private void GetAndProbeAllProcesses(StringBuilder supportedProcesses, StringBuilder unsupportedProcesses, StringBuilder unknownProcesses,
StringBuilder supportedCsv, StringBuilder unsupportedCsv, StringBuilder unknownCsv)
{
IEnumerable<int> pids = DiagnosticsClient.GetPublishedProcesses();
foreach (int pid in pids)
{
if (pid == Environment.ProcessId)
{
continue;
}

// Resolve name before probing: a process that exits after probing is untraceable
// regardless of its probe result, so knowing the name for the failure message
// is more valuable than knowing the probe result without a name.
string processName;
try
{
processName = Process.GetProcessById(pid).ProcessName;
}
catch (ArgumentException)
{
// Process exited between discovery and name resolution, no need to report these.
continue;
}

UserEventsProbeResult probeResult = ProbeProcess(pid, out string detectedRuntimeVersion);
BuildProcessSupportCsv(pid, processName, probeResult, supportedCsv, unsupportedCsv, unknownCsv);
switch (probeResult)
{
case UserEventsProbeResult.Supported:
supportedProcesses?.AppendLine($"{pid} {processName}");
break;
case UserEventsProbeResult.NotSupported:
unsupportedProcesses?.AppendLine($"{pid} {processName} - Detected runtime: '{detectedRuntimeVersion}'");
break;
case UserEventsProbeResult.ConnectionFailed:
unknownProcesses?.AppendLine($"{pid} {processName} - Process may have exited, or it doesn't have an accessible .NET diagnostic port");
break;
}
}
}

private static void BuildProcessSupportCsv(int resolvedPid, string resolvedName, bool supports, StringBuilder supportedCsv, StringBuilder unsupportedCsv)
private static void BuildProcessSupportCsv(int resolvedPid, string resolvedName, UserEventsProbeResult probeResult, StringBuilder supportedCsv, StringBuilder unsupportedCsv, StringBuilder unknownCsv)
{
if (supportedCsv == null && unsupportedCsv == null)
if (supportedCsv == null && unsupportedCsv == null && unknownCsv == null)
{
return;
}

string escapedName = (resolvedName ?? string.Empty).Replace(",", string.Empty);
if (supports)
{
supportedCsv?.AppendLine($"{resolvedPid},{escapedName},true");
}
else
switch (probeResult)
{
unsupportedCsv?.AppendLine($"{resolvedPid},{escapedName},false");
case UserEventsProbeResult.Supported:
supportedCsv?.AppendLine($"{resolvedPid},{escapedName},true");
break;
case UserEventsProbeResult.NotSupported:
unsupportedCsv?.AppendLine($"{resolvedPid},{escapedName},false");
break;
case UserEventsProbeResult.ConnectionFailed:
unknownCsv?.AppendLine($"{resolvedPid},{escapedName},unknown");
break;
}
}

Expand Down Expand Up @@ -530,7 +601,7 @@ private int OutputHandler(uint type, IntPtr data, UIntPtr dataLen)
private static readonly Option<bool> ProbeOption =
new("--probe")
{
Description = "Probe .NET processes for support of the EventPipe UserEvents IPC command used by collect-linux, without collecting a trace. Results list supported processes first. Use '-o stdout' to print CSV (pid,processName,supportsCollectLinux) to the console, or '-o <file>' to write the CSV. Probe a single process with -n|--name or -p|--process-id.",
Description = "Probe .NET processes for support of the EventPipe UserEvents IPC command used by collect-linux, without collecting a trace. Results are categorized as supported, not supported, or unknown (when the process doesn't have an accessible .NET diagnostic port). Use '-o stdout' to print CSV (pid,processName,supportsCollectLinux) to the console, or '-o <file>' to write the CSV. Probe a single process with -n|--name or -p|--process-id.",
};

private enum ProbeOutputMode
Expand All @@ -540,6 +611,13 @@ private enum ProbeOutputMode
CsvToConsole,
}

private enum UserEventsProbeResult
{
Supported,
NotSupported,
ConnectionFailed,
}

private enum OutputType : uint
{
Normal = 0,
Expand Down
33 changes: 33 additions & 0 deletions src/tests/dotnet-trace/CollectLinuxCommandFunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
Expand Down Expand Up @@ -210,6 +211,38 @@ public void CollectLinuxCommand_Probe_ReportsResolveProcessErrors_BothPidAndName
console.AssertSanitizedLinesEqual(null, expected);
}

[ConditionalFact(nameof(IsCollectLinuxSupported))]
public void CollectLinuxCommand_ReportsConnectionFailed_NonDotNetProcess()
{
// PID 1 (init/systemd) exists but is not a .NET process — no diagnostic port.
string pid1Name = Process.GetProcessById(1).ProcessName;
MockConsole console = new(200, 30, _outputHelper);
var args = TestArgs(processId: 1);
int exitCode = Run(args, console);

Assert.Equal((int)ReturnCode.TracingError, exitCode);
console.AssertSanitizedLinesEqual(null, FormatException(
$"Unable to connect to process '{pid1Name} (1)'. The process may have exited, or it doesn't have an accessible .NET diagnostic port."));
}

[ConditionalFact(nameof(IsCollectLinuxSupported))]
public void CollectLinuxCommand_Probe_ReportsConnectionFailed_NonDotNetProcess()
{
// PID 1 (init/systemd) exists but is not a .NET process — no diagnostic port.
string pid1Name = Process.GetProcessById(1).ProcessName;
MockConsole console = new(200, 2000, _outputHelper);
var args = TestArgs(processId: 1, probe: true, output: new FileInfo(CommonOptions.DefaultTraceName));
int exitCode = Run(args, console);

Assert.Equal((int)ReturnCode.Ok, exitCode);
string[] expected = ExpectPreviewWithMessages(
new[] {
$"Could not probe process '{pid1Name} (1)'. The process may have exited, or it doesn't have an accessible .NET diagnostic port.",
}
);
console.AssertSanitizedLinesEqual(null, expected);
}

[ConditionalFact(nameof(IsCollectLinuxNotSupported))]
public void CollectLinuxCommand_NotSupported_OnNonLinux()
{
Expand Down
Loading