File: Layout\LayoutProcessRunnerTests.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.Globalization;
using Aspire.Cli.DotNet;
using Aspire.Cli.Layout;
using Aspire.Cli.Tests.TestServices;
 
namespace Aspire.Cli.Tests.LayoutTests;
 
public class LayoutProcessRunnerTests
{
    [Fact]
    public async Task RunAsync_InjectsOrphanDetectionEnvironment()
    {
        IDictionary<string, string>? capturedEnv = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, env, _, _) => capturedEnv = env,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await runner.RunAsync("tool", ["arg"], ct: TestContext.Current.CancellationToken);
 
        Assert.NotNull(capturedEnv);
        Assert.Equal(Environment.ProcessId.ToString(CultureInfo.InvariantCulture), capturedEnv["ASPIRE_CLI_PID"]);
        Assert.True(capturedEnv.ContainsKey("ASPIRE_CLI_STARTED"));
    }
 
    [Fact]
    public async Task RunAsync_DoesNotOverrideCallerSuppliedOrphanEnvironment()
    {
        IDictionary<string, string>? capturedEnv = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, env, _, _) => capturedEnv = env,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        var callerEnv = new Dictionary<string, string> { ["ASPIRE_CLI_PID"] = "999" };
 
        await runner.RunAsync("tool", ["arg"], environmentVariables: callerEnv, ct: TestContext.Current.CancellationToken);
 
        Assert.NotNull(capturedEnv);
        Assert.Equal("999", capturedEnv["ASPIRE_CLI_PID"]);
        // The caller's dictionary must not be mutated.
        Assert.False(callerEnv.ContainsKey("ASPIRE_CLI_STARTED"));
    }
 
    [Fact]
    public async Task StartAsync_InjectsOrphanDetectionEnvironment()
    {
        // StartAsync launches long-lived children (aspire-managed dashboard for `aspire dashboard run`
        // and the profiling collector), so it must stamp the same orphan-detection identity as RunAsync
        // or those children cannot self-terminate when the CLI is hard-killed.
        IDictionary<string, string>? capturedEnv = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, env, _, _) => capturedEnv = env,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await using var execution = await runner.StartAsync("tool", ["arg"]);
 
        Assert.NotNull(capturedEnv);
        Assert.Equal(Environment.ProcessId.ToString(CultureInfo.InvariantCulture), capturedEnv["ASPIRE_CLI_PID"]);
        Assert.True(capturedEnv.ContainsKey("ASPIRE_CLI_STARTED"));
    }
 
    [Fact]
    public async Task StartAsync_DoesNotOverrideCallerSuppliedOrphanEnvironment()
    {
        IDictionary<string, string>? capturedEnv = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, env, _, _) => capturedEnv = env,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        var callerEnv = new Dictionary<string, string> { ["ASPIRE_CLI_PID"] = "999" };
 
        await using var execution = await runner.StartAsync("tool", ["arg"], environmentVariables: callerEnv);
 
        Assert.NotNull(capturedEnv);
        Assert.Equal("999", capturedEnv["ASPIRE_CLI_PID"]);
        // The caller's dictionary must not be mutated.
        Assert.False(callerEnv.ContainsKey("ASPIRE_CLI_STARTED"));
    }
 
    [Fact]
    public async Task RunAsync_WhenKillOnParentExitRequested_SetsInvocationOption()
    {
        // killOnParentExit binds the child to the Windows kill-on-close job; it must flow through to the
        // ProcessInvocationOptions the factory receives so the spawn path can assign the child to the job.
        // (On Windows this also disarms the cooperative watchdog identity — asserted separately below.)
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, _, _, options) => capturedOptions = options,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await runner.RunAsync("tool", ["arg"], killOnParentExit: true, ct: TestContext.Current.CancellationToken);
 
        Assert.NotNull(capturedOptions);
        Assert.True(capturedOptions.KillOnParentExit);
    }
 
    [Fact]
    public async Task RunAsync_DefaultsKillOnParentExitToFalse()
    {
        // The backstop is opt-in: build/restore/etc. callers that don't ask for it must not be bound to
        // the kill-on-close job, preserving the existing force-kill/back-compat behavior.
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, _, _, options) => capturedOptions = options,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await runner.RunAsync("tool", ["arg"], ct: TestContext.Current.CancellationToken);
 
        Assert.NotNull(capturedOptions);
        Assert.False(capturedOptions.KillOnParentExit);
    }
 
    [Fact]
    public async Task StartAsync_WhenKillOnParentExitRequested_SetsInvocationOption()
    {
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, _, _, options) => capturedOptions = options,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await using var execution = await runner.StartAsync("tool", ["arg"], killOnParentExit: true);
 
        Assert.NotNull(capturedOptions);
        Assert.True(capturedOptions.KillOnParentExit);
    }
 
    [Fact]
    public async Task StartAsync_PreservesCallerSuppliedKillOnParentExit()
    {
        // StartAsync only ever turns the flag on, never off, so a caller that already opted in via its
        // own options is not silently downgraded when it omits the killOnParentExit argument.
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, _, _, options) => capturedOptions = options,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        var callerOptions = new ProcessInvocationOptions { KillOnParentExit = true };
 
        await using var execution = await runner.StartAsync("tool", ["arg"], options: callerOptions);
 
        Assert.NotNull(capturedOptions);
        Assert.True(capturedOptions.KillOnParentExit);
    }
 
    [Fact]
    public async Task StartAsync_WhenKillOnParentExitRequested_DoesNotMutateCallerOptions()
    {
        // The killOnParentExit flag is applied to a clone, never the caller's instance, so a caller that
        // reuses a single ProcessInvocationOptions across calls is not silently bound to the kill-on-close
        // job on a later invocation that happens to request it.
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, _, _, options) => capturedOptions = options,
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        var callerOptions = new ProcessInvocationOptions { KillOnParentExit = false };
 
        await using var execution = await runner.StartAsync("tool", ["arg"], options: callerOptions, killOnParentExit: true);
 
        Assert.NotNull(capturedOptions);
        // The child was launched with the flag set...
        Assert.True(capturedOptions.KillOnParentExit);
        // ...but the caller's own instance was left untouched.
        Assert.NotSame(callerOptions, capturedOptions);
        Assert.False(callerOptions.KillOnParentExit);
    }
 
    [Fact]
    public async Task RunAsync_WhenKillOnParentExit_DisarmsCooperativeWatchdogOnWindowsOnly()
    {
        // The Windows kill-on-close job and the cross-platform cooperative watchdog are redundant
        // implementations of the same "don't outlive the CLI" policy; arming both on one child races the
        // job's kernel TerminateProcess against the watchdog's Environment.Exit at CLI exit and can get the child stuck. 
        // So on Windows (where the job applies) the watchdog identity is NOT stamped via environment variables, 
        // while on other hosts (where KillOnParentExit is a no-op) the watchdog is the mechanism 
        // and receivers relevant environment variables.
        IDictionary<string, string>? capturedEnv = null;
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, env, _, options) => { capturedEnv = env; capturedOptions = options; },
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await runner.RunAsync("tool", ["arg"], killOnParentExit: true, ct: TestContext.Current.CancellationToken);
 
        Assert.NotNull(capturedOptions);
        Assert.True(capturedOptions.KillOnParentExit);
        Assert.NotNull(capturedEnv);
        if (OperatingSystem.IsWindows())
        {
            Assert.False(capturedEnv.ContainsKey("ASPIRE_CLI_PID"));
        }
        else
        {
            Assert.True(capturedEnv.ContainsKey("ASPIRE_CLI_PID"));
        }
    }
 
    [Fact]
    public async Task StartAsync_WhenKillOnParentExit_DisarmsCooperativeWatchdogOnWindowsOnly()
    {
        // Same mutual-exclusion contract as RunAsync above, for the long-lived StartAsync helpers
        // (aspire-managed dashboard, profiling collector).
        IDictionary<string, string>? capturedEnv = null;
        ProcessInvocationOptions? capturedOptions = null;
        var factory = new TestProcessExecutionFactory
        {
            AssertionCallback = (_, env, _, options) => { capturedEnv = env; capturedOptions = options; },
            DefaultExitCode = 0,
        };
        var runner = new LayoutProcessRunner(factory);
 
        await using var execution = await runner.StartAsync("tool", ["arg"], killOnParentExit: true);
 
        Assert.NotNull(capturedOptions);
        Assert.True(capturedOptions.KillOnParentExit);
        Assert.NotNull(capturedEnv);
        if (OperatingSystem.IsWindows())
        {
            Assert.False(capturedEnv.ContainsKey("ASPIRE_CLI_PID"));
        }
        else
        {
            Assert.True(capturedEnv.ContainsKey("ASPIRE_CLI_PID"));
        }
    }
}