From 38607791361766fa440e86b1d46a839d9969cbca Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:47:08 +0200 Subject: [PATCH 1/2] Add tests (skipped by default) to verify dumps compatibility --- .../Steeltoe.Management.Endpoint.csproj | 1 + .../Actuators/DumpPackagingTest.cs | 540 ++++++++++++++++++ .../ThreadDump/EventPipeThreadDumperTest.cs | 2 + .../Steeltoe.Management.Endpoint.Test.csproj | 5 + ...Management.GitProperties.Build.Test.csproj | 2 +- .../TestAppTargetFramework.cs | 2 +- 6 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 src/Management/test/Endpoint.Test/Actuators/DumpPackagingTest.cs diff --git a/src/Management/src/Endpoint/Steeltoe.Management.Endpoint.csproj b/src/Management/src/Endpoint/Steeltoe.Management.Endpoint.csproj index eba86e4344..8432fa8518 100755 --- a/src/Management/src/Endpoint/Steeltoe.Management.Endpoint.csproj +++ b/src/Management/src/Endpoint/Steeltoe.Management.Endpoint.csproj @@ -37,6 +37,7 @@ $(Pkgdotnet-gcdump)\tools\net8.0\any\dotnet-gcdump.dll diff --git a/src/Management/test/Endpoint.Test/Actuators/DumpPackagingTest.cs b/src/Management/test/Endpoint.Test/Actuators/DumpPackagingTest.cs new file mode 100644 index 0000000000..45125f0721 --- /dev/null +++ b/src/Management/test/Endpoint.Test/Actuators/DumpPackagingTest.cs @@ -0,0 +1,540 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Xml.Linq; + +namespace Steeltoe.Management.Endpoint.Test.Actuators; + +/// +/// Used to verify whether the version of Microsoft.Diagnostics.FastSerialization embedded in dotnet-gcdump is binary compatible with the version +/// referenced by Microsoft.Diagnostics.Tracing.TraceEvent. Unskip and run this manually after version bumps (takes several minutes). +/// +[Collection("TestsForMemoryDumpsMustRunSequentially")] +[Trait("Category", "MemoryDumps")] +public sealed class DumpPackagingTest(DumpPackagingTest.PackSteeltoeLibrariesOnceFixture fixture) + : IClassFixture +{ + private const string EndpointProjectName = "Steeltoe.Management.Endpoint"; + + // Tip: Set SkipReason to null to unskip all tests. + private const string? SkipReason = + "Slow: packs NuGet packages and builds/runs a console app per TFM. Unskip and run locally after changing a dump-related package version."; + + // To reproduce test failures, here's a combination that compiles fine, while crashing at runtime due to binary breaking changes: + // - dotnet-gcdump v9.0.621003 (embeds Microsoft.Diagnostics.FastSerialization v3.1.16.0) + // - Microsoft.Diagnostics.Tracing.TraceEvent v3.2.6 (embeds Microsoft.Diagnostics.FastSerialization v3.2.6.0) + + [Theory(Skip = SkipReason)] + [MemberData(nameof(TestTargetFrameworks))] + public async Task Can_take_gcdump_from_packaged_Steeltoe_library(string targetFramework) + { + Assert.SkipWhen(!IsHighestTestHostFramework(), "Running this test only on the latest .NET SDK is sufficient."); + + var app = await DumpVerificationApp.CreateForGCDumpAsync(fixture, targetFramework); + + // ReSharper disable once AccessToDisposedClosure + Func action = app.RunAsync; + + await action.Should().NotThrowAsync(); + } + + [Theory(Skip = SkipReason)] + [MemberData(nameof(TestTargetFrameworks))] + public async Task Can_take_minidump_from_packaged_Steeltoe_library(string targetFramework) + { + Assert.SkipWhen(!IsHighestTestHostFramework(), "Running this test only on the latest .NET SDK is sufficient."); + + var app = await DumpVerificationApp.CreateForMinidumpAsync(fixture, targetFramework); + + // ReSharper disable once AccessToDisposedClosure + Func action = app.RunAsync; + + await action.Should().NotThrowAsync(); + } + + [Theory(Skip = SkipReason)] + [MemberData(nameof(TestTargetFrameworks))] + public async Task Can_take_thread_dump_from_packaged_Steeltoe_library(string targetFramework) + { + Assert.SkipWhen(!IsHighestTestHostFramework(), "Running this test only on the latest .NET SDK is sufficient."); + + var app = await DumpVerificationApp.CreateForThreadDumpAsync(fixture, targetFramework); + + // ReSharper disable once AccessToDisposedClosure + Func action = app.RunAsync; + + await action.Should().NotThrowAsync(); + } + + public static TheoryData TestTargetFrameworks() + { + var theoryData = new TheoryData(); + + foreach (string targetFramework in ResolveTestTargetFrameworks()) + { + theoryData.Add(targetFramework); + } + + return theoryData; + } + + private static string[] ResolveTestTargetFrameworks() + { + AssemblyMetadataAttribute? attribute = Assembly.GetExecutingAssembly().GetCustomAttributes() + .FirstOrDefault(candidate => candidate.Key == "TestTargetFrameworks"); + + if (attribute?.Value == null) + { + throw new InvalidOperationException("Could not resolve TestTargetFrameworks from AssemblyMetadata in test project file."); + } + + string[] targetFrameworks = attribute.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (targetFrameworks.Length == 0) + { + throw new InvalidOperationException("TestTargetFrameworks from AssemblyMetadata in test project file is empty."); + } + + return targetFrameworks; + } + + private static bool IsHighestTestHostFramework() + { + // The InlineData entries already cover every supported TFM, so running tests on all target frameworks would just repeat identical work. + + Version hostVersion = typeof(object).Assembly.GetName().Version!; + int highestMajorVersion = ResolveTestTargetFrameworks().Max(targetFramework => Version.Parse(targetFramework["net".Length..]).Major); + + return hostVersion.Major == highestMajorVersion; + } + + /// + /// Packs project Steeltoe.Management.Endpoint (along with the Steeltoe projects it references) into an isolated local NuGet feed (once per test run) to + /// speed up running tests. + /// + public sealed class PackSteeltoeLibrariesOnceFixture : IAsyncLifetime + { + private string? _sessionDirectory; + + internal string SessionDirectory => _sessionDirectory!; + internal NuGetSource Source { get; private set; } = null!; + internal string PackageVersion { get; } = $"9.9.9-test.{$"{Guid.NewGuid():N}"[..8]}"; + + public async ValueTask InitializeAsync() + { + if (!IsHighestTestHostFramework()) + { + return; + } + + string directoryName = $"steeltoe-dumps-test-session-{$"{Guid.NewGuid():N}"[..8]}"; + string tempPath = Path.GetTempPath(); + string sessionDirectory = new DirectoryInfo(tempPath).CreateSubdirectory(directoryName).FullName; + _sessionDirectory = sessionDirectory; + + var sessionDirectoryInfo = new DirectoryInfo(sessionDirectory); + Source = CreateNuGetSource(sessionDirectoryInfo); + + string endpointProjectPath = GetEndpointProjectPath(); + + foreach (string projectPath in ResolveSteeltoeProjectFilePaths(endpointProjectPath)) + { + await PackAsync(projectPath, Source.FeedDirectory, PackageVersion); + } + } + + private static NuGetSource CreateNuGetSource(DirectoryInfo sessionDirectoryInfo) + { + string feedDirectory = sessionDirectoryInfo.CreateSubdirectory("feed").FullName; + string packagesDirectory = sessionDirectoryInfo.CreateSubdirectory("packages").FullName; + return new NuGetSource(feedDirectory, packagesDirectory); + } + + private static string GetEndpointProjectPath() + { + string testDirectory = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "..", "..", ".."); + string projectFilePath = Path.Combine(testDirectory, "..", "..", "src", "Endpoint", $"{EndpointProjectName}.csproj"); + return Path.GetFullPath(projectFilePath); + } + + private static HashSet ResolveSteeltoeProjectFilePaths(string startProjectPath) + { + var discovered = new HashSet(StringComparer.OrdinalIgnoreCase); + + var pending = new Queue(); + pending.Enqueue(Path.GetFullPath(startProjectPath)); + + while (pending.TryDequeue(out string? nextProjectPath)) + { + if (discovered.Add(nextProjectPath)) + { + XDocument document = XDocument.Load(nextProjectPath); + string directory = Path.GetDirectoryName(nextProjectPath)!; + + foreach (string relativePath in document.Descendants("ProjectReference").Attributes("Include").Select(attribute => attribute.Value)) + { + string absolutePath = Path.GetFullPath(Path.Combine(directory, NormalizePath(relativePath))); + pending.Enqueue(absolutePath); + } + } + } + + return discovered; + } + + private static string NormalizePath(string path) + { + return path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + } + + private static async Task PackAsync(string projectPath, string feedDirectory, string packageVersion) + { + string projectDirectory = Path.GetDirectoryName(projectPath)!; + + await DotNetProcessRunner.RunAsync(projectDirectory, "build", projectPath, "-c", "Release", $"-p:Version={packageVersion}", + $"-p:PackageOutputPath={feedDirectory}"); + } + + public ValueTask DisposeAsync() + { + if (_sessionDirectory != null) + { + try + { + Directory.Delete(_sessionDirectory, true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup only: a transiently locked file (e.g. an antivirus scan) must not fail the test run. + } + } + + return ValueTask.CompletedTask; + } + } + + internal sealed class DumpVerificationApp + { + private const string LibrarySource = """ + using Microsoft.Extensions.DependencyInjection; + using Steeltoe.Management.Endpoint.Actuators.HeapDump; + using Steeltoe.Management.Endpoint.Actuators.ThreadDump; + + namespace DumpTestLibrary; + + public static class DumpProvider + { + public static void RegisterHeapDump(IServiceCollection services) + { + services.AddHeapDumpActuator(false); + } + + public static void RegisterThreadDump(IServiceCollection services) + { + services.AddThreadDumpActuator(false); + } + + public static async Task TakeHeapDumpAsync(IServiceProvider services) + { + var handler = services.GetRequiredService(); + string path = await handler.InvokeAsync(null, CancellationToken.None); + File.Delete(path); + } + + public static async Task TakeThreadDumpAsync(IServiceProvider services) + { + var handler = services.GetRequiredService(); + await handler.InvokeAsync(null, CancellationToken.None); + } + } + """; + + private const string GCDumpAppSource = """ + using DumpTestLibrary; + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Configuration["Management:Endpoints:Heapdump:HeapDumpType"] = "GCDump"; + DumpProvider.RegisterHeapDump(builder.Services); + + await using WebApplication app = builder.Build(); + + try + { + await DumpProvider.TakeHeapDumpAsync(app.Services); + return 0; + } + catch (Exception exception) + { + Console.WriteLine($"FAILED: {exception}"); + return 1; + } + """; + + private const string MinidumpAppSource = """ + using DumpTestLibrary; + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Configuration["Management:Endpoints:Heapdump:HeapDumpType"] = "Mini"; + DumpProvider.RegisterHeapDump(builder.Services); + + await using WebApplication app = builder.Build(); + + try + { + await DumpProvider.TakeHeapDumpAsync(app.Services); + return 0; + } + catch (Exception exception) + { + Console.WriteLine($"FAILED: {exception}"); + return 1; + } + """; + + private const string ThreadDumpAppSource = """ + using DumpTestLibrary; + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + DumpProvider.RegisterThreadDump(builder.Services); + + await using WebApplication app = builder.Build(); + + try + { + await DumpProvider.TakeThreadDumpAsync(app.Services); + return 0; + } + catch (Exception exception) + { + Console.WriteLine($"FAILED: {exception}"); + return 1; + } + """; + + private readonly string _appDirectory; + + private DumpVerificationApp(string appDirectory) + { + _appDirectory = appDirectory; + } + + internal static async Task CreateForGCDumpAsync(PackSteeltoeLibrariesOnceFixture fixture, string targetFramework) + { + return await CreateAsync(fixture, targetFramework, "GCDumpTestApp", GCDumpAppSource); + } + + internal static async Task CreateForMinidumpAsync(PackSteeltoeLibrariesOnceFixture fixture, string targetFramework) + { + return await CreateAsync(fixture, targetFramework, "MinidumpTestApp", MinidumpAppSource); + } + + internal static async Task CreateForThreadDumpAsync(PackSteeltoeLibrariesOnceFixture fixture, string targetFramework) + { + return await CreateAsync(fixture, targetFramework, "ThreadDumpTestApp", ThreadDumpAppSource); + } + + private static async Task CreateAsync(PackSteeltoeLibrariesOnceFixture fixture, string targetFramework, string testAppName, + string appSource) + { + const string testLibraryName = "DumpTestLibrary"; + + string rootDirectory = Path.Combine(fixture.SessionDirectory, "projects"); + string libraryDirectory = Directory.CreateDirectory(Path.Combine(rootDirectory, testLibraryName)).FullName; + string appDirectory = Directory.CreateDirectory(Path.Combine(rootDirectory, testAppName)).FullName; + + await WriteNuGetConfigFileAsync(rootDirectory, fixture.Source); + await WriteLibraryProjectAsync(libraryDirectory, testLibraryName, fixture.PackageVersion, targetFramework); + await WriteAppProjectAsync(appDirectory, testAppName, testLibraryName, targetFramework, appSource); + + return new DumpVerificationApp(appDirectory); + } + + private static async Task WriteNuGetConfigFileAsync(string directory, NuGetSource source) + { + string contents = $""" + + + + + + + + + + """; + + string nuGetConfigPath = Path.Combine(directory, "nuget.config"); + await File.WriteAllTextAsync(nuGetConfigPath, contents); + } + + private static async Task WriteLibraryProjectAsync(string libraryDirectory, string libraryName, string packageVersion, string targetFramework) + { + string projectFilePath = Path.Combine(libraryDirectory, $"{libraryName}.csproj"); + string projectFileContents = GetLibraryProjectFile(packageVersion, targetFramework); + await File.WriteAllTextAsync(projectFilePath, projectFileContents); + + string sourcePath = Path.Combine(libraryDirectory, "DumpProvider.cs"); + await File.WriteAllTextAsync(sourcePath, LibrarySource); + } + + private static async Task WriteAppProjectAsync(string appDirectory, string testAppName, string testLibraryName, string targetFramework, + string appSource) + { + string projectFilePath = Path.Combine(appDirectory, $"{testAppName}.csproj"); + string projectPath = GetAppProjectFile(testLibraryName, targetFramework); + await File.WriteAllTextAsync(projectFilePath, projectPath); + + string sourcePath = Path.Combine(appDirectory, "Program.cs"); + await File.WriteAllTextAsync(sourcePath, appSource); + } + + private static string GetLibraryProjectFile(string packageVersion, string targetFramework) + { + return $""" + + + {targetFramework} + enable + + + + + + """; + } + + private static string GetAppProjectFile(string testLibraryName, string targetFramework) + { + return $""" + + + {targetFramework} + enable + + + + + + """; + } + + public async Task RunAsync() + { + await DotNetProcessRunner.RunAsync(_appDirectory, "run"); + } + } + + private static class DotNetProcessRunner + { + private static readonly TimeSpan ProcessExitTimeout = TimeSpan.FromMinutes(5); + + public static async Task RunAsync(string workingDirectory, params string[] arguments) + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + + string[] dotNetArguments = + [ + .. arguments, + "-p:RunAnalyzers=false", + "-p:NuGetAudit=false" + ]; + + var outputBuilder = new StringBuilder(); + object outputLock = new(); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (string argument in dotNetArguments) + { + startInfo.ArgumentList.Add(argument); + } + + // Without this, a spawned "dotnet build"/"run" leaves a persistent MSBuild worker node running in the background for reuse by a later + // build. That node inherits our redirected stdout/stderr pipe handles and keeps them open after the process we launched exits, so the + // read end never sees EOF and awaiting exit below would block forever even though the build already completed successfully. + startInfo.EnvironmentVariables["MSBUILDDISABLENODEREUSE"] = "1"; + + using var process = new Process(); + process.StartInfo = startInfo; + process.OutputDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data); + process.ErrorDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(ProcessExitTimeout); + + try + { + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + KillEntireProcessTreeInBackground(process.Id); + + if (cancellationToken.IsCancellationRequested) + { + throw; + } + + throw new TimeoutException($"'dotnet {string.Join(' ', dotNetArguments)}' in '{workingDirectory}' did not exit within {ProcessExitTimeout}."); + } + + string output = outputBuilder.ToString(); + + process.ExitCode.Should().Be(0, "'dotnet {0}' in '{1}' was expected to exit successfully. Output:\n{2}", string.Join(' ', dotNetArguments), + workingDirectory, output); + + void AppendLine(string? line) + { + if (line == null) + { + return; + } + +#pragma warning disable S6507 // Blocks should not be synchronized on local variables + // Justification: Deliberately a call-scoped lock, not a shared static one: a global lock would serialize stdout/stderr callbacks across + // every concurrently running process, starving the thread pool under high-volume output (e.g. "dotnet build -v:detailed"). + lock (outputLock) +#pragma warning restore S6507 // Blocks should not be synchronized on local variables + { + outputBuilder.AppendLine(line); + } + } + } + + private static void KillEntireProcessTreeInBackground(int processId) + { + // Fire-and-forget, so that pressing the Stop button in an IDE responds immediately. + _ = Task.Run(() => + { + try + { + using var process = Process.GetProcessById(processId); + process.Kill(true); + } + catch (Exception) + { + // Best-effort kill of an already-timed-out process. + } + }); + } + } + + internal sealed record NuGetSource(string FeedDirectory, string PackagesDirectory); +} diff --git a/src/Management/test/Endpoint.Test/Actuators/ThreadDump/EventPipeThreadDumperTest.cs b/src/Management/test/Endpoint.Test/Actuators/ThreadDump/EventPipeThreadDumperTest.cs index 43711fff72..e429641771 100644 --- a/src/Management/test/Endpoint.Test/Actuators/ThreadDump/EventPipeThreadDumperTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/ThreadDump/EventPipeThreadDumperTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; using Steeltoe.Common.TestResources; using Steeltoe.Management.Endpoint.Actuators.ThreadDump; @@ -96,6 +97,7 @@ await action.Should().ThrowExactlyAsync() private static class NestedType { + [MethodImpl(MethodImplOptions.NoInlining)] public static void BackgroundThreadCallback(object? argument) { (CancellationToken cancellationToken, ManualResetEventSlim threadStarted) = ((CancellationToken, ManualResetEventSlim))argument!; diff --git a/src/Management/test/Endpoint.Test/Steeltoe.Management.Endpoint.Test.csproj b/src/Management/test/Endpoint.Test/Steeltoe.Management.Endpoint.Test.csproj index 97607ba184..85b9af34bb 100644 --- a/src/Management/test/Endpoint.Test/Steeltoe.Management.Endpoint.Test.csproj +++ b/src/Management/test/Endpoint.Test/Steeltoe.Management.Endpoint.Test.csproj @@ -5,6 +5,11 @@ + + + + + PreserveNewest diff --git a/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj b/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj index 983a147341..74b0e57f71 100644 --- a/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj +++ b/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs b/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs index f50113a03c..4f7a506d28 100644 --- a/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs +++ b/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs @@ -16,7 +16,7 @@ internal static partial class TestAppTargetFramework private static string Resolve() { AssemblyMetadataAttribute? attribute = Assembly.GetExecutingAssembly().GetCustomAttributes() - .FirstOrDefault(candidate => candidate.Key == "TargetFramework"); + .FirstOrDefault(candidate => candidate.Key == "TestTargetFramework"); if (attribute?.Value == null) { From 029fd43565238ff8cfa9573a872370dc1e204064 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:47:25 +0200 Subject: [PATCH 2/2] Bump dump dependencies to latest versions --- versions.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/versions.props b/versions.props index e94e82c170..35675e60c2 100644 --- a/versions.props +++ b/versions.props @@ -55,7 +55,7 @@ 2.2.* 1.8.* - 9.0.652701 + 10.0.745401 8.0.* 10.0.* - 0.2.652701 - 3.1.23 + 0.2.745401 + 3.2.6 1.15.*-* 1.15.* 10.0.*