File: Projects\ProcessGuestLauncherTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
using static Aspire.Cli.Tests.TestServices.ProcessTestHelpers;
 
namespace Aspire.Cli.Tests.Projects;
 
public class ProcessGuestLauncherTests(ITestOutputHelper outputHelper) : IDisposable
{
    private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper));
 
    public void Dispose() => _loggerFactory.Dispose();
 
    private ProcessGuestLauncher CreateLauncher(IProcessExecutionFactory? processExecutionFactory = null)
        => new(
            "test",
            _loggerFactory.CreateLogger<ProcessGuestLauncher>(),
            fileLoggerProvider: null,
            commandResolver: PathLookupHelper.FindFullPathFromPath,
            processExecutionFactory: processExecutionFactory ?? new ProcessExecutionFactory(new TestEnvironment(), _loggerFactory.CreateLogger<ProcessExecutionFactory>()));
 
    [Fact]
    public async Task LaunchAsync_WithIsolatedConsoleForGracefulShutdown_RequestsKillOnParentExit()
    {
        var executionFactory = new TestProcessExecutionFactory();
        var launcher = CreateLauncher(executionFactory);
        var options = new GuestLaunchOptions(IsolateConsoleForGracefulShutdown: true);
 
        var (exitCode, _) = await launcher.LaunchAsync(
            "dotnet",
            ["--version"],
            new DirectoryInfo(Directory.GetCurrentDirectory()),
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: options,
            CancellationToken.None);
 
        Assert.Equal(0, exitCode);
        Assert.True(executionFactory.LastProcessInvocationOptions?.IsolateConsole);
        Assert.True(executionFactory.LastProcessInvocationOptions?.KillOnParentExit);
    }
 
    [Fact]
    public async Task LaunchAsync_NoOptions_OnCancellation_ForceKillsProcessTreeAndReturns()
    {
        // Baseline: when no GuestLaunchOptions are passed (publish path, scaffolding, any caller
        // not opted into the central shutdown budget) the launcher must continue to force-kill on
        // cancellation. This preserves today's tactical behavior for non-Run callers and exercises
        // the same code path as the existing ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation
        // test, just with an explicit assertion on the no-options branch.
        var launcher = CreateLauncher();
 
        var (command, args) = GetLongRunningCommand();
 
        using var cts = new CancellationTokenSource();
        var launchTask = launcher.LaunchAsync(
            command,
            args,
            new DirectoryInfo(Path.GetTempPath()),
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: null,
            cts.Token);
 
        // Give the OS time to spawn the child before cancelling so we exercise the kill-while-running
        // path instead of the cancel-before-start short-circuit.
        await Task.Delay(500);
 
        var stopwatch = Stopwatch.StartNew();
        cts.Cancel();
 
        var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30));
        stopwatch.Stop();
 
        Assert.NotEqual(0, exitCode);
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
            $"Expected force-kill ladder to return within 15s of cancellation but it took {stopwatch.Elapsed}.");
    }
 
    [Fact]
    public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEscalation()
    {
        // Run-path happy case: graceful signaler is invoked, simulates a successful signal by
        // killing the process directly, and the ladder observes the exit via WaitForExitAsync
        // before anyone calls Expire(). The central shutdown token must NOT be cancelled — this
        // proves the launcher consumes the token as a deadline (not a trigger) and doesn't burn
        // the central budget on successful graceful exits.
        var launcher = CreateLauncher();
        var (command, args) = GetLongRunningCommand();
 
        using var cts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
        // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder.
        var signaler = new RecordingGracefulSignaler(onSignal: pid =>
        {
            try
            {
                using var proc = Process.GetProcessById(pid);
                proc.Kill(entireProcessTree: true);
            }
            catch (ArgumentException)
            {
                // Already exited; treat as graceful success.
            }
 
            return Task.FromResult(true);
        });
 
        var options = new GuestLaunchOptions(
            IsolateConsoleForGracefulShutdown: false,
            GracefulShutdownSignaler: signaler,
            ShutdownService: shutdownService);
 
        var launchTask = launcher.LaunchAsync(
            command,
            args,
            new DirectoryInfo(Path.GetTempPath()),
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: options,
            cts.Token);
 
        await Task.Delay(500);
        cts.Cancel();
 
        var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.NotEqual(0, exitCode);
        Assert.Single(signaler.Pids);
        Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested);
    }
 
    [Fact]
    public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsumeGracefulBudget()
    {
        // Regression coverage for DCP's stop-process-tree behavior: the graceful signaler can
        // deliver the signal quickly and then block until the target exits. The ladder must wait
        // for process exit in parallel with that signaler instead of awaiting the signaler first.
        var launcher = CreateLauncher();
        var (command, args) = GetLongRunningCommand();
 
        using var cts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
        // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder.
        var signalerNeverCompletes = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        var signaler = new RecordingGracefulSignaler(onSignal: pid =>
        {
            try
            {
                using var proc = Process.GetProcessById(pid);
                proc.Kill(entireProcessTree: true);
            }
            catch (ArgumentException)
            {
                // Already exited; treat as graceful success.
            }
 
            return signalerNeverCompletes.Task;
        });
 
        var options = new GuestLaunchOptions(
            IsolateConsoleForGracefulShutdown: false,
            GracefulShutdownSignaler: signaler,
            ShutdownService: shutdownService);
 
        var launchTask = launcher.LaunchAsync(
            command,
            args,
            new DirectoryInfo(Path.GetTempPath()),
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: options,
            cts.Token);
 
        await Task.Delay(500);
        cts.Cancel();
 
        var stopwatch = Stopwatch.StartNew();
        var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(10));
        stopwatch.Stop();
 
        Assert.NotEqual(0, exitCode);
        Assert.Single(signaler.Pids);
        Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested);
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5),
            $"Expected process exit to win over the still-blocked signaler but it took {stopwatch.Elapsed}.");
    }
 
    [Fact]
    public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEscalatesToTreeKill()
    {
        // Run-path bad-citizen case: graceful signaler accepts the request but the process ignores
        // it (the canonical tsx-swallows-Ctrl+Break scenario on Windows). Expiring the central token
        // must break the ladder out of WaitForExitAsync and escalate to Kill(entireProcessTree: true)
        // so the tree dies even when the cooperative path fails.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var launcher = CreateLauncher();
        var descendantPidFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "descendant.pid"));
        var (command, args) = await GetProcessTreeCommandAsync(workspace.WorkspaceRoot, descendantPidFile);
 
        using var cts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
 
        // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder.
        // Escalation is driven by the explicit Expire() below, not by the budget elapsing.
 
        var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var signaler = new RecordingGracefulSignaler(onSignal: _ =>
        {
            signaled.TrySetResult();
            // Pretend the OS signal was delivered but the process is a bad citizen and refuses to exit.
            return Task.FromResult(true);
        });
 
        var options = new GuestLaunchOptions(
            IsolateConsoleForGracefulShutdown: false,
            GracefulShutdownSignaler: signaler,
            ShutdownService: shutdownService);
 
        var launchTask = launcher.LaunchAsync(
            command,
            args,
            workspace.WorkspaceRoot,
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: options,
            cts.Token);
 
        var descendantPid = await WaitForPidFileAsync(descendantPidFile);
 
        try
        {
            cts.Cancel();
 
            // Wait until the ladder has actually called the signaler before expiring. Otherwise Expire()
            // could fire before the ladder reaches WaitForExitAsync and we'd be testing cancellation
            // ordering rather than the escalation path.
            await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10));
 
            shutdownService.Expire();
 
            var stopwatch = Stopwatch.StartNew();
            var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30));
            stopwatch.Stop();
 
            Assert.NotEqual(0, exitCode);
            Assert.True(WaitForProcessExit(descendantPid, TimeSpan.FromSeconds(10)), $"Expected descendant process {descendantPid} to be killed with the root process tree.");
            Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
                $"Expected escalation to tree-kill within 15s of Expire() but it took {stopwatch.Elapsed}.");
        }
        finally
        {
            TryKillProcess(descendantPid);
        }
    }
 
    [Fact]
    public async Task LaunchAsync_WithGracefulServices_SignalerThrows_StillEscalatesToTreeKill()
    {
        // Best-effort contract for the graceful signal: any exception (DCP layout discovery failed,
        // network blip, anything) must be logged and swallowed so the ladder still escalates to
        // Kill(entireProcessTree: true). The previous force-kill path swallowed signaler failures
        // implicitly by not even calling the signaler; the new ladder needs an explicit guarantee.
        var launcher = CreateLauncher();
        var (command, args) = GetLongRunningCommand();
 
        using var cts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
 
        // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder.
 
        var signaler = new RecordingGracefulSignaler(onSignal: _ =>
            throw new InvalidOperationException("simulated DCP failure"));
 
        var options = new GuestLaunchOptions(
            IsolateConsoleForGracefulShutdown: false,
            GracefulShutdownSignaler: signaler,
            ShutdownService: shutdownService);
 
        var launchTask = launcher.LaunchAsync(
            command,
            args,
            new DirectoryInfo(Path.GetTempPath()),
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: options,
            cts.Token);
 
        await Task.Delay(500);
        cts.Cancel();
        // Expire immediately — without a working signaler there's nothing to wait for; the ladder
        // is going to wait on WaitForExitAsync(gracefulToken) and we want the escalation to fire.
        shutdownService.Expire();
 
        var stopwatch = Stopwatch.StartNew();
        var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30));
        stopwatch.Stop();
 
        Assert.NotEqual(0, exitCode);
        Assert.Single(signaler.Pids);
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
            $"Expected escalation to tree-kill within 15s of Expire() but it took {stopwatch.Elapsed}.");
    }
 
    private static (string Command, string[] Args) GetLongRunningCommand()
    {
        // Cross-platform child that runs long enough to outlive the cancellation+kill ladder under
        // realistic CI load. Matches LongRunningAppHostServerProject (AppHostServerSessionTests).
        if (OperatingSystem.IsWindows())
        {
            // ping with a long count keeps the child alive ~60s; tree-kill needs to traverse cmd -> ping.
            return ("cmd.exe", ["/c", "ping", "-n", "60", "127.0.0.1"]);
        }
 
        return ("sleep", ["60"]);
    }
 
    private static async Task<(string Command, string[] Args)> GetProcessTreeCommandAsync(DirectoryInfo workspaceRoot, FileInfo descendantPidFile)
    {
        if (OperatingSystem.IsWindows())
        {
            // Use cmd.exe as the descendant instead of powershell.exe because PowerShell has
            // a multi-second cold start on CI runners, and nesting two powershell.exe processes
            // (root + child) frequently exceeds the pid-file wait timeout.
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "spawn-descendant.ps1"));
            var content =
                "$psi = [System.Diagnostics.ProcessStartInfo]::new()" + Environment.NewLine +
                "$psi.FileName = 'cmd.exe'" + Environment.NewLine +
                "$psi.Arguments = '/c ping -n 60 127.0.0.1 > nul'" + Environment.NewLine +
                "$psi.UseShellExecute = $false" + Environment.NewLine +
                "$child = [System.Diagnostics.Process]::Start($psi)" + Environment.NewLine +
                $"Set-Content -Path '{descendantPidFile.FullName}' -Value $child.Id" + Environment.NewLine +
                "$child.WaitForExit()" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
 
            return ("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptFile.FullName]);
        }
 
        var shellFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "spawn-descendant.sh"));
        var shellContent =
            "#!/usr/bin/env bash" + Environment.NewLine +
            "sleep 60 &" + Environment.NewLine +
            $"echo $! > \"{descendantPidFile.FullName}\"" + Environment.NewLine +
            "wait $!" + Environment.NewLine;
        await File.WriteAllTextAsync(shellFile.FullName, shellContent);
        File.SetUnixFileMode(
            shellFile.FullName,
            UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
            UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
            UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
 
        return ("/bin/bash", [shellFile.FullName]);
    }
 
    private static async Task<int> WaitForPidFileAsync(FileInfo pidFile)
    {
        var deadline = DateTime.UtcNow + TestConstants.LongTimeoutTimeSpan;
        while (DateTime.UtcNow < deadline)
        {
            if (pidFile.Exists)
            {
                try
                {
                    // The writer (PowerShell Set-Content on Windows / shell redirect on Unix) may
                    // still hold the file open when we observe it exists, so reading can race into a
                    // Windows sharing violation. Open with FileShare.ReadWrite and treat any
                    // IOException as "not ready yet" — the int.TryParse guard below also rejects a
                    // partially-written value — then retry until the deadline.
                    using var stream = new FileStream(pidFile.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
                    using var reader = new StreamReader(stream);
                    var text = await reader.ReadToEndAsync();
                    if (int.TryParse(text.Trim(), out var pid))
                    {
                        return pid;
                    }
                }
                catch (IOException)
                {
                    // File briefly locked by the writer; fall through and retry.
                }
            }
 
            await Task.Delay(50);
            pidFile.Refresh();
        }
 
        throw new TimeoutException($"Timed out waiting for descendant pid file '{pidFile.FullName}'.");
    }
}