diff --git a/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumpEndpointHandler.cs b/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumpEndpointHandler.cs index 1c8b1a70e8..e4c980d454 100644 --- a/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumpEndpointHandler.cs +++ b/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumpEndpointHandler.cs @@ -27,11 +27,12 @@ public HeapDumpEndpointHandler(IOptionsMonitor optionsM _logger = loggerFactory.CreateLogger(); } - public Task InvokeAsync(object? argument, CancellationToken cancellationToken) + public async Task InvokeAsync(object? argument, CancellationToken cancellationToken) { LogInvokingHeapDumper(); - string filePath = _heapDumper.DumpHeapToFile(cancellationToken); - return Task.FromResult(filePath); + + using IDisposable dumpLock = await ProcessDumpLock.EnterAsync(_logger, cancellationToken); + return _heapDumper.DumpHeapToFile(cancellationToken); } [LoggerMessage(Level = LogLevel.Trace, Message = "Invoking the heap dumper.")] diff --git a/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumper.cs b/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumper.cs index 6e21c41e2d..cecf434ca7 100644 --- a/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumper.cs +++ b/src/Management/src/Endpoint/Actuators/HeapDump/HeapDumper.cs @@ -2,12 +2,21 @@ // 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.Reflection; using System.Runtime.InteropServices; +using System.Text; using Graphs; using Microsoft.Diagnostics.NETCore.Client; using Microsoft.Diagnostics.Tools.GCDump; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using LockPrimitive = +#if NET10_0_OR_GREATER + System.Threading.Lock +#else + object +#endif + ; namespace Steeltoe.Management.Endpoint.Actuators.HeapDump; @@ -100,8 +109,19 @@ private void CreateGCDump(int processId, string outputPath, string dumpDescripti var heapInfo = new DotNetHeapInfo(); var memoryGraph = new MemoryGraph(50_000); + ResetEventPipeDotNetHeapDumperStaticState(); + if (EventPipeDotNetHeapDumper.DumpFromEventPipe(cancellationToken, processId, null, memoryGraph, logWriter, timeoutInSeconds, heapInfo)) { + // Workaround for https://github.com/dotnet/diagnostics/issues/6048: DumpFromEventPipe can return true + // without the memory graph having a root, when an exception was thrown (and swallowed) after the GC + // stop event was observed but before the graph was fully built. Calling AllowReading() in that case + // throws "RootIndex not set.", so treat it as a failed dump instead. + if (memoryGraph.RootIndex == NodeIndex.Invalid) + { + return false; + } + memoryGraph.AllowReading(); GCHeapDump.WriteMemoryGraph(memoryGraph, outputPath, "dotnet-gcdump"); return true; @@ -111,6 +131,28 @@ private void CreateGCDump(int processId, string outputPath, string dumpDescripti }, dumpDescription, cancellationToken); } + private static void ResetEventPipeDotNetHeapDumperStaticState() + { + // Workaround for https://github.com/dotnet/diagnostics/issues/6048: EventPipeDotNetHeapDumper tracks + // completion using "internal static volatile" fields (eventPipeDataPresent, dumpComplete) that are never + // reset between calls. A dump that follows a successful one can then observe stale completion state left + // behind by the previous call and return a false "success". We don't control that assembly, so reset the + // fields via reflection before every call. This is temporary: remove once diagnostics ships a fix that uses + // invocation-local state instead. Safe to skip silently if the fields are renamed or removed upstream, + // since that only means the original (pre-workaround) risk of this specific issue remains. + Type dumperType = typeof(EventPipeDotNetHeapDumper); + + foreach (string fieldName in new[] + { + "eventPipeDataPresent", + "dumpComplete" + }) + { + FieldInfo? field = dumperType.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static); + field?.SetValue(null, false); + } + } + private static void CreateHeapDump(HeapDumpType? heapDumpType, int processId, string outputPath) { var client = new DiagnosticsClient(processId); @@ -133,25 +175,24 @@ private static void CreateHeapDump(HeapDumpType? heapDumpType, int processId, st internal void CaptureLogOutput(Func action, string dumpDescription, CancellationToken cancellationToken) { - using var logStream = new MemoryStream(); + // EventPipeDotNetHeapDumper writes to the supplied writer from multiple threads (the EventPipe reader task + // and the session stop task) that can be active at the same time during session shutdown. A plain + // StreamWriter is not thread-safe for that, which caused the intermittent crash described in + // https://github.com/dotnet/diagnostics/issues/6048. + var logWriter = new ConcurrentTextWriter(); Exception? error = null; bool succeeded = false; - using (TextWriter logWriter = new StreamWriter(logStream, leaveOpen: true)) + try { - try - { - succeeded = action(logWriter); - } - catch (Exception exception) - { - error = exception; - } + succeeded = action(logWriter); + } + catch (Exception exception) + { + error = exception; } - logStream.Seek(0, SeekOrigin.Begin); - using var logReader = new StreamReader(logStream); - string logOutput = logReader.ReadToEnd(); + string logOutput = logWriter.ToString(); if (error != null || !succeeded) { @@ -188,4 +229,58 @@ private static void SafeDelete(string? outputPath) [LoggerMessage(Level = LogLevel.Trace, Message = "Captured log from {DumpType}:{LineBreak}{DumpLog}")] private partial void LogDumpLogCaptured(string dumpType, string lineBreak, string dumpLog); + + /// + /// A that can safely receive concurrent writes from multiple threads. + /// + private sealed class ConcurrentTextWriter : TextWriter + { + private readonly LockPrimitive _gate = new(); + private readonly StringBuilder _buffer = new(); + + public override Encoding Encoding => Encoding.Unicode; + + public override void Write(char value) + { + lock (_gate) + { + _buffer.Append(value); + } + } + + public override void Write(string? value) + { + if (!string.IsNullOrEmpty(value)) + { + lock (_gate) + { + _buffer.Append(value); + } + } + } + + public override void WriteLine(string? value) + { + lock (_gate) + { + _buffer.Append(value).Append(CoreNewLine); + } + } + + public override void WriteLine() + { + lock (_gate) + { + _buffer.Append(CoreNewLine); + } + } + + public override string ToString() + { + lock (_gate) + { + return _buffer.ToString(); + } + } + } } diff --git a/src/Management/src/Endpoint/Actuators/ThreadDump/ThreadDumpEndpointHandler.cs b/src/Management/src/Endpoint/Actuators/ThreadDump/ThreadDumpEndpointHandler.cs index 87bde9475f..d8fcd9727a 100644 --- a/src/Management/src/Endpoint/Actuators/ThreadDump/ThreadDumpEndpointHandler.cs +++ b/src/Management/src/Endpoint/Actuators/ThreadDump/ThreadDumpEndpointHandler.cs @@ -30,6 +30,8 @@ public ThreadDumpEndpointHandler(IOptionsMonitor opti public async Task> InvokeAsync(object? argument, CancellationToken cancellationToken) { LogInvokingThreadDumper(); + + using IDisposable dumpLock = await ProcessDumpLock.EnterAsync(_logger, cancellationToken); return await _threadDumper.DumpThreadsAsync(cancellationToken); } diff --git a/src/Management/src/Endpoint/ProcessDumpLock.cs b/src/Management/src/Endpoint/ProcessDumpLock.cs new file mode 100644 index 0000000000..431478ba6d --- /dev/null +++ b/src/Management/src/Endpoint/ProcessDumpLock.cs @@ -0,0 +1,46 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Steeltoe.Management.Endpoint; + +internal static partial class ProcessDumpLock +{ + private static readonly SemaphoreSlim Semaphore = new(1, 1); + + public static async Task EnterAsync(ILogger logger, CancellationToken cancellationToken) + { + LogLockEntering(logger); + await Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + LogLockEntered(logger); + + return new ReleaseOnDispose(logger); + } + + [LoggerMessage(LogLevel.Trace, "Obtaining exclusive lock")] + private static partial void LogLockEntering(ILogger logger); + + [LoggerMessage(LogLevel.Trace, "Exclusive lock obtained")] + private static partial void LogLockEntered(ILogger logger); + + [LoggerMessage(LogLevel.Trace, "Exclusive lock released")] + private static partial void LogLockReleased(ILogger logger); + + private sealed class ReleaseOnDispose(ILogger logger) : IDisposable + { + private readonly ILogger _logger = logger; + private bool _isReleased; + + public void Dispose() + { + if (!_isReleased) + { + _isReleased = true; + Semaphore.Release(); + LogLockReleased(_logger); + } + } + } +} 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..45e14f2e2c --- /dev/null +++ b/src/Management/test/Endpoint.Test/Actuators/DumpPackagingTest.cs @@ -0,0 +1,558 @@ +// 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) + { + SkipTestIfNotRunningOnHighestHostFramework(); + + 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) + { + SkipTestIfNotRunningOnHighestHostFramework(); + + 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) + { + SkipTestIfNotRunningOnHighestHostFramework(); + + 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 void SkipTestIfNotRunningOnHighestHostFramework() + { + string? expectedTargetFramework = GetExpectedTestHostFramework(); + + if (expectedTargetFramework != null) + { + // The InlineData entries already cover every supported TFM, so running tests on all target frameworks would just repeat identical work. + Assert.SkipWhen(true, $"Run this test on the '{expectedTargetFramework}' target framework instead."); + } + } + + private static string? GetExpectedTestHostFramework() + { + Version hostVersion = typeof(object).Assembly.GetName().Version!; + Version highestTargetFrameworkVersion = GetHighestTestTargetFrameworkVersion(); + + return hostVersion.Major == highestTargetFrameworkVersion.Major && hostVersion.Minor == highestTargetFrameworkVersion.Minor + ? null + // The InlineData entries already cover every supported TFM, so running tests on all target frameworks would just repeat identical work. + : $"net{hostVersion.Major}.{hostVersion.Minor}"; + } + + private static Version GetHighestTestTargetFrameworkVersion() + { + string[] targetFrameworks = ResolveTestTargetFrameworks(); + return targetFrameworks.Max(targetFramework => Version.Parse(targetFramework["net".Length..]))!; + } + + /// + /// 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 (GetExpectedTestHostFramework() != null) + { + 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 projectFileContents = GetAppProjectFile(testLibraryName, targetFramework); + await File.WriteAllTextAsync(projectFilePath, projectFileContents); + + 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) { 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.*