From a84a44b561760babd080866bcab8b6c2c338bef2 Mon Sep 17 00:00:00 2001 From: Roger Johansson Date: Sat, 21 Mar 2026 13:01:00 +0100 Subject: [PATCH] Refactor profile input loading responsibilities --- src/ProfileTool/MemoryProfileResultFactory.cs | 37 +++ src/ProfileTool/ProfileCollectionRunner.cs | 2 +- src/ProfileTool/ProfileInputKind.cs | 11 + src/ProfileTool/ProfileInputLoader.cs | 213 +++++------------- src/ProfileTool/ProfileInputPath.cs | 72 ++++++ .../ProfilerExecutionRequestFactory.cs | 4 +- .../MemoryProfileResultFactoryTests.cs | 30 +++ .../ProfileInputLoaderTests.cs | 71 ------ .../ProfileInputPathTests.cs | 66 ++++++ 9 files changed, 273 insertions(+), 233 deletions(-) create mode 100644 src/ProfileTool/MemoryProfileResultFactory.cs create mode 100644 src/ProfileTool/ProfileInputKind.cs create mode 100644 src/ProfileTool/ProfileInputPath.cs create mode 100644 tests/Asynkron.Profiler.Tests/MemoryProfileResultFactoryTests.cs create mode 100644 tests/Asynkron.Profiler.Tests/ProfileInputPathTests.cs diff --git a/src/ProfileTool/MemoryProfileResultFactory.cs b/src/ProfileTool/MemoryProfileResultFactory.cs new file mode 100644 index 0000000..6c9d808 --- /dev/null +++ b/src/ProfileTool/MemoryProfileResultFactory.cs @@ -0,0 +1,37 @@ +using System.Linq; +using static Asynkron.Profiler.CallTreeHelpers; + +namespace Asynkron.Profiler; + +internal static class MemoryProfileResultFactory +{ + public static MemoryProfileResult Build(AllocationCallTreeResult callTree) + { + var allocationEntries = callTree.TypeRoots + .OrderByDescending(root => root.TotalBytes) + .Take(50) + .Select(root => new AllocationEntry(root.Name, root.Count, FormatBytes(root.TotalBytes))) + .ToList(); + + var totalAllocated = FormatBytes(callTree.TotalBytes); + + return new MemoryProfileResult( + null, + null, + null, + totalAllocated, + null, + null, + null, + null, + null, + null, + null, + null, + totalAllocated, + allocationEntries, + callTree, + null, + null); + } +} diff --git a/src/ProfileTool/ProfileCollectionRunner.cs b/src/ProfileTool/ProfileCollectionRunner.cs index d27a63b..1f2e02e 100644 --- a/src/ProfileTool/ProfileCollectionRunner.cs +++ b/src/ProfileTool/ProfileCollectionRunner.cs @@ -92,7 +92,7 @@ public ProfileCollectionRunner( }, _profileInputLoader.AnalyzeAllocationTrace); - return callTree == null ? null : ProfileInputLoader.BuildMemoryProfileResult(callTree); + return callTree == null ? null : MemoryProfileResultFactory.Build(callTree); } public ExceptionProfileResult? RunExceptionProfile(string[] command, string label) diff --git a/src/ProfileTool/ProfileInputKind.cs b/src/ProfileTool/ProfileInputKind.cs new file mode 100644 index 0000000..6c7ff98 --- /dev/null +++ b/src/ProfileTool/ProfileInputKind.cs @@ -0,0 +1,11 @@ +namespace Asynkron.Profiler; + +internal enum ProfileInputKind +{ + Unknown, + Speedscope, + NetTrace, + Etlx, + Gcdump, + HeapReport +} diff --git a/src/ProfileTool/ProfileInputLoader.cs b/src/ProfileTool/ProfileInputLoader.cs index 52d0a9b..480671e 100644 --- a/src/ProfileTool/ProfileInputLoader.cs +++ b/src/ProfileTool/ProfileInputLoader.cs @@ -1,10 +1,7 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; -using System.Linq; using Spectre.Console; -using static Asynkron.Profiler.CallTreeHelpers; namespace Asynkron.Profiler; @@ -43,19 +40,12 @@ public ProfileInputLoader( return null; } - var extension = GetNormalizedExtension(inputPath); - if (extension == ".json") + return ProfileInputPath.GetKind(inputPath) switch { - return AnalyzeSpeedscope(inputPath); - } - - if (!IsSupportedExtension(extension, ".nettrace", ".etlx")) - { - WriteUnsupportedInput("Unsupported CPU input", inputPath); - return null; - } - - return AnalyzeCpuTrace(inputPath); + ProfileInputKind.Speedscope => AnalyzeSpeedscope(inputPath), + ProfileInputKind.NetTrace or ProfileInputKind.Etlx => AnalyzeCpuTrace(inputPath), + _ => WriteUnsupportedInputAndReturn("Unsupported CPU input", inputPath) + }; } public MemoryProfileResult? LoadMemory(string inputPath) @@ -66,7 +56,7 @@ public ProfileInputLoader( } var callTree = AnalyzeAllocationTrace(inputPath); - return callTree == null ? null : BuildMemoryProfileResult(callTree); + return callTree == null ? null : MemoryProfileResultFactory.Build(callTree); } public ExceptionProfileResult? LoadException(string inputPath) @@ -96,170 +86,49 @@ public ProfileInputLoader( return null; } - var extension = GetNormalizedExtension(inputPath); - if (extension == ".gcdump") - { - if (!_ensureToolAvailable("dotnet-gcdump", _dotnetGcdumpInstallHint)) - { - return null; - } - - return GcdumpReportLoader.Load( - inputPath, - _getTheme(), - _runProcess, - _parseGcdumpReport, - _writeLine); - } - - if (extension is ".txt" or ".log") + return ProfileInputPath.GetKind(inputPath) switch { - return _parseGcdumpReport(File.ReadAllText(inputPath)); - } - - WriteUnsupportedInput("Unsupported heap input", inputPath); - return null; + ProfileInputKind.Gcdump => LoadGcdump(inputPath), + ProfileInputKind.HeapReport => _parseGcdumpReport(File.ReadAllText(inputPath)), + _ => WriteUnsupportedInputAndReturn("Unsupported heap input", inputPath) + }; } public CpuProfileResult? AnalyzeCpuTrace(string traceFile) { - try + var result = TryAnalyzeTrace(traceFile, "CPU trace parse failed", _traceAnalyzer.AnalyzeCpuTrace); + if (result == null) { - var result = _traceAnalyzer.AnalyzeCpuTrace(traceFile); - if (result.AllFunctions.Count == 0) - { - _writeLine($"[{_getTheme().AccentColor}]No CPU samples found in trace.[/]"); - return null; - } - - return result; - } - catch (Exception ex) - { - _writeLine($"[{_getTheme().AccentColor}]CPU trace parse failed:[/] {Markup.Escape(ex.Message)}"); return null; } - } - public AllocationCallTreeResult? AnalyzeAllocationTrace(string traceFile) - { - try - { - return _traceAnalyzer.AnalyzeAllocationTrace(traceFile); - } - catch (Exception ex) + if (result.AllFunctions.Count == 0) { - _writeLine($"[{_getTheme().AccentColor}]Allocation trace parse failed:[/] {Markup.Escape(ex.Message)}"); + _writeLine($"[{_getTheme().AccentColor}]No CPU samples found in trace.[/]"); return null; } - } - public ExceptionProfileResult? AnalyzeExceptionTrace(string traceFile) - { - try - { - return _traceAnalyzer.AnalyzeExceptionTrace(traceFile); - } - catch (Exception ex) - { - _writeLine($"[{_getTheme().AccentColor}]Exception trace parse failed:[/] {Markup.Escape(ex.Message)}"); - return null; - } + return result; } - public ContentionProfileResult? AnalyzeContentionTrace(string traceFile) - { - try - { - return _traceAnalyzer.AnalyzeContentionTrace(traceFile); - } - catch (Exception ex) - { - _writeLine($"[{_getTheme().AccentColor}]Contention trace parse failed:[/] {Markup.Escape(ex.Message)}"); - return null; - } - } - - public static MemoryProfileResult BuildMemoryProfileResult(AllocationCallTreeResult callTree) + public AllocationCallTreeResult? AnalyzeAllocationTrace(string traceFile) { - var allocationEntries = callTree.TypeRoots - .OrderByDescending(root => root.TotalBytes) - .Take(50) - .Select(root => new AllocationEntry(root.Name, root.Count, FormatBytes(root.TotalBytes))) - .ToList(); - - var totalAllocated = FormatBytes(callTree.TotalBytes); - - return new MemoryProfileResult( - null, - null, - null, - totalAllocated, - null, - null, - null, - null, - null, - null, - null, - null, - totalAllocated, - allocationEntries, - callTree, - null, - null); + return TryAnalyzeTrace(traceFile, "Allocation trace parse failed", _traceAnalyzer.AnalyzeAllocationTrace); } - public static string BuildInputLabel(string inputPath) + public ExceptionProfileResult? AnalyzeExceptionTrace(string traceFile) { - return FileLabelSanitizer.Sanitize(Path.GetFileNameWithoutExtension(inputPath), "input"); + return TryAnalyzeTrace(traceFile, "Exception trace parse failed", _traceAnalyzer.AnalyzeExceptionTrace); } - public static void ApplyInputDefaults( - string inputPath, - ref bool runCpu, - ref bool runMemory, - ref bool runHeap, - ref bool runException, - ref bool runContention) + public ContentionProfileResult? AnalyzeContentionTrace(string traceFile) { - switch (GetNormalizedExtension(inputPath)) - { - case ".json": - runCpu = true; - break; - case ".nettrace": - runCpu = true; - runException = true; - runContention = true; - break; - case ".etlx": - runMemory = true; - runException = true; - runContention = true; - break; - case ".gcdump": - case ".txt": - case ".log": - runHeap = true; - break; - default: - runCpu = true; - break; - } + return TryAnalyzeTrace(traceFile, "Contention trace parse failed", _traceAnalyzer.AnalyzeContentionTrace); } private CpuProfileResult? AnalyzeSpeedscope(string speedscopePath) { - try - { - return ProfilerTraceAnalyzer.AnalyzeSpeedscope(speedscopePath); - } - catch (Exception ex) - { - _writeLine($"[{_getTheme().AccentColor}]Speedscope parse failed:[/] {Markup.Escape(ex.Message)}"); - return null; - } + return TryAnalyzeTrace(speedscopePath, "Speedscope parse failed", ProfilerTraceAnalyzer.AnalyzeSpeedscope); } private bool TryValidateTraceInput(string inputPath, string unsupportedMessage) @@ -269,7 +138,7 @@ private bool TryValidateTraceInput(string inputPath, string unsupportedMessage) return false; } - if (IsSupportedExtension(GetNormalizedExtension(inputPath), ".nettrace", ".etlx")) + if (ProfileInputPath.IsTraceInput(inputPath)) { return true; } @@ -278,6 +147,21 @@ private bool TryValidateTraceInput(string inputPath, string unsupportedMessage) return false; } + private HeapProfileResult? LoadGcdump(string inputPath) + { + if (!_ensureToolAvailable("dotnet-gcdump", _dotnetGcdumpInstallHint)) + { + return null; + } + + return GcdumpReportLoader.Load( + inputPath, + _getTheme(), + _runProcess, + _parseGcdumpReport, + _writeLine); + } + private bool TryEnsureInputExists(string inputPath) { if (File.Exists(inputPath)) @@ -294,13 +178,24 @@ private void WriteUnsupportedInput(string message, string inputPath) _writeLine($"[{_getTheme().ErrorColor}]{message}:[/] {Markup.Escape(inputPath)}"); } - private static string GetNormalizedExtension(string inputPath) + private T? TryAnalyzeTrace(string path, string failureMessage, Func analyze) + where T : class { - return Path.GetExtension(inputPath).ToLowerInvariant(); + try + { + return analyze(path); + } + catch (Exception ex) + { + _writeLine($"[{_getTheme().AccentColor}]{failureMessage}[/] {Markup.Escape(ex.Message)}"); + return null; + } } - private static bool IsSupportedExtension(string extension, params string[] allowedExtensions) + private T? WriteUnsupportedInputAndReturn(string message, string inputPath) + where T : class { - return allowedExtensions.Contains(extension, StringComparer.Ordinal); + WriteUnsupportedInput(message, inputPath); + return null; } } diff --git a/src/ProfileTool/ProfileInputPath.cs b/src/ProfileTool/ProfileInputPath.cs new file mode 100644 index 0000000..7ed829f --- /dev/null +++ b/src/ProfileTool/ProfileInputPath.cs @@ -0,0 +1,72 @@ +using System.IO; + +namespace Asynkron.Profiler; + +internal static class ProfileInputPath +{ + public static string BuildLabel(string inputPath) + { + return FileLabelSanitizer.Sanitize(Path.GetFileNameWithoutExtension(inputPath), "input"); + } + + public static void ApplyDefaults( + string inputPath, + ref bool runCpu, + ref bool runMemory, + ref bool runHeap, + ref bool runException, + ref bool runContention) + { + switch (GetKind(inputPath)) + { + case ProfileInputKind.Speedscope: + runCpu = true; + break; + case ProfileInputKind.NetTrace: + runCpu = true; + runException = true; + runContention = true; + break; + case ProfileInputKind.Etlx: + runMemory = true; + runException = true; + runContention = true; + break; + case ProfileInputKind.Gcdump: + case ProfileInputKind.HeapReport: + runHeap = true; + break; + default: + runCpu = true; + break; + } + } + + public static bool IsTraceInput(string inputPath) + { + return IsTraceInput(GetKind(inputPath)); + } + + public static bool IsTraceInput(ProfileInputKind inputKind) + { + return inputKind is ProfileInputKind.NetTrace or ProfileInputKind.Etlx; + } + + public static ProfileInputKind GetKind(string inputPath) + { + return GetNormalizedExtension(inputPath) switch + { + ".json" => ProfileInputKind.Speedscope, + ".nettrace" => ProfileInputKind.NetTrace, + ".etlx" => ProfileInputKind.Etlx, + ".gcdump" => ProfileInputKind.Gcdump, + ".txt" or ".log" => ProfileInputKind.HeapReport, + _ => ProfileInputKind.Unknown + }; + } + + private static string GetNormalizedExtension(string inputPath) + { + return Path.GetExtension(inputPath).ToLowerInvariant(); + } +} diff --git a/src/ProfileTool/ProfilerExecutionRequestFactory.cs b/src/ProfileTool/ProfilerExecutionRequestFactory.cs index fdb51aa..64481e3 100644 --- a/src/ProfileTool/ProfilerExecutionRequestFactory.cs +++ b/src/ProfileTool/ProfilerExecutionRequestFactory.cs @@ -37,13 +37,13 @@ public ProfilerExecutionRequestFactory( if (hasInput) { - label = ProfileInputLoader.BuildInputLabel(invocation.InputPath!); + label = ProfileInputPath.BuildLabel(invocation.InputPath!); description = invocation.InputPath!; command = Array.Empty(); if (!hasExplicitModes) { - ProfileInputLoader.ApplyInputDefaults( + ProfileInputPath.ApplyDefaults( invocation.InputPath!, ref runCpu, ref runMemory, diff --git a/tests/Asynkron.Profiler.Tests/MemoryProfileResultFactoryTests.cs b/tests/Asynkron.Profiler.Tests/MemoryProfileResultFactoryTests.cs new file mode 100644 index 0000000..e03626a --- /dev/null +++ b/tests/Asynkron.Profiler.Tests/MemoryProfileResultFactoryTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Xunit; + +namespace Asynkron.Profiler.Tests; + +public sealed class MemoryProfileResultFactoryTests +{ + [Fact] + public void Build_SortsEntriesAndCapsAtFifty() + { + var roots = Enumerable.Range(0, 55) + .Select(index => new AllocationCallTreeNode($"Type{index}") + { + Count = index + 1, + TotalBytes = index + 1 + }) + .ToArray(); + var callTree = new AllocationCallTreeResult(roots.Sum(root => root.TotalBytes), roots.Sum(root => root.Count), roots); + + var result = MemoryProfileResultFactory.Build(callTree); + + Assert.Equal("1.50 KB", result.TotalAllocated); + Assert.Equal("1.50 KB", result.AllocationTotal); + Assert.Same(callTree, result.AllocationCallTree); + Assert.Equal(50, result.AllocationEntries.Count); + Assert.Equal("Type54", result.AllocationEntries[0].Type); + Assert.Equal("55 B", result.AllocationEntries[0].Total); + Assert.Equal("Type5", result.AllocationEntries[^1].Type); + } +} diff --git a/tests/Asynkron.Profiler.Tests/ProfileInputLoaderTests.cs b/tests/Asynkron.Profiler.Tests/ProfileInputLoaderTests.cs index a3b3b2d..23db744 100644 --- a/tests/Asynkron.Profiler.Tests/ProfileInputLoaderTests.cs +++ b/tests/Asynkron.Profiler.Tests/ProfileInputLoaderTests.cs @@ -8,40 +8,6 @@ namespace Asynkron.Profiler.Tests; public sealed class ProfileInputLoaderTests { - [Fact] - public void ApplyInputDefaults_MapsExtensionsToExpectedModes() - { - AssertModes("trace.json", expectedCpu: true, expectedMemory: false, expectedHeap: false, expectedException: false, expectedContention: false); - AssertModes("trace.nettrace", expectedCpu: true, expectedMemory: false, expectedHeap: false, expectedException: true, expectedContention: true); - AssertModes("trace.etlx", expectedCpu: false, expectedMemory: true, expectedHeap: false, expectedException: true, expectedContention: true); - AssertModes("trace.gcdump", expectedCpu: false, expectedMemory: false, expectedHeap: true, expectedException: false, expectedContention: false); - AssertModes("trace.unknown", expectedCpu: true, expectedMemory: false, expectedHeap: false, expectedException: false, expectedContention: false); - } - - [Fact] - public void BuildMemoryProfileResult_SortsEntriesAndCapsAtFifty() - { - var loader = CreateLoader((_, _) => true); - var roots = Enumerable.Range(0, 55) - .Select(index => new AllocationCallTreeNode($"Type{index}") - { - Count = index + 1, - TotalBytes = index + 1 - }) - .ToArray(); - var callTree = new AllocationCallTreeResult(roots.Sum(root => root.TotalBytes), roots.Sum(root => root.Count), roots); - - var result = ProfileInputLoader.BuildMemoryProfileResult(callTree); - - Assert.Equal("1.50 KB", result.TotalAllocated); - Assert.Equal("1.50 KB", result.AllocationTotal); - Assert.Same(callTree, result.AllocationCallTree); - Assert.Equal(50, result.AllocationEntries.Count); - Assert.Equal("Type54", result.AllocationEntries[0].Type); - Assert.Equal("55 B", result.AllocationEntries[0].Total); - Assert.Equal("Type5", result.AllocationEntries[^1].Type); - } - [Fact] public void LoadHeap_UsesGcdumpLoaderWhenToolIsAvailable() { @@ -100,43 +66,6 @@ public void LoadHeap_RejectsUnsupportedInputs() } } - [Fact] - public void BuildInputLabel_FallsBackToInputWhenFileNameIsMissing() - { - var label = ProfileInputLoader.BuildInputLabel(string.Empty); - - Assert.Equal("input", label); - } - - private static void AssertModes( - string inputPath, - bool expectedCpu, - bool expectedMemory, - bool expectedHeap, - bool expectedException, - bool expectedContention) - { - var runCpu = false; - var runMemory = false; - var runHeap = false; - var runException = false; - var runContention = false; - - ProfileInputLoader.ApplyInputDefaults( - inputPath, - ref runCpu, - ref runMemory, - ref runHeap, - ref runException, - ref runContention); - - Assert.Equal(expectedCpu, runCpu); - Assert.Equal(expectedMemory, runMemory); - Assert.Equal(expectedHeap, runHeap); - Assert.Equal(expectedException, runException); - Assert.Equal(expectedContention, runContention); - } - private static ProfileInputLoader CreateLoader( Func ensureToolAvailable, Func, string?, int, (bool Success, string StdOut, string StdErr)>? runProcess = null, diff --git a/tests/Asynkron.Profiler.Tests/ProfileInputPathTests.cs b/tests/Asynkron.Profiler.Tests/ProfileInputPathTests.cs new file mode 100644 index 0000000..b838349 --- /dev/null +++ b/tests/Asynkron.Profiler.Tests/ProfileInputPathTests.cs @@ -0,0 +1,66 @@ +using Xunit; + +namespace Asynkron.Profiler.Tests; + +public sealed class ProfileInputPathTests +{ + [Fact] + public void ApplyDefaults_MapsExtensionsToExpectedModes() + { + AssertModes("trace.json", expectedCpu: true, expectedMemory: false, expectedHeap: false, expectedException: false, expectedContention: false); + AssertModes("trace.nettrace", expectedCpu: true, expectedMemory: false, expectedHeap: false, expectedException: true, expectedContention: true); + AssertModes("trace.etlx", expectedCpu: false, expectedMemory: true, expectedHeap: false, expectedException: true, expectedContention: true); + AssertModes("trace.gcdump", expectedCpu: false, expectedMemory: false, expectedHeap: true, expectedException: false, expectedContention: false); + AssertModes("trace.unknown", expectedCpu: true, expectedMemory: false, expectedHeap: false, expectedException: false, expectedContention: false); + } + + [Fact] + public void BuildLabel_FallsBackToInputWhenFileNameIsMissing() + { + var label = ProfileInputPath.BuildLabel(string.Empty); + + Assert.Equal("input", label); + } + + [Theory] + [InlineData("trace.json", "Speedscope")] + [InlineData("trace.nettrace", "NetTrace")] + [InlineData("trace.etlx", "Etlx")] + [InlineData("trace.gcdump", "Gcdump")] + [InlineData("trace.txt", "HeapReport")] + [InlineData("trace.log", "HeapReport")] + [InlineData("trace.bin", "Unknown")] + public void GetKind_ReturnsExpectedInputKind(string inputPath, string expectedKind) + { + Assert.Equal(expectedKind, ProfileInputPath.GetKind(inputPath).ToString()); + } + + private static void AssertModes( + string inputPath, + bool expectedCpu, + bool expectedMemory, + bool expectedHeap, + bool expectedException, + bool expectedContention) + { + var runCpu = false; + var runMemory = false; + var runHeap = false; + var runException = false; + var runContention = false; + + ProfileInputPath.ApplyDefaults( + inputPath, + ref runCpu, + ref runMemory, + ref runHeap, + ref runException, + ref runContention); + + Assert.Equal(expectedCpu, runCpu); + Assert.Equal(expectedMemory, runMemory); + Assert.Equal(expectedHeap, runHeap); + Assert.Equal(expectedException, runException); + Assert.Equal(expectedContention, runContention); + } +}