From: Johan Lorensson Date: Tue, 29 Aug 2023 10:52:09 +0000 (+0200) Subject: Extend dotnet-gcdump support for mobile and dotnet-dsrouter scenarios. (#4081) X-Git-Tag: accepted/tizen/unified/riscv/20231226.055542~37^2^2~64 X-Git-Url: http://review.tizen.org/git/?a=commitdiff_plain;h=88dab75fb845fd66d250007e21db493b0d179d8a;p=platform%2Fcore%2Fdotnet%2Fdiagnostics.git Extend dotnet-gcdump support for mobile and dotnet-dsrouter scenarios. (#4081) .net8 adds support for dotnet-gcdump on Mono, meaning dotnet-gcdump will be used targeting mobile platforms. Currently dotnet-gcdump doesn't have support needed since it can only connect using pid or process name and has logic to make sure the process connected to has the same pid as the -p argument passed to dotnet-gcdump. On mobile platforms, runtime is running on device and communicates using TCP/IP (over loopback interface, using Android adb port forwarding or usbmux on iOS). All logic related to communicate with devices on different platforms is handled by dotnet-dsrouter, that expose the same IPC channels as a normal runtime would do, meaning most diagnostic tools can connect to dotnet-dsrouter that will route all communication to/from device. Tools like dotnet-trace can leverage --diagnostic-port=,connect instead of pid to connect to a named IPC channel on dotnet-dsrouter (routed to a TCP/IP port on device). It is also possible to connect directly towards dotnet-dsrouters pid since it will masquerade like a regular runtime, but it will not alter the pid passed in EventPipe messages, meaning that dotnet.gcdump's pid checks currently breaks that scenario. This PR extends diagnostic tooling support for mobile and dotnet-dsrouter scenarios. * Add support for --diagnostic-port argument in dotnet-gcdump, but only support connect mode, since listen mode (reverse diagnostic server) is mainly for startup tracing where GC dump is not full supported. * Add support for new command, convert, in dotnet-gcdump that can take a nettrace file as input and convert it into a gcdump file. Can be useful if GC dumps have been captured by tools like dotnet-trace, meaning that dotnet-gcdump will be able to convert it into a gcdump. * Add ability to tell diagnostic tools currently supporting --diagnostic-port command that process in -p is a dsrouter process. This will make the tools construct a default IPC channel used by dsrouter based on the -p parameter, simplify the connect scenarios against dsrouter from tools a lot, since it can use the default IPC channel setup by dsrouter. * Always setup default IPC server channel in dsrouter if no specific ipcs argument has been set. * Fix dsrouter ctrl-c shutdown issue + additional error logging for diagnostic. --- diff --git a/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/IpcTransport.cs b/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/IpcTransport.cs index d49b24d00..6789b0aed 100644 --- a/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/IpcTransport.cs +++ b/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsIpc/IpcTransport.cs @@ -262,25 +262,7 @@ namespace Microsoft.Diagnostics.NETCore.Client private string GetDefaultAddress() { - try - { - Process process = Process.GetProcessById(_pid); - } - catch (ArgumentException) - { - throw new ServerNotAvailableException($"Process {_pid} is not running."); - } - catch (InvalidOperationException) - { - throw new ServerNotAvailableException($"Process {_pid} seems to be elevated."); - } - - if (!TryGetDefaultAddress(_pid, out string transportName)) - { - throw new ServerNotAvailableException($"Process {_pid} not running compatible .NET runtime."); - } - - return transportName; + return GetDefaultAddress(_pid); } private static bool TryGetDefaultAddress(int pid, out string defaultAddress) @@ -290,6 +272,16 @@ namespace Microsoft.Diagnostics.NETCore.Client if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { defaultAddress = $"dotnet-diagnostic-{pid}"; + + try + { + string dsrouterAddress = Directory.GetFiles(IpcRootPath, $"dotnet-diagnostic-dsrouter-{pid}").FirstOrDefault(); + if (!string.IsNullOrEmpty(dsrouterAddress)) + { + defaultAddress = dsrouterAddress; + } + } + catch { } } else { @@ -298,15 +290,62 @@ namespace Microsoft.Diagnostics.NETCore.Client defaultAddress = Directory.GetFiles(IpcRootPath, $"dotnet-diagnostic-{pid}-*-socket") // Try best match. .OrderByDescending(f => new FileInfo(f).LastWriteTime) .FirstOrDefault(); + + string dsrouterAddress = Directory.GetFiles(IpcRootPath, $"dotnet-diagnostic-dsrouter-{pid}-*-socket") // Try best match. + .OrderByDescending(f => new FileInfo(f).LastWriteTime) + .FirstOrDefault(); + + if (!string.IsNullOrEmpty(dsrouterAddress) && !string.IsNullOrEmpty(defaultAddress)) + { + FileInfo defaultFile = new(defaultAddress); + FileInfo dsrouterFile = new(dsrouterAddress); + + if (dsrouterFile.LastWriteTime >= defaultFile.LastWriteTime) + { + defaultAddress = dsrouterAddress; + } + } } - catch (InvalidOperationException) - { - } + catch { } } return !string.IsNullOrEmpty(defaultAddress); } + public static string GetDefaultAddress(int pid) + { + try + { + Process process = Process.GetProcessById(pid); + } + catch (ArgumentException) + { + throw new ServerNotAvailableException($"Process {pid} is not running."); + } + catch (InvalidOperationException) + { + throw new ServerNotAvailableException($"Process {pid} seems to be elevated."); + } + + if (!TryGetDefaultAddress(pid, out string defaultAddress)) + { + throw new ServerNotAvailableException($"Process {pid} not running compatible .NET runtime."); + } + + return defaultAddress; + } + + public static bool IsDefaultAddressDSRouter(int pid, string address) + { + if (address.StartsWith(IpcRootPath, StringComparison.OrdinalIgnoreCase)) + { + address = address.Substring(IpcRootPath.Length); + } + + string dsrouterAddress = $"dotnet-diagnostic-dsrouter-{pid}"; + return address.StartsWith(dsrouterAddress, StringComparison.OrdinalIgnoreCase); + } + public override bool Equals(object obj) { return Equals(obj as PidIpcEndpoint); diff --git a/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsServerRouter/DiagnosticsServerRouterRunner.cs b/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsServerRouter/DiagnosticsServerRouterRunner.cs index 19cd30202..80abfd44c 100644 --- a/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsServerRouter/DiagnosticsServerRouterRunner.cs +++ b/src/Microsoft.Diagnostics.NETCore.Client/DiagnosticsServerRouter/DiagnosticsServerRouterRunner.cs @@ -137,6 +137,8 @@ namespace Microsoft.Diagnostics.NETCore.Client routerFactory.Logger?.LogInformation("Starting automatic shutdown."); throw; } + + routerFactory.Logger?.LogTrace($"runRouter continues after exception: {ex.Message}"); } } } diff --git a/src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj b/src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj index 094b30935..c4fe96c3e 100644 --- a/src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj +++ b/src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj @@ -1,4 +1,4 @@ - + Library netstandard2.0;net6.0 @@ -31,6 +31,7 @@ + diff --git a/src/Tools/Common/Commands/Utils.cs b/src/Tools/Common/Commands/Utils.cs index 2d4951e9b..823bedaf2 100644 --- a/src/Tools/Common/Commands/Utils.cs +++ b/src/Tools/Common/Commands/Utils.cs @@ -2,9 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Diagnostics; - +using System.Collections.Generic; using Microsoft.Diagnostics.NETCore.Client; namespace Microsoft.Internal.Common.Utils @@ -72,7 +71,7 @@ namespace Microsoft.Internal.Common.Utils public static bool ValidateArgumentsForAttach(int processId, string name, string port, out int resolvedProcessId) { resolvedProcessId = -1; - if (processId == 0 && name == null && string.IsNullOrEmpty(port)) + if (processId == 0 && string.IsNullOrEmpty(name) && string.IsNullOrEmpty(port)) { Console.WriteLine("Must specify either --process-id, --name, or --diagnostic-port."); return false; @@ -82,24 +81,24 @@ namespace Microsoft.Internal.Common.Utils Console.WriteLine($"{processId} is not a valid process ID"); return false; } - else if (processId != 0 && name != null && !string.IsNullOrEmpty(port)) + else if (processId != 0 && !string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(port)) { Console.WriteLine("Only one of the --name, --process-id, or --diagnostic-port options may be specified."); return false; } - else if (processId != 0 && name != null) + else if (processId != 0 && !string.IsNullOrEmpty(name)) { - Console.WriteLine("Can only one of specify --name or --process-id."); + Console.WriteLine("Only one of the --name or --process-id options may be specified."); return false; } else if (processId != 0 && !string.IsNullOrEmpty(port)) { - Console.WriteLine("Can only one of specify --process-id or --diagnostic-port."); + Console.WriteLine("Only one of the --process-id or --diagnostic-port options may be specified."); return false; } - else if (name != null && !string.IsNullOrEmpty(port)) + else if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(port)) { - Console.WriteLine("Can only one of specify --name or --diagnostic-port."); + Console.WriteLine("Only one of the --name or --diagnostic-port options may be specified."); return false; } // If we got this far it means only one of --name/--diagnostic-port/--process-id was specified @@ -108,7 +107,7 @@ namespace Microsoft.Internal.Common.Utils return true; } // Resolve name option - else if (name != null) + else if (!string.IsNullOrEmpty(name)) { processId = CommandUtils.FindProcessIdWithName(name); if (processId < 0) diff --git a/src/Tools/dotnet-counters/CounterMonitor.cs b/src/Tools/dotnet-counters/CounterMonitor.cs index 5ec240d05..a2a623d0a 100644 --- a/src/Tools/dotnet-counters/CounterMonitor.cs +++ b/src/Tools/dotnet-counters/CounterMonitor.cs @@ -613,7 +613,6 @@ namespace Microsoft.Diagnostics.Tools.Counters { return (int)ReturnCode.ArgumentError; } - ct.Register(() => _shouldExit.TrySetResult((int)ReturnCode.Ok)); DiagnosticsClientBuilder builder = new("dotnet-counters", 10); diff --git a/src/Tools/dotnet-counters/Program.cs b/src/Tools/dotnet-counters/Program.cs index 826b8373b..ec79a2e6e 100644 --- a/src/Tools/dotnet-counters/Program.cs +++ b/src/Tools/dotnet-counters/Program.cs @@ -167,7 +167,7 @@ namespace Microsoft.Diagnostics.Tools.Counters private static Option DiagnosticPortOption() => new( - alias: "--diagnostic-port", + aliases: new[] { "--dport", "--diagnostic-port" }, description: "The path to diagnostic port to be used.") { Argument = new Argument(name: "diagnosticPort", getDefaultValue: () => "") diff --git a/src/Tools/dotnet-dsrouter/DiagnosticsServerRouterCommands.cs b/src/Tools/dotnet-dsrouter/DiagnosticsServerRouterCommands.cs index f7c37f117..59692a9e1 100644 --- a/src/Tools/dotnet-dsrouter/DiagnosticsServerRouterCommands.cs +++ b/src/Tools/dotnet-dsrouter/DiagnosticsServerRouterCommands.cs @@ -105,6 +105,8 @@ namespace Microsoft.Diagnostics.Tools.DiagnosticsServerRouter ILogger logger = loggerFactory.CreateLogger("dotnet-dsrouter"); + logger.LogInformation($"Starting dotnet-dsrouter using pid={Process.GetCurrentProcess().Id}"); + Task routerTask = createRouterTask(logger, Launcher, linkedCancelToken); while (!linkedCancelToken.IsCancellationRequested) @@ -127,7 +129,19 @@ namespace Microsoft.Diagnostics.Tools.DiagnosticsServerRouter } } } - return routerTask.Result; + + if (!routerTask.IsCompleted) + { + cancelRouterTask.Cancel(); + } + + await Task.WhenAny(routerTask, Task.Delay(1000, CancellationToken.None)).ConfigureAwait(false); + if (routerTask.IsCompleted) + { + return routerTask.Result; + } + + return 0; } } @@ -335,19 +349,12 @@ namespace Microsoft.Diagnostics.Tools.DiagnosticsServerRouter private static string GetDefaultIpcServerPath(ILogger logger) { + string path = string.Empty; int processId = Process.GetCurrentProcess().Id; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - string path = Path.Combine(PidIpcEndpoint.IpcRootPath, $"dotnet-diagnostic-{processId}"); - if (File.Exists(path)) - { - logger?.LogWarning($"Default IPC server path, {path}, already in use. To disable default diagnostics for dotnet-dsrouter, set DOTNET_EnableDiagnostics=0 and re-run."); - - path = Path.Combine(PidIpcEndpoint.IpcRootPath, $"dotnet-dsrouter-{processId}"); - logger?.LogWarning($"Fallback using none default IPC server path, {path}."); - } - - return path.Substring(PidIpcEndpoint.IpcRootPath.Length); + path = $"dotnet-diagnostic-dsrouter-{processId}"; } else { @@ -358,19 +365,13 @@ namespace Microsoft.Diagnostics.Tools.DiagnosticsServerRouter unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); #endif TimeSpan diff = Process.GetCurrentProcess().StartTime.ToUniversalTime() - unixEpoch; - - string path = Path.Combine(PidIpcEndpoint.IpcRootPath, $"dotnet-diagnostic-{processId}-{(long)diff.TotalSeconds}-socket"); - if (Directory.GetFiles(PidIpcEndpoint.IpcRootPath, $"dotnet-diagnostic-{processId}-*-socket").Length != 0) - { - logger?.LogWarning($"Default IPC server path, {Path.Combine(PidIpcEndpoint.IpcRootPath, $"dotnet-diagnostic-{processId}-*-socket")}, already in use. To disable default diagnostics for dotnet-dsrouter, set DOTNET_EnableDiagnostics=0 and re-run."); - - path = Path.Combine(PidIpcEndpoint.IpcRootPath, $"dotnet-dsrouter-{processId}-{(long)diff.TotalSeconds}-socket"); - logger?.LogWarning($"Fallback using none default IPC server path, {path}."); - } - - return path; + path = Path.Combine(PidIpcEndpoint.IpcRootPath, $"dotnet-diagnostic-dsrouter-{processId}-{(long)diff.TotalSeconds}-socket"); } + logger?.LogDebug($"Using default IPC server path, {path}."); + logger?.LogDebug($"Attach to default dotnet-dsrouter IPC server using --process-id {processId} diagnostic tooling argument."); + + return path; } private static TcpClientRouterFactory.CreateInstanceDelegate ChooseTcpClientRouterFactory(string forwardPort, ILogger logger) diff --git a/src/Tools/dotnet-dsrouter/USBMuxTcpClientRouterFactory.cs b/src/Tools/dotnet-dsrouter/USBMuxTcpClientRouterFactory.cs index 95cce47a6..1bea8efc7 100644 --- a/src/Tools/dotnet-dsrouter/USBMuxTcpClientRouterFactory.cs +++ b/src/Tools/dotnet-dsrouter/USBMuxTcpClientRouterFactory.cs @@ -362,6 +362,7 @@ namespace Microsoft.Diagnostics.Tools.DiagnosticsServerRouter { if (_deviceConnectionID == 0) { + _logger.LogError($"Failed to connect device over USB, no device currently connected."); throw new Exception($"Failed to connect device over USB, no device currently connected."); } @@ -370,6 +371,7 @@ namespace Microsoft.Diagnostics.Tools.DiagnosticsServerRouter if (result != 0) { + _logger?.LogError($"Failed USBMuxConnectByPort: device = {_deviceConnectionID}, port = {_port}, result = {result}."); throw new Exception($"Failed to connect device over USB using connection {_deviceConnectionID} and port {_port}."); } diff --git a/src/Tools/dotnet-gcdump/CommandLine/CollectCommandHandler.cs b/src/Tools/dotnet-gcdump/CommandLine/CollectCommandHandler.cs index ee8723815..63f8912ec 100644 --- a/src/Tools/dotnet-gcdump/CommandLine/CollectCommandHandler.cs +++ b/src/Tools/dotnet-gcdump/CommandLine/CollectCommandHandler.cs @@ -8,6 +8,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using Graphs; +using Microsoft.Diagnostics.NETCore.Client; using Microsoft.Internal.Common.Utils; using Microsoft.Tools.Common; @@ -15,7 +16,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump { internal static class CollectCommandHandler { - private delegate Task CollectDelegate(CancellationToken ct, IConsole console, int processId, string output, int timeout, bool verbose, string name); + private delegate Task CollectDelegate(CancellationToken ct, IConsole console, int processId, string output, int timeout, bool verbose, string name, string diagnosticPort); /// /// Collects a gcdump from a currently running process. @@ -24,37 +25,42 @@ namespace Microsoft.Diagnostics.Tools.GCDump /// /// The process to collect the gcdump from. /// The output path for the collected gcdump. + /// The timeout for the collected gcdump. + /// Enable verbose logging. + /// The process name to collect the gcdump from. + /// The diagnostic IPC channel to collect the gcdump from. /// - private static async Task Collect(CancellationToken ct, IConsole console, int processId, string output, int timeout, bool verbose, string name) + private static async Task Collect(CancellationToken ct, IConsole console, int processId, string output, int timeout, bool verbose, string name, string diagnosticPort) { - if (name != null) + if (!CommandUtils.ValidateArgumentsForAttach(processId, name, diagnosticPort, out int resolvedProcessId)) { - if (processId != 0) - { - Console.WriteLine("Can only specify either --name or --process-id option."); - return -1; - } - processId = CommandUtils.FindProcessIdWithName(name); - if (processId < 0) - { - return -1; - } + return -1; } - try + processId = resolvedProcessId; + + if (!string.IsNullOrEmpty(diagnosticPort)) { - if (processId < 0) + try { - Console.Out.WriteLine($"The PID cannot be negative: {processId}"); - return -1; + IpcEndpointConfig config = IpcEndpointConfig.Parse(diagnosticPort); + if (!config.IsConnectConfig) + { + Console.Error.WriteLine("--diagnostic-port is only supporting connect mode."); + return -1; + } } - - if (processId == 0) + catch (Exception ex) { - Console.Out.WriteLine("-p|--process-id is required"); + Console.Error.WriteLine($"--diagnostic-port argument error: {ex.Message}"); return -1; } + processId = 0; + } + + try + { output = string.IsNullOrEmpty(output) ? $"{DateTime.Now:yyyyMMdd\\_HHmmss}_{processId}.gcdump" : output; @@ -74,7 +80,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump Console.Out.WriteLine($"Writing gcdump to '{outputFileInfo.FullName}'..."); Task dumpTask = Task.Run(() => { - if (TryCollectMemoryGraph(ct, processId, timeout, verbose, out MemoryGraph memoryGraph)) + if (TryCollectMemoryGraph(ct, processId, diagnosticPort, timeout, verbose, out MemoryGraph memoryGraph)) { GCHeapDump.WriteMemoryGraph(memoryGraph, outputFileInfo.FullName, "dotnet-gcdump"); return true; @@ -109,15 +115,14 @@ namespace Microsoft.Diagnostics.Tools.GCDump } } - internal static bool TryCollectMemoryGraph(CancellationToken ct, int processId, int timeout, bool verbose, - out MemoryGraph memoryGraph) + internal static bool TryCollectMemoryGraph(CancellationToken ct, int processId, string diagnosticPort, int timeout, bool verbose, out MemoryGraph memoryGraph) { DotNetHeapInfo heapInfo = new(); TextWriter log = verbose ? Console.Out : TextWriter.Null; memoryGraph = new MemoryGraph(50_000); - if (!EventPipeDotNetHeapDumper.DumpFromEventPipe(ct, processId, memoryGraph, log, timeout, heapInfo)) + if (!EventPipeDotNetHeapDumper.DumpFromEventPipe(ct, processId, diagnosticPort, memoryGraph, log, timeout, heapInfo)) { return false; } @@ -134,10 +139,15 @@ namespace Microsoft.Diagnostics.Tools.GCDump // Handler HandlerDescriptor.FromDelegate((CollectDelegate) Collect).GetCommandHandler(), // Options - ProcessIdOption(), OutputPathOption(), VerboseOption(), TimeoutOption(), NameOption() + ProcessIdOption(), + OutputPathOption(), + VerboseOption(), + TimeoutOption(), + NameOption(), + DiagnosticPortOption() }; - private static Option ProcessIdOption() => + private static Option ProcessIdOption() => new( aliases: new[] { "-p", "--process-id" }, description: "The process id to collect the gcdump from.") @@ -145,7 +155,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump Argument = new Argument(name: "pid"), }; - private static Option NameOption() => + private static Option NameOption() => new( aliases: new[] { "-n", "--name" }, description: "The name of the process to collect the gcdump from.") @@ -153,7 +163,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump Argument = new Argument(name: "name") }; - private static Option OutputPathOption() => + private static Option OutputPathOption() => new( aliases: new[] { "-o", "--output" }, description: $@"The path where collected gcdumps should be written. Defaults to '.\YYYYMMDD_HHMMSS_.gcdump' where YYYYMMDD is Year/Month/Day and HHMMSS is Hour/Minute/Second. Otherwise, it is the full path and file name of the dump.") @@ -161,7 +171,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump Argument = new Argument(name: "gcdump-file-path", getDefaultValue: () => string.Empty) }; - private static Option VerboseOption() => + private static Option VerboseOption() => new( aliases: new[] { "-v", "--verbose" }, description: "Output the log while collecting the gcdump.") @@ -170,12 +180,20 @@ namespace Microsoft.Diagnostics.Tools.GCDump }; public static int DefaultTimeout = 30; - private static Option TimeoutOption() => + private static Option TimeoutOption() => new( aliases: new[] { "-t", "--timeout" }, description: $"Give up on collecting the gcdump if it takes longer than this many seconds. The default value is {DefaultTimeout}s.") { Argument = new Argument(name: "timeout", getDefaultValue: () => DefaultTimeout) }; + + private static Option DiagnosticPortOption() => + new( + aliases: new[] { "--dport", "--diagnostic-port" }, + description: "The path to a diagnostic port to collect the dump from.") + { + Argument = new Argument(name: "diagnostic-port", getDefaultValue: () => string.Empty) + }; } } diff --git a/src/Tools/dotnet-gcdump/CommandLine/ConvertCommandHandler.cs b/src/Tools/dotnet-gcdump/CommandLine/ConvertCommandHandler.cs new file mode 100644 index 000000000..60de9b0e9 --- /dev/null +++ b/src/Tools/dotnet-gcdump/CommandLine/ConvertCommandHandler.cs @@ -0,0 +1,92 @@ +// 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.CommandLine; +using System.IO; +using Graphs; +using Microsoft.Tools.Common; + +namespace Microsoft.Diagnostics.Tools.GCDump +{ + internal static class ConvertCommandHandler + { + public static int ConvertFile(FileInfo input, string output, bool verbose) + { + if (!input.Exists) + { + Console.Error.WriteLine($"File '{input.FullName}' does not exist."); + return -1; + } + + output = string.IsNullOrEmpty(output) + ? Path.ChangeExtension(input.FullName, "gcdump") + : output; + + FileInfo outputFileInfo = new(output); + + if (outputFileInfo.Exists) + { + outputFileInfo.Delete(); + } + + if (string.IsNullOrEmpty(outputFileInfo.Extension) || outputFileInfo.Extension != ".gcdump") + { + outputFileInfo = new FileInfo(outputFileInfo.FullName + ".gcdump"); + } + + Console.Out.WriteLine($"Writing gcdump to '{outputFileInfo.FullName}'..."); + + DotNetHeapInfo heapInfo = new(); + TextWriter log = verbose ? Console.Out : TextWriter.Null; + + MemoryGraph memoryGraph = new(50_000); + + if (!EventPipeDotNetHeapDumper.DumpFromEventPipeFile(input.FullName, memoryGraph, log, heapInfo)) + { + return -1; + } + + memoryGraph.AllowReading(); + GCHeapDump.WriteMemoryGraph(memoryGraph, outputFileInfo.FullName, "dotnet-gcdump"); + + return 0; + } + + public static System.CommandLine.Command ConvertCommand() => + new( + name: "convert", + description: "Converts nettrace file into .gcdump file handled by analysis tools. Can only convert from the nettrace format.") + { + // Handler + System.CommandLine.Invocation.CommandHandler.Create(ConvertFile), + // Arguments and Options + InputPathArgument(), + OutputPathOption(), + VerboseOption() + }; + + private static Argument InputPathArgument() => + new Argument("input") + { + Description = "Input trace file to be converted.", + Arity = new ArgumentArity(0, 1) + }.ExistingOnly(); + + private static Option OutputPathOption() => + new( + aliases: new[] { "-o", "--output" }, + description: $@"The path where converted gcdump should be written. Defaults to '.gcdump'") + { + Argument = new Argument(name: "output", getDefaultValue: () => string.Empty) + }; + + private static Option VerboseOption() => + new( + aliases: new[] { "-v", "--verbose" }, + description: "Output the log while converting the gcdump.") + { + Argument = new Argument(name: "verbose", getDefaultValue: () => false) + }; + } +} diff --git a/src/Tools/dotnet-gcdump/CommandLine/ReportCommandHandler.cs b/src/Tools/dotnet-gcdump/CommandLine/ReportCommandHandler.cs index 2f754f740..27dd2400e 100644 --- a/src/Tools/dotnet-gcdump/CommandLine/ReportCommandHandler.cs +++ b/src/Tools/dotnet-gcdump/CommandLine/ReportCommandHandler.cs @@ -9,12 +9,14 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Diagnostics.Tools.GCDump.CommandLine; using Microsoft.Tools.Common; +using Microsoft.Internal.Common.Utils; +using Microsoft.Diagnostics.NETCore.Client; namespace Microsoft.Diagnostics.Tools.GCDump { internal static class ReportCommandHandler { - private delegate Task ReportDelegate(CancellationToken ct, IConsole console, FileInfo gcdump_filename, int? processId = null, ReportType reportType = ReportType.HeapStat); + private delegate Task ReportDelegate(CancellationToken ct, IConsole console, FileInfo gcdump_filename, int? processId = null, ReportType reportType = ReportType.HeapStat, string diagnosticPort = null); public static Command ReportCommand() => new( @@ -24,23 +26,32 @@ namespace Microsoft.Diagnostics.Tools.GCDump // Handler HandlerDescriptor.FromDelegate((ReportDelegate) Report).GetCommandHandler(), // Options - FileNameArgument(), ProcessIdOption(), ReportTypeOption() + FileNameArgument(), + ProcessIdOption(), + ReportTypeOption(), + DiagnosticPortOption(), }; - private static Task Report(CancellationToken ct, IConsole console, FileInfo gcdump_filename, int? processId = null, ReportType type = ReportType.HeapStat) + private static Task Report(CancellationToken ct, IConsole console, FileInfo gcdump_filename, int? processId = null, ReportType type = ReportType.HeapStat, string diagnosticPort = null) { // // Validation // - if (gcdump_filename == null && !processId.HasValue) + if (gcdump_filename == null && !processId.HasValue && string.IsNullOrEmpty(diagnosticPort)) { - Console.Error.WriteLine(" or -p|--process-id is required"); + Console.Error.WriteLine(" or -p|--process-id or --dport|--diagnostic-port is required"); return Task.FromResult(-1); } - if (gcdump_filename != null && processId.HasValue) + if (gcdump_filename != null && (processId.HasValue || !string.IsNullOrEmpty(diagnosticPort))) { - Console.Error.WriteLine("Specify only one of -f|--file or -p|--process-id."); + Console.Error.WriteLine("Specify only one of -f|--file or -p|--process-id or --dport|--diagnostic-port."); + return Task.FromResult(-1); + } + + if (processId.HasValue && !string.IsNullOrEmpty(diagnosticPort)) + { + Console.Error.WriteLine("Specify only one of -p|--process-id or -dport|--diagnostic-port."); return Task.FromResult(-1); } @@ -53,14 +64,14 @@ namespace Microsoft.Diagnostics.Tools.GCDump { source = ReportSource.DumpFile; } - else if (processId.HasValue) + else if (processId.HasValue || !string.IsNullOrEmpty(diagnosticPort)) { source = ReportSource.Process; } return (source, type) switch { - (ReportSource.Process, ReportType.HeapStat) => ReportFromProcess(processId.Value, ct), + (ReportSource.Process, ReportType.HeapStat) => ReportFromProcess(processId ?? 0, diagnosticPort, ct), (ReportSource.DumpFile, ReportType.HeapStat) => ReportFromFile(gcdump_filename), _ => HandleUnknownParam() }; @@ -72,10 +83,37 @@ namespace Microsoft.Diagnostics.Tools.GCDump return Task.FromResult(-1); } - private static Task ReportFromProcess(int processId, CancellationToken ct) + private static Task ReportFromProcess(int processId, string diagnosticPort, CancellationToken ct) { + if (!CommandUtils.ValidateArgumentsForAttach(processId, string.Empty, diagnosticPort, out int resolvedProcessId)) + { + return Task.FromResult(-1); + } + + processId = resolvedProcessId; + + if (!string.IsNullOrEmpty(diagnosticPort)) + { + try + { + IpcEndpointConfig config = IpcEndpointConfig.Parse(diagnosticPort); + if (!config.IsConnectConfig) + { + Console.Error.WriteLine("--diagnostic-port is only supporting connect mode."); + return Task.FromResult(-1); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"--diagnostic-port argument error: {ex.Message}"); + return Task.FromResult(-1); + } + + processId = 0; + } + if (!CollectCommandHandler - .TryCollectMemoryGraph(ct, processId, CollectCommandHandler.DefaultTimeout, false, out Graphs.MemoryGraph mg)) + .TryCollectMemoryGraph(ct, processId, diagnosticPort, CollectCommandHandler.DefaultTimeout, false, out Graphs.MemoryGraph mg)) { Console.Error.WriteLine("An error occured while collecting gcdump."); return Task.FromResult(-1); @@ -115,12 +153,27 @@ namespace Microsoft.Diagnostics.Tools.GCDump }.ExistingOnly(); private static Option ProcessIdOption() => - new(new[] { "-p", "--process-id" }, "The process id to collect the gcdump from."); + new( + aliases: new[] { "-p", "--process-id" }, + description: "The process id to collect the gcdump from.") + { + Argument = new Argument(name: "pid"), + }; private static Option ReportTypeOption() => - new(new[] { "-t", "--report-type" }, "The type of report to generate. Available options: heapstat (default)") + new( + aliases: new[] { "-t", "--report-type" }, + description: "The type of report to generate. Available options: heapstat (default)") + { + Argument = new Argument(name: "report-type", () => ReportType.HeapStat) + }; + + private static Option DiagnosticPortOption() => + new( + aliases: new[] { "--dport", "--diagnostic-port" }, + description: "The path to a diagnostic port to collect the dump from.") { - Argument = new Argument(() => ReportType.HeapStat) + Argument = new Argument(name: "diagnostic-port", getDefaultValue: () => string.Empty) }; private enum ReportSource diff --git a/src/Tools/dotnet-gcdump/DotNetHeapDump/EventPipeDotNetHeapDumper.cs b/src/Tools/dotnet-gcdump/DotNetHeapDump/EventPipeDotNetHeapDumper.cs index fb97bc355..4d8a40c6b 100644 --- a/src/Tools/dotnet-gcdump/DotNetHeapDump/EventPipeDotNetHeapDumper.cs +++ b/src/Tools/dotnet-gcdump/DotNetHeapDump/EventPipeDotNetHeapDumper.cs @@ -20,18 +20,102 @@ namespace Microsoft.Diagnostics.Tools.GCDump internal static volatile bool eventPipeDataPresent; internal static volatile bool dumpComplete; + /// + /// Given a nettrace file from a EventPipe session with the appropriate provider and keywords turned on, + /// generate a GCHeapDump using the resulting events. + /// + /// + /// + /// + /// + /// + public static bool DumpFromEventPipeFile(string path, MemoryGraph memoryGraph, TextWriter log, DotNetHeapInfo dotNetInfo) + { + DateTime start = DateTime.Now; + Func getElapsed = () => DateTime.Now - start; + + DotNetHeapDumpGraphReader dumper = new(log) + { + DotNetHeapInfo = dotNetInfo + }; + + try + { + TimeSpan lastEventPipeUpdate = getElapsed(); + + int gcNum = -1; + + EventPipeEventSource source = new(path); + + source.Clr.GCStart += delegate (GCStartTraceData data) + { + eventPipeDataPresent = true; + + if (gcNum < 0 && data.Depth == 2 && data.Type != GCType.BackgroundGC) + { + gcNum = data.Count; + log.WriteLine("{0,5:n1}s: .NET Dump Started...", getElapsed().TotalSeconds); + } + }; + + source.Clr.GCStop += delegate (GCEndTraceData data) + { + if (data.Count == gcNum) + { + log.WriteLine("{0,5:n1}s: .NET GC Complete.", getElapsed().TotalSeconds); + dumpComplete = true; + } + }; + + source.Clr.GCBulkNode += delegate (GCBulkNodeTraceData data) + { + eventPipeDataPresent = true; + + if ((getElapsed() - lastEventPipeUpdate).TotalMilliseconds > 500) + { + log.WriteLine("{0,5:n1}s: Making GC Heap Progress...", getElapsed().TotalSeconds); + } + + lastEventPipeUpdate = getElapsed(); + }; + + if (memoryGraph != null) + { + dumper.SetupCallbacks(memoryGraph, source); + } + + log.WriteLine("{0,5:n1}s: Starting to process events", getElapsed().TotalSeconds); + source.Process(); + log.WriteLine("{0,5:n1}s: Finished processing events", getElapsed().TotalSeconds); + + if (eventPipeDataPresent) + { + dumper.ConvertHeapDataToGraph(); + } + } + catch (Exception e) + { + log.WriteLine($"{getElapsed().TotalSeconds,5:n1}s: [Error] Exception processing events: {e}"); + } + + log.WriteLine("[{0,5:n1}s: Done Dumping .NET heap success={1}]", getElapsed().TotalSeconds, dumpComplete); + + return dumpComplete; + } + /// /// Given a factory for creating an EventPipe session with the appropriate provider and keywords turned on, /// generate a GCHeapDump using the resulting events. The correct keywords and provider name /// are given as input to the Func eventPipeEventSourceFactory. /// - /// - /// A delegate for creating and stopping EventPipe sessions + /// + /// /// /// + /// /// /// - public static bool DumpFromEventPipe(CancellationToken ct, int processID, MemoryGraph memoryGraph, TextWriter log, int timeout, DotNetHeapInfo dotNetInfo = null) + public static bool DumpFromEventPipe(CancellationToken ct, int processId, string diagnosticPort, MemoryGraph memoryGraph, TextWriter log, int timeout, DotNetHeapInfo dotNetInfo) { DateTime start = DateTime.Now; Func getElapsed = () => DateTime.Now - start; @@ -47,7 +131,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump bool fDone = false; log.WriteLine("{0,5:n1}s: Creating type table flushing task", getElapsed().TotalSeconds); - using (EventPipeSessionController typeFlushSession = new(processID, new List { + using (EventPipeSessionController typeFlushSession = new(processId, diagnosticPort, new List { new EventPipeProvider("Microsoft-DotNETCore-SampleProfiler", EventLevel.Informational) }, false)) { @@ -72,7 +156,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump // Start the providers and trigger the GCs. log.WriteLine("{0,5:n1}s: Requesting a .NET Heap Dump", getElapsed().TotalSeconds); - using EventPipeSessionController gcDumpSession = new(processID, new List { + using EventPipeSessionController gcDumpSession = new(processId, diagnosticPort, new List { new EventPipeProvider("Microsoft-Windows-DotNETRuntime", EventLevel.Verbose, (long)(ClrTraceEventParser.Keywords.GCHeapSnapshot)) }); log.WriteLine("{0,5:n1}s: gcdump EventPipe Session started", getElapsed().TotalSeconds); @@ -81,7 +165,11 @@ namespace Microsoft.Diagnostics.Tools.GCDump gcDumpSession.Source.Clr.GCStart += delegate (GCStartTraceData data) { - if (data.ProcessID != processID) + if (gcDumpSession.UseWildcardProcessId) + { + processId = data.ProcessID; + } + if (data.ProcessID != processId) { return; } @@ -97,7 +185,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump gcDumpSession.Source.Clr.GCStop += delegate (GCEndTraceData data) { - if (data.ProcessID != processID) + if (data.ProcessID != processId) { return; } @@ -111,7 +199,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump gcDumpSession.Source.Clr.GCBulkNode += delegate (GCBulkNodeTraceData data) { - if (data.ProcessID != processID) + if (data.ProcessID != processId) { return; } @@ -128,7 +216,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump if (memoryGraph != null) { - dumper.SetupCallbacks(memoryGraph, gcDumpSession.Source, processID.ToString()); + dumper.SetupCallbacks(memoryGraph, gcDumpSession.Source, gcDumpSession.UseWildcardProcessId ? null : processId.ToString()); } // Set up a separate thread that will listen for EventPipe events coming back telling us we succeeded. @@ -229,15 +317,49 @@ namespace Microsoft.Diagnostics.Tools.GCDump private EventPipeSession _session; private EventPipeEventSource _source; private int _pid; + private IpcEndpointConfig _diagnosticPort; public IReadOnlyList Providers => _providers.AsReadOnly(); public EventPipeEventSource Source => _source; - public EventPipeSessionController(int pid, List providers, bool requestRundown = true) + public bool UseWildcardProcessId => _diagnosticPort != null; + + public EventPipeSessionController(int pid, string diagnosticPort, List providers, bool requestRundown = true) { + if (string.IsNullOrEmpty(diagnosticPort)) + { + try + { + string defaultAddress = PidIpcEndpoint.GetDefaultAddress(pid); + if (!string.IsNullOrEmpty(defaultAddress) && PidIpcEndpoint.IsDefaultAddressDSRouter(pid, defaultAddress)) + { + diagnosticPort = defaultAddress + ",connect"; + } + } + catch { } + } + + if (!string.IsNullOrEmpty(diagnosticPort)) + { + _diagnosticPort = IpcEndpointConfig.Parse(diagnosticPort); + if (!_diagnosticPort.IsConnectConfig) + { + throw new ArgumentException("DiagnosticPort is only supporting connect mode."); + } + } + _pid = pid; _providers = providers; - _client = new DiagnosticsClient(pid); + + if (_diagnosticPort != null) + { + _client = new DiagnosticsClient(_diagnosticPort); + } + else + { + _client = new DiagnosticsClient(pid); + } + _session = _client.StartEventPipeSession(providers, requestRundown, 1024); _source = new EventPipeEventSource(_session.EventStream); } diff --git a/src/Tools/dotnet-gcdump/Program.cs b/src/Tools/dotnet-gcdump/Program.cs index 8830c743d..5de61d81a 100644 --- a/src/Tools/dotnet-gcdump/Program.cs +++ b/src/Tools/dotnet-gcdump/Program.cs @@ -16,6 +16,7 @@ namespace Microsoft.Diagnostics.Tools.GCDump .AddCommand(CollectCommandHandler.CollectCommand()) .AddCommand(ProcessStatusCommandHandler.ProcessStatusCommand("Lists the dotnet processes that gcdumps can be collected from.")) .AddCommand(ReportCommandHandler.ReportCommand()) + .AddCommand(ConvertCommandHandler.ConvertCommand()) .UseDefaults() .Build(); diff --git a/src/Tools/dotnet-trace/CommandLine/Commands/CollectCommand.cs b/src/Tools/dotnet-trace/CommandLine/Commands/CollectCommand.cs index fbf3fdac4..15eab9c79 100644 --- a/src/Tools/dotnet-trace/CommandLine/Commands/CollectCommand.cs +++ b/src/Tools/dotnet-trace/CommandLine/Commands/CollectCommand.cs @@ -526,7 +526,7 @@ namespace Microsoft.Diagnostics.Tools.Trace }; private static Option DiagnosticPortOption() => new( - alias: "--diagnostic-port", + aliases: new[] { "--dport", "--diagnostic-port" }, description: @"The path to a diagnostic port to be used.") { Argument = new Argument(name: "diagnosticPort", getDefaultValue: () => string.Empty)