diff --git a/src/Microsoft.Diagnostics.ExtensionCommands/DumpHeapCommand.cs b/src/Microsoft.Diagnostics.ExtensionCommands/DumpHeapCommand.cs index 9d92574953..b9f11f7503 100644 --- a/src/Microsoft.Diagnostics.ExtensionCommands/DumpHeapCommand.cs +++ b/src/Microsoft.Diagnostics.ExtensionCommands/DumpHeapCommand.cs @@ -77,6 +77,14 @@ public override void Invoke() { ParseArguments(); + if (StatOnly) + { + FilteredHeap.ProgressCallback = (scanned, total) => + { + Console.WriteLine(ProgressReporter.FormatProgressMessage(scanned, total)); + }; + } + IEnumerable objectsToPrint = FilteredHeap.EnumerateFilteredObjects(Console.CancellationToken); bool? liveObjectWarning = null; diff --git a/src/Microsoft.Diagnostics.ExtensionCommands/HeapWithFilters.cs b/src/Microsoft.Diagnostics.ExtensionCommands/HeapWithFilters.cs index 36c229157a..34c1ec22c2 100644 --- a/src/Microsoft.Diagnostics.ExtensionCommands/HeapWithFilters.cs +++ b/src/Microsoft.Diagnostics.ExtensionCommands/HeapWithFilters.cs @@ -78,6 +78,17 @@ public int? GCHeap /// public Func, IOrderedEnumerable> SortSubHeaps { get; set; } + /// + /// Minimum interval in milliseconds between progress reports. + /// + private const int ProgressIntervalMs = 10_000; + + /// + /// Optional callback invoked periodically during heap enumeration to report progress. + /// Parameters are (bytesScanned, totalBytes). + /// + public Action ProgressCallback { get; set; } + public HeapWithFilters(ClrHeap heap) { _heap = heap; @@ -211,7 +222,21 @@ public IEnumerable EnumerateFilteredSegments(ClrSubHeap subheap) public IEnumerable EnumerateFilteredObjects(CancellationToken cancellation) { - foreach (ClrSegment segment in EnumerateFilteredSegments()) + Action progressCallback = ProgressCallback; + ProgressReporter progress = null; + IEnumerable segments = EnumerateFilteredSegments(); + + if (progressCallback != null) + { + // Materialize the segment list to avoid enumerating twice + // (once for total size, once for object enumeration). + List segmentList = segments.ToList(); + long totalBytes = segmentList.Sum(s => (long)s.CommittedMemory.Length); + progress = new ProgressReporter(progressCallback, totalBytes, ProgressIntervalMs); + segments = segmentList; + } + + foreach (ClrSegment segment in segments) { IEnumerable objs; if (MemoryRange is MemoryRange range) @@ -235,6 +260,9 @@ public IEnumerable EnumerateFilteredObjects(CancellationToken cancell if (obj.IsValid) { ulong size = obj.Size; + + progress?.ReportObject((long)size); + if (MinimumObjectSize != 0 && size < MinimumObjectSize) { continue; diff --git a/src/Microsoft.Diagnostics.ExtensionCommands/Microsoft.Diagnostics.ExtensionCommands.csproj b/src/Microsoft.Diagnostics.ExtensionCommands/Microsoft.Diagnostics.ExtensionCommands.csproj index b2b3caece6..3ef1b60737 100644 --- a/src/Microsoft.Diagnostics.ExtensionCommands/Microsoft.Diagnostics.ExtensionCommands.csproj +++ b/src/Microsoft.Diagnostics.ExtensionCommands/Microsoft.Diagnostics.ExtensionCommands.csproj @@ -18,6 +18,10 @@ + + + + diff --git a/src/Microsoft.Diagnostics.ExtensionCommands/ProgressReporter.cs b/src/Microsoft.Diagnostics.ExtensionCommands/ProgressReporter.cs new file mode 100644 index 0000000000..b91f939b6f --- /dev/null +++ b/src/Microsoft.Diagnostics.ExtensionCommands/ProgressReporter.cs @@ -0,0 +1,65 @@ +// 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.Diagnostics; + +namespace Microsoft.Diagnostics.ExtensionCommands +{ + /// + /// Reports progress periodically during heap enumeration based on elapsed time. + /// + internal sealed class ProgressReporter + { + private readonly Action _callback; + private readonly long _totalBytes; + private readonly int _intervalMs; + private readonly Stopwatch _stopwatch; + private long _scannedBytes; + private long _lastReportMs; + + /// + /// Creates a new ProgressReporter. + /// + /// Invoked periodically with (bytesScanned, totalBytes). + /// Total expected bytes to scan. + /// Minimum interval in milliseconds between reports. + public ProgressReporter(Action callback, long totalBytes, int intervalMs) + { + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + _totalBytes = totalBytes; + _intervalMs = intervalMs; + _stopwatch = Stopwatch.StartNew(); + } + + /// + /// Gets the total number of bytes scanned so far. + /// + public long ScannedBytes => _scannedBytes; + + /// + /// Reports that an object of the given size has been scanned. + /// Invokes the callback if enough time has elapsed since the last report. + /// + public void ReportObject(long objectSize) + { + _scannedBytes += objectSize; + + long elapsedMs = _stopwatch.ElapsedMilliseconds; + if (elapsedMs - _lastReportMs >= _intervalMs) + { + _lastReportMs = elapsedMs; + _callback(_scannedBytes, _totalBytes); + } + } + + /// + /// Formats a progress message suitable for display during heap scanning. + /// + public static string FormatProgressMessage(long scannedBytes, long totalBytes) + { + double pct = totalBytes > 0 ? 100.0 * scannedBytes / totalBytes : 0; + return $"Scanning heap: {scannedBytes / (1024 * 1024):n0} MB / {totalBytes / (1024 * 1024):n0} MB ({pct:f0}%)..."; + } + } +} diff --git a/src/Microsoft.Diagnostics.ExtensionCommands/VerifyHeapCommand.cs b/src/Microsoft.Diagnostics.ExtensionCommands/VerifyHeapCommand.cs index 4ce410e576..0871a351b8 100644 --- a/src/Microsoft.Diagnostics.ExtensionCommands/VerifyHeapCommand.cs +++ b/src/Microsoft.Diagnostics.ExtensionCommands/VerifyHeapCommand.cs @@ -58,6 +58,11 @@ public override void Invoke() throw new DiagnosticsException("The GC heap is not in a valid state for traversal. (Use -ignoreGCState to override.)"); } + filteredHeap.ProgressCallback = (scanned, total) => + { + Console.WriteLine(ProgressReporter.FormatProgressMessage(scanned, total)); + }; + VerifyHeap(filteredHeap.EnumerateFilteredObjects(Console.CancellationToken), verifySyncTable: filteredHeap.HasFilters); } diff --git a/src/tests/Microsoft.Diagnostics.ExtensionCommands.UnitTests/Microsoft.Diagnostics.ExtensionCommands.UnitTests.csproj b/src/tests/Microsoft.Diagnostics.ExtensionCommands.UnitTests/Microsoft.Diagnostics.ExtensionCommands.UnitTests.csproj new file mode 100644 index 0000000000..9d28481712 --- /dev/null +++ b/src/tests/Microsoft.Diagnostics.ExtensionCommands.UnitTests/Microsoft.Diagnostics.ExtensionCommands.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + $(SupportedXUnitTestTargetFrameworks) + false + + + + + + + + + + + diff --git a/src/tests/Microsoft.Diagnostics.ExtensionCommands.UnitTests/ProgressReporterTests.cs b/src/tests/Microsoft.Diagnostics.ExtensionCommands.UnitTests/ProgressReporterTests.cs new file mode 100644 index 0000000000..93925845bd --- /dev/null +++ b/src/tests/Microsoft.Diagnostics.ExtensionCommands.UnitTests/ProgressReporterTests.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Diagnostics.ExtensionCommands.UnitTests +{ + public class ProgressReporterTests + { + [Fact] + public void ReportObject_WithZeroInterval_CallsCallbackEveryTime() + { + List<(long scanned, long total)> reports = new(); + + ProgressReporter reporter = new( + (scanned, total) => reports.Add((scanned, total)), + totalBytes: 1000, + intervalMs: 0); + + reporter.ReportObject(100); + reporter.ReportObject(200); + reporter.ReportObject(300); + + Assert.Equal(3, reports.Count); + Assert.Equal((100, 1000), reports[0]); + Assert.Equal((300, 1000), reports[1]); + Assert.Equal((600, 1000), reports[2]); + } + + [Fact] + public void ReportObject_TracksScannedBytes() + { + ProgressReporter reporter = new( + (_, _) => { }, + totalBytes: 1000, + intervalMs: 60_000); // long interval so callback doesn't fire after first + + reporter.ReportObject(100); + Assert.Equal(100, reporter.ScannedBytes); + + reporter.ReportObject(250); + Assert.Equal(350, reporter.ScannedBytes); + + reporter.ReportObject(50); + Assert.Equal(400, reporter.ScannedBytes); + } + + [Fact] + public void ReportObject_WithLongInterval_DoesNotFireDuringInterval() + { + int callbackCount = 0; + + ProgressReporter reporter = new( + (_, _) => callbackCount++, + totalBytes: 1000, + intervalMs: 60_000); // 60 seconds - won't fire in this test + + // No calls should fire within the 60s interval + for (int i = 0; i < 100; i++) + { + reporter.ReportObject(1); + } + + Assert.Equal(0, callbackCount); + Assert.Equal(100, reporter.ScannedBytes); + } + + [Fact] + public void FormatProgressMessage_FormatsCorrectly() + { + string msg = ProgressReporter.FormatProgressMessage( + scannedBytes: 5L * 1024 * 1024 * 1024, // 5 GB + totalBytes: 16L * 1024 * 1024 * 1024); // 16 GB + + Assert.Contains("5", msg); + Assert.Contains("16", msg); + Assert.Contains("31%", msg); + Assert.Contains("Scanning heap:", msg); + } + + [Fact] + public void FormatProgressMessage_HandlesZeroTotal() + { + string msg = ProgressReporter.FormatProgressMessage(0, 0); + Assert.Contains("0%", msg); + } + + [Fact] + public void FormatProgressMessage_Handles100Percent() + { + string msg = ProgressReporter.FormatProgressMessage(1024 * 1024, 1024 * 1024); + Assert.Contains("100%", msg); + } + } +}