| File: OrphanDetectorTests.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. using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Aspire.Hosting.RemoteHost.Tests; public class OrphanDetectorTests { private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(10); [Fact] public async Task CompletesWithoutStoppingWhenNoParentPidConfigured() { var configuration = new ConfigurationBuilder().Build(); var stopCalled = false; var lifetime = new HostLifetimeStub(() => stopCalled = true); var detector = new OrphanDetector(configuration, lifetime, NullLogger<OrphanDetector>.Instance); await detector.StartAsync(CancellationToken.None).WaitAsync(s_timeout); await detector.ExecuteTask!.WaitAsync(s_timeout); Assert.False(stopCalled); } [Fact] public async Task StopsWhenParentPidNotRunning_PidOnly() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { ["REMOTE_APP_HOST_PID"] = "1111" }) .Build(); var stopTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var lifetime = new HostLifetimeStub(() => stopTcs.TrySetResult()); var detector = new OrphanDetector(configuration, lifetime, NullLogger<OrphanDetector>.Instance) { IsProcessRunning = _ => false, // Ensure the start-time path is not used when no start time is configured. IsProcessRunningWithStartTime = (_, _) => throw new InvalidOperationException("Start-time path should not be used without REMOTE_APP_HOST_STARTED."), }; await detector.StartAsync(CancellationToken.None).WaitAsync(s_timeout); await stopTcs.Task.WaitAsync(s_timeout); } [Fact] public async Task StopsWhenParentPidNotRunning_WithStartTimeVerification() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { ["REMOTE_APP_HOST_PID"] = "1111", ["REMOTE_APP_HOST_STARTED"] = "1700000000", }) .Build(); var stopTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var lifetime = new HostLifetimeStub(() => stopTcs.TrySetResult()); var detector = new OrphanDetector(configuration, lifetime, NullLogger<OrphanDetector>.Instance) { // PID-only path must not run when a start time is configured. IsProcessRunning = _ => throw new InvalidOperationException("PID-only path should not be used when REMOTE_APP_HOST_STARTED is set."), IsProcessRunningWithStartTime = (_, _) => false, }; await detector.StartAsync(CancellationToken.None).WaitAsync(s_timeout); await stopTcs.Task.WaitAsync(s_timeout); } [Fact] public async Task StopsWhenStartTimeMismatch_PidReuse() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { ["REMOTE_APP_HOST_PID"] = "1111", ["REMOTE_APP_HOST_STARTED"] = "1700000000", }) .Build(); var stopTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var lifetime = new HostLifetimeStub(() => stopTcs.TrySetResult()); // The PID exists but its start time does not match — a recycled PID. The detector must treat the // parent as gone rather than keeping the orphaned server alive. var detector = new OrphanDetector(configuration, lifetime, NullLogger<OrphanDetector>.Instance) { IsProcessRunningWithStartTime = (_, _) => false, }; await detector.StartAsync(CancellationToken.None).WaitAsync(s_timeout); await stopTcs.Task.WaitAsync(s_timeout); } [Fact] public async Task FallsBackToAspireCliStartedWhenRemoteStartedMissing() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { ["REMOTE_APP_HOST_PID"] = "1111", ["ASPIRE_CLI_STARTED"] = "1700000000", }) .Build(); var stopTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var lifetime = new HostLifetimeStub(() => stopTcs.TrySetResult()); var detector = new OrphanDetector(configuration, lifetime, NullLogger<OrphanDetector>.Instance) { IsProcessRunning = _ => throw new InvalidOperationException("Start time from ASPIRE_CLI_STARTED should select the legacy start-time path."), IsProcessRunningWithStartTime = (_, _) => throw new InvalidOperationException("Legacy ASPIRE_CLI_STARTED should not use the stable start-time path."), IsProcessRunningWithLegacyStartTime = (_, _) => false, }; await detector.StartAsync(CancellationToken.None).WaitAsync(s_timeout); await stopTcs.Task.WaitAsync(s_timeout); } [Fact] public async Task PrefersStableAspireCliStartedWhenRemoteStartedMissing() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { ["REMOTE_APP_HOST_PID"] = "1111", ["ASPIRE_CLI_STARTED"] = "1700000000", ["ASPIRE_CLI_STARTED_STABLE"] = "1700000001", }) .Build(); var stopTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var lifetime = new HostLifetimeStub(() => stopTcs.TrySetResult()); var detector = new OrphanDetector(configuration, lifetime, NullLogger<OrphanDetector>.Instance) { IsProcessRunning = _ => throw new InvalidOperationException("Start time from ASPIRE_CLI_STARTED_STABLE should select the start-time path."), IsProcessRunningWithStartTime = (_, startTime) => { Assert.Equal(1700000001, startTime); return false; }, IsProcessRunningWithLegacyStartTime = (_, _) => throw new InvalidOperationException("Stable ASPIRE_CLI_STARTED_STABLE should be preferred."), }; await detector.StartAsync(CancellationToken.None).WaitAsync(s_timeout); await stopTcs.Task.WaitAsync(s_timeout); } private sealed class HostLifetimeStub(Action stopImplementation) : IHostApplicationLifetime { public CancellationToken ApplicationStarted => throw new NotImplementedException(); public CancellationToken ApplicationStopped => throw new NotImplementedException(); public CancellationToken ApplicationStopping => throw new NotImplementedException(); public void StopApplication() => stopImplementation(); } }