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 c006330b90..098fb2813b 100644 --- a/src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj +++ b/src/Microsoft.Diagnostics.NETCore.Client/Microsoft.Diagnostics.NETCore.Client.csproj @@ -41,6 +41,7 @@ + diff --git a/src/Tools/dotnet-stack/ReportCommand.cs b/src/Tools/dotnet-stack/ReportCommand.cs index c8f2ecc365..79389c4009 100644 --- a/src/Tools/dotnet-stack/ReportCommand.cs +++ b/src/Tools/dotnet-stack/ReportCommand.cs @@ -27,22 +27,44 @@ internal static class ReportCommandHandler /// The process to report the stack from. /// The name of process to report the stack from. /// The duration of to trace the target for. + /// The diagnostic port to connect to. /// - private static async Task Report(CancellationToken ct, TextWriter stdOutput, TextWriter stdError, int processId, string name, TimeSpan duration) + private static async Task Report(CancellationToken ct, TextWriter stdOutput, TextWriter stdError, int processId, string name, TimeSpan duration, string diagnosticPort) { string tempNetTraceFilename = Path.Join(Path.GetTempPath(), Path.GetRandomFileName() + ".nettrace"); string tempEtlxFilename = ""; try { - // Either processName or processId has to be specified. + // Validate that only one of processId, name, or diagnosticPort is specified + int optionCount = 0; + if (processId != 0) + { + optionCount++; + } + if (!string.IsNullOrEmpty(name)) + { + optionCount++; + } + if (!string.IsNullOrEmpty(diagnosticPort)) + { + optionCount++; + } + + if (optionCount == 0) + { + stdError.WriteLine("--process-id, --name, or --diagnostic-port is required"); + return -1; + } + else if (optionCount > 1) + { + stdError.WriteLine("Only one of --process-id, --name, or --diagnostic-port can be specified"); + return -1; + } + + // Resolve process name to ID if needed if (!string.IsNullOrEmpty(name)) { - if (processId != 0) - { - Console.WriteLine("Can only specify either --name or --process-id option."); - return -1; - } processId = CommandUtils.FindProcessIdWithName(name); if (processId < 0) { @@ -55,91 +77,125 @@ private static async Task Report(CancellationToken ct, TextWriter stdOutput stdError.WriteLine("Process ID should not be negative."); return -1; } - else if (processId == 0) + + DiagnosticsClientBuilder builder = new("dotnet-stack", 10); + using (DiagnosticsClientHolder holder = await builder.Build(ct, processId, diagnosticPort, showChildIO: false, printLaunchCommand: false).ConfigureAwait(false)) { - stdError.WriteLine("--process-id is required"); - return -1; - } + if (holder == null) + { + return -1; + } + DiagnosticsClient client = holder.Client; - DiagnosticsClient client = new(processId); - List providers = new() - { - new EventPipeProvider("Microsoft-DotNETCore-SampleProfiler", EventLevel.Informational) - }; - - // collect a *short* trace with stack samples - // the hidden '--duration' flag can increase the time of this trace in case 10ms - // is too short in a given environment, e.g., resource constrained systems - // N.B. - This trace INCLUDES rundown. For sufficiently large applications, it may take non-trivial time to collect - // the symbol data in rundown. - EventPipeSession session = await client.StartEventPipeSessionAsync(providers, requestRundown:true, token:ct).ConfigureAwait(false); - using (session) - using (FileStream fs = File.OpenWrite(tempNetTraceFilename)) - { - Task copyTask = session.EventStream.CopyToAsync(fs, ct); - await Task.Delay(duration, ct).ConfigureAwait(false); - await session.StopAsync(ct).ConfigureAwait(false); - - // check if rundown is taking more than 5 seconds and add comment to report - Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); - Task completedTask = await Task.WhenAny(copyTask, timeoutTask).ConfigureAwait(false); - if (completedTask == timeoutTask) + // Resume runtime if it was suspended (similar to --resume-runtime:true in other tools) + // This is safe to call even if the runtime wasn't suspended - it's a no-op in that case + try { - stdOutput.WriteLine($"# Sufficiently large applications can cause this reportCommand to take non-trivial amounts of time"); + await client.ResumeRuntimeAsync(ct).ConfigureAwait(false); + } + catch (Exception) + { + // ResumeRuntime is a no-op if the runtime wasn't suspended, + // so we can safely ignore exceptions here } - await copyTask.ConfigureAwait(false); - } - // using the generated trace file, symbolicate and compute stacks. - tempEtlxFilename = TraceLog.CreateFromEventPipeDataFile(tempNetTraceFilename); - using (SymbolReader symbolReader = new(TextWriter.Null) { SymbolPath = SymbolPath.MicrosoftSymbolServerPath }) - using (TraceLog eventLog = new(tempEtlxFilename)) - { - MutableTraceEventStackSource stackSource = new(eventLog) + List providers = new() { - OnlyManagedCodeStacks = true + new EventPipeProvider("Microsoft-DotNETCore-SampleProfiler", EventLevel.Informational) }; - SampleProfilerThreadTimeComputer computer = new(eventLog, symbolReader); - computer.GenerateThreadTimeStacks(stackSource); - - Dictionary> samplesForThread = new(); + // collect a *short* trace with stack samples + // the hidden '--duration' flag can increase the time of this trace in case 10ms + // is too short in a given environment, e.g., resource constrained systems + // N.B. - This trace INCLUDES rundown. For sufficiently large applications, it may take non-trivial time to collect + // the symbol data in rundown. + EventPipeSession session; + try + { + session = await client.StartEventPipeSessionAsync(providers, requestRundown:true, token:ct).ConfigureAwait(false); + } + catch (Exception) + { + stdError.WriteLine(EventPipeErrorMessage); + return -1; + } - stackSource.ForEach((sample) => { - StackSourceCallStackIndex stackIndex = sample.StackIndex; - while (!stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false).StartsWith("Thread (")) + try + { + using (session) + using (FileStream fs = File.OpenWrite(tempNetTraceFilename)) { - stackIndex = stackSource.GetCallerIndex(stackIndex); + Task copyTask = session.EventStream.CopyToAsync(fs, ct); + await Task.Delay(duration, ct).ConfigureAwait(false); + await session.StopAsync(ct).ConfigureAwait(false); + + // check if rundown is taking more than 5 seconds and add comment to report + Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); + Task completedTask = await Task.WhenAny(copyTask, timeoutTask).ConfigureAwait(false); + if (completedTask == timeoutTask) + { + stdOutput.WriteLine($"# Sufficiently large applications can cause this reportCommand to take non-trivial amounts of time"); + } + await copyTask.ConfigureAwait(false); } + } + catch (Exception) + { + stdError.WriteLine(EventPipeErrorMessage); + return -1; + } - // long form for: int.Parse(threadFrame["Thread (".Length..^1)]) - // Thread id is in the frame name as "Thread ()" - string template = "Thread ("; - string threadFrame = stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false); + // using the generated trace file, symbolicate and compute stacks. + tempEtlxFilename = TraceLog.CreateFromEventPipeDataFile(tempNetTraceFilename); + using (SymbolReader symbolReader = new(TextWriter.Null) { SymbolPath = SymbolPath.MicrosoftSymbolServerPath }) + using (TraceLog eventLog = new(tempEtlxFilename)) + { + MutableTraceEventStackSource stackSource = new(eventLog) + { + OnlyManagedCodeStacks = true + }; - // we are looking for the first index of ) because - // we need to handle a thread name like: Thread (4008) (.NET IO ThreadPool Worker) - int firstIndex = threadFrame.IndexOf(')'); - int threadId = int.Parse(threadFrame.AsSpan(template.Length, firstIndex - template.Length)); + SampleProfilerThreadTimeComputer computer = new(eventLog, symbolReader); + computer.GenerateThreadTimeStacks(stackSource); - if (samplesForThread.TryGetValue(threadId, out List samples)) - { - samples.Add(sample); - } - else - { - samplesForThread[threadId] = new List() { sample }; - } - }); + Dictionary> samplesForThread = new(); - // For every thread recorded in our trace, print the first stack - foreach ((int threadId, List samples) in samplesForThread) - { + stackSource.ForEach((sample) => { + StackSourceCallStackIndex stackIndex = sample.StackIndex; + while (!stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false).StartsWith("Thread (")) + { + stackIndex = stackSource.GetCallerIndex(stackIndex); + } + + // long form for: int.Parse(threadFrame["Thread (".Length..^1)]) + // Thread id is in the frame name as "Thread ()" + string template = "Thread ("; + string threadFrame = stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false); + + // we are looking for the first index of ) because + // we need to handle a thread name like: Thread (4008) (.NET IO ThreadPool Worker) + int firstIndex = threadFrame.IndexOf(')'); + int threadId = int.Parse(threadFrame.AsSpan(template.Length, firstIndex - template.Length)); + + if (samplesForThread.TryGetValue(threadId, out List samples)) + { + samples.Add(sample); + } + else + { + samplesForThread[threadId] = new List() { sample }; + } + }); + + // For every thread recorded in our trace, print the first stack + foreach ((int threadId, List samples) in samplesForThread) + { #if DEBUG - stdOutput.WriteLine($"Found {samples.Count} stacks for thread 0x{threadId:X}"); + stdOutput.WriteLine($"Found {samples.Count} stacks for thread 0x{threadId:X}"); #endif - PrintStack(stdOutput, threadId, samples[0], stackSource); + PrintStack(stdOutput, threadId, samples[0], stackSource); + } } } } @@ -189,7 +245,8 @@ public static Command ReportCommand() { ProcessIdOption, NameOption, - DurationOption + DurationOption, + DiagnosticPortOption }; reportCommand.SetAction((parseResult, ct) => Report(ct, @@ -197,7 +254,8 @@ public static Command ReportCommand() stdError: parseResult.Configuration.Error, processId: parseResult.GetValue(ProcessIdOption), name: parseResult.GetValue(NameOption), - duration: parseResult.GetValue(DurationOption))); + duration: parseResult.GetValue(DurationOption), + diagnosticPort: parseResult.GetValue(DiagnosticPortOption))); return reportCommand; } @@ -221,5 +279,14 @@ public static Command ReportCommand() { Description = "The name of the process to report the stack." }; + + private static readonly Option DiagnosticPortOption = + new("--diagnostic-port", "--dport") + { + Description = "The path to a diagnostic port to be used." + }; + + private const string EventPipeErrorMessage = + "There was a failure in reading stack data. Possible reasons could be trying to connect to a runtime that has been suspended, an unexpected close of the IPC channel, etc."; } } diff --git a/src/Tools/dotnet-stack/dotnet-stack.csproj b/src/Tools/dotnet-stack/dotnet-stack.csproj index beacb323f4..9f0448817a 100644 --- a/src/Tools/dotnet-stack/dotnet-stack.csproj +++ b/src/Tools/dotnet-stack/dotnet-stack.csproj @@ -23,8 +23,10 @@ + + diff --git a/src/tests/dotnet-stack/StackTests.cs b/src/tests/dotnet-stack/StackTests.cs index c689e5a5ba..cff55e2fc8 100644 --- a/src/tests/dotnet-stack/StackTests.cs +++ b/src/tests/dotnet-stack/StackTests.cs @@ -100,5 +100,26 @@ public async Task ReportsStacksCorrectly(TestConfiguration config) Assert.True(correctStackParts[j] == stackParts[i], $"{correctStackParts[j]} != {stackParts[i]}"); } } + + [Fact] + public void DiagnosticPortOptionIsRegistered() + { + Command reportCommand = ReportCommandHandler.ReportCommand(); + + // Verify the diagnostic port option is registered + bool hasDiagnosticPortOption = false; + foreach (var option in reportCommand.Options) + { + if (option.Name == "diagnostic-port") + { + hasDiagnosticPortOption = true; + // Verify it has the short alias --dport + Assert.Contains("--dport", option.Aliases); + break; + } + } + + Assert.True(hasDiagnosticPortOption, "The --diagnostic-port option should be registered in the report command"); + } } }