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 21e73f2d7e..63d6dad574 100644 --- a/src/Tools/dotnet-stack/ReportCommand.cs +++ b/src/Tools/dotnet-stack/ReportCommand.cs @@ -27,95 +27,155 @@ 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 { - CommandUtils.ResolveProcess(processId, name, out int resolvedProcessId, out string _); - processId = resolvedProcessId; + // 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++; + } - DiagnosticsClient client = new(processId); - List providers = new() + if (optionCount == 0) { - 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)) + stdError.WriteLine("--process-id, --name, or --diagnostic-port is required"); + return -1; + } + else if (optionCount > 1) { - 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) + 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)) + { + processId = CommandUtils.FindProcessIdWithName(name); + if (processId < 0) { - stdOutput.WriteLine($"# Sufficiently large applications can cause this reportCommand to take non-trivial amounts of time"); + return -1; } - 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)) + if (processId < 0) { - MutableTraceEventStackSource stackSource = new(eventLog) - { - OnlyManagedCodeStacks = true - }; + stdError.WriteLine("Process ID should not be negative."); + return -1; + } - SampleProfilerThreadTimeComputer computer = new(eventLog, symbolReader); - computer.GenerateThreadTimeStacks(stackSource); + DiagnosticsClientBuilder builder = new("dotnet-stack", 10); + using (DiagnosticsClientHolder holder = await builder.Build(ct, processId, diagnosticPort, showChildIO: false, printLaunchCommand: false).ConfigureAwait(false)) + { + if (holder == null) + { + return -1; + } - Dictionary> samplesForThread = new(); + DiagnosticsClient client = holder.Client; - stackSource.ForEach((sample) => { - StackSourceCallStackIndex stackIndex = sample.StackIndex; - while (!stackSource.GetFrameName(stackSource.GetFrameIndex(stackIndex), false).StartsWith("Thread (")) - { - stackIndex = stackSource.GetCallerIndex(stackIndex); - } + // Resume runtime if it was suspended (similar to --resume-runtime:true in other tools) + try + { + await client.ResumeRuntimeAsync(ct).ConfigureAwait(false); + } + catch (UnsupportedCommandException) + { + // Noop if command is unsupported, since the target is most likely a 3.1 app. + } - // 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); + List providers = new() + { + new EventPipeProvider("Microsoft-DotNETCore-SampleProfiler", EventLevel.Informational) + }; - // 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)); + // 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); - if (samplesForThread.TryGetValue(threadId, out List samples)) - { - samples.Add(sample); - } - else + // 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) { - samplesForThread[threadId] = new List() { sample }; + stdOutput.WriteLine($"# Sufficiently large applications can cause this reportCommand to take non-trivial amounts of time"); } - }); + await copyTask.ConfigureAwait(false); + } - // For every thread recorded in our trace, print the first stack - foreach ((int threadId, List samples) in samplesForThread) + // 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 + }; + + SampleProfilerThreadTimeComputer computer = new(eventLog, symbolReader); + computer.GenerateThreadTimeStacks(stackSource); + + Dictionary> samplesForThread = new(); + + 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); + } } } } @@ -170,7 +230,8 @@ public static Command ReportCommand() { ProcessIdOption, NameOption, - DurationOption + DurationOption, + DiagnosticPortOption }; reportCommand.SetAction((parseResult, ct) => Report(ct, @@ -178,7 +239,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; } @@ -202,5 +264,11 @@ 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." + }; } } diff --git a/src/Tools/dotnet-stack/dotnet-stack.csproj b/src/Tools/dotnet-stack/dotnet-stack.csproj index 403ee760d6..696ab8a4ea 100644 --- a/src/Tools/dotnet-stack/dotnet-stack.csproj +++ b/src/Tools/dotnet-stack/dotnet-stack.csproj @@ -23,6 +23,7 @@ + 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"); + } } }