| File: tests\Shared\Processes\ProcessSupervisorTests.cs | Web Access |
| Project: src\tests\Aspire.Hosting.RemoteHost.Tests\Aspire.Hosting.RemoteHost.Tests.csproj (Aspire.Hosting.RemoteHost.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #if !NET11_0_OR_GREATER extern alias RemoteHost; #endif using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; using System.Text.Json; using Aspire.TestUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Xunit; #if NET11_0_OR_GREATER using TestProcessStartInfoHelper = global::ProcessStartInfoHelper; #else using TestProcessStartInfoHelper = RemoteHost::ProcessStartInfoHelper; #endif namespace Aspire.Shared.Tests; [RequiresTools(["dotnet"])] [Collection(ProcessTestCollection.Name)] public class ProcessSupervisorTests(ProcessTestFixture fixture) { [Fact] public async Task OwnedTree_WindowsBatchShim_PreservesFinalChildArguments() { Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows batch commands require cmd.exe."); var directory = fixture.CreateDirectory().CreateSubdirectory("literal %TEMP%! with spaces"); var shim = Path.Combine(directory.FullName, "literal %PATH%! shim.cmd"); await File.WriteAllTextAsync(shim, """ @echo off "%ASPIRE_TEST_DOTNET%" "%ASPIRE_TEST_HOST%" argv %* """); string[] arguments = [ "literal %PATH%! & value", "", "a^b|c", "(group)>file<other", "a \"quoted\" value", "quoted \"& text\" remains literal", "backslash\\\"quote", @"C:\tools\trailing\" ]; var startInfo = new ProcessStartInfo { WorkingDirectory = directory.FullName }; TestProcessStartInfoHelper.SetCommand(startInfo, shim, arguments, isWindows: true); startInfo.Environment["ASPIRE_TEST_DOTNET"] = Path.Combine(System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory(), "..", "..", "..", "dotnet.exe"); startInfo.Environment["ASPIRE_TEST_HOST"] = fixture.AssemblyPath; var stdout = new ConcurrentQueue<string>(); await using var process = ProcessTestFixture.CreateProcess(startInfo, new ChildProcessOptions { Lifetime = ChildProcessLifetime.OwnedTree, CreateSupervisorStartInfo = fixture.CreateSupervisorStartInfo, StandardOutputCallback = stdout.Enqueue }); await process.StartAsync(TestContext.Current.CancellationToken); Assert.Equal(0, await process.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30))); Assert.Equal(arguments, JsonSerializer.Deserialize<string[]>(Assert.Single(stdout))); } [Theory] [InlineData(0)] [InlineData(23)] public async Task CommandExit_PreservesCompletionCodeAndReapsWorkers(int exitCode) { var directory = fixture.CreateDirectory(); using var readiness = new ProcessTestReadiness(); var completionPath = Path.Combine(directory.FullName, "exit-code"); var sink = new TestSink(); var stderr = new ConcurrentQueue<string>(); await using var guardian = ProcessTestFixture.CreateProcess(fixture.CreateSupervisorStartInfo( fixture.CreateStartInfo("tree-exit", readiness.Name, exitCode.ToString(CultureInfo.InvariantCulture)), completionPath), new TestLogger("guardian owner", sink, enabled: true), new ChildProcessOptions { StandardErrorCallback = stderr.Enqueue }); await guardian.StartAsync(TestContext.Current.CancellationToken); var guardianPid = guardian.ProcessId; await using var scope = new ProcessScope(guardian, NullLogger.Instance, "test completion"); var identities = await readiness.ReadTreeAsync(); await scope.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30)); await scope.DisposeAsync(); Assert.Equal(exitCode.ToString(CultureInfo.InvariantCulture), await File.ReadAllTextAsync(completionPath)); var exit = Assert.Single(sink.Writes, entry => entry.Message?.Contains("Supervised command", StringComparison.Ordinal) == true); Assert.Equal(exitCode == 0 ? LogLevel.Information : LogLevel.Warning, exit.LogLevel); Assert.Equal($"Process supervisor {guardianPid}: Supervised command 'dotnet' exited with code {exitCode}.", exit.Message); Assert.Empty(stderr); await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync)); } [Fact] public async Task Cancellation_ReapsGuardianRuntimeAndWorker() { using var readiness = new ProcessTestReadiness(); await using var guardian = ProcessTestFixture.CreateProcess(fixture.CreateSupervisorStartInfo( fixture.CreateStartInfo("tree", readiness.Name)), new ChildProcessOptions()); await guardian.StartAsync(TestContext.Current.CancellationToken); await using var scope = new ProcessScope(guardian, NullLogger.Instance, "test cancellation"); var guardianIdentity = ProcessTestIdentity.Capture(scope.ProcessId); var identities = await readiness.ReadTreeAsync(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => scope.WaitForExitAsync(cancellation.Token)); await scope.DisposeAsync(); await Task.WhenAll(identities.Append(guardianIdentity).Select(ProcessTestFixture.AssertExitedAsync)); } [Fact] public async Task GuardianCrash_ReapsRuntimeAndWorker() { using var readiness = new ProcessTestReadiness(); await using var guardian = ProcessTestFixture.CreateProcess(fixture.CreateSupervisorStartInfo( fixture.CreateStartInfo("tree", readiness.Name)), new ChildProcessOptions()); await guardian.StartAsync(TestContext.Current.CancellationToken); await using var scope = new ProcessScope(guardian, NullLogger.Instance, "test guardian crash"); var identities = await readiness.ReadTreeAsync(); using var observedGuardian = Process.GetProcessById(scope.ProcessId); observedGuardian.Kill(entireProcessTree: false); await scope.DisposeAsync(); await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync)); } [Theory] [InlineData(false)] [InlineData(true)] public async Task ExecutionWithoutScope_ReapsDescendantsAfterGuardianExit(bool cancel) { using var readiness = new ProcessTestReadiness(); await using var guardian = ProcessTestFixture.CreateProcess(fixture.CreateSupervisorStartInfo( fixture.CreateStartInfo("tree", readiness.Name)), new ChildProcessOptions()); await guardian.StartAsync(TestContext.Current.CancellationToken); var identities = await readiness.ReadTreeAsync(); if (cancel) { using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => guardian.WaitForExitAsync(cancellation.Token)); } else { using var observedGuardian = Process.GetProcessById(guardian.ProcessId); observedGuardian.Kill(entireProcessTree: false); } // Execution itself must verify containment. No supervising owner wrapper // should be required to reap orphaned workers. await guardian.DisposeAsync(); await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync)); } [Fact] public async Task OwnerCrash_GuardianIndependentlyReapsRuntimeAndWorker() { using var readiness = new ProcessTestReadiness(); await using var owner = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("owner", readiness.Name), new ChildProcessOptions()); await owner.StartAsync(TestContext.Current.CancellationToken); var guardianIdentity = await readiness.ReadGuardianAsync(); ProcessTestIdentity[] identities = []; try { identities = await readiness.ReadTreeAsync(); using var observedOwner = Process.GetProcessById(owner.ProcessId); observedOwner.Kill(entireProcessTree: false); await owner.WaitForRootExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30)); await Task.WhenAll(identities.Append(guardianIdentity).Select(ProcessTestFixture.AssertExitedAsync)); } finally { ProcessTestFixture.KillIfRunning(guardianIdentity); foreach (var identity in identities) { ProcessTestFixture.KillIfRunning(identity); } } } }