File: DotNet\ProcessExecutionDetachedTests.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 System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Aspire.Cli.DotNet;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.DotNet;
 
public class ProcessExecutionDetachedTests(ITestOutputHelper outputHelper)
{
    [Fact]
    [SupportedOSPlatform("windows")]
    public async Task StartAsync_OnWindows_ProvidesValidNullStdin()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows-only test.");
 
        await using var child = CreateDetachedExecution(
            "powershell.exe",
            ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", """
                Add-Type 'using System; using System.Runtime.InteropServices; public static class Stdio { [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int id); }';
                $inputHandle = [Stdio]::GetStdHandle(-10);
                if ($inputHandle -eq [IntPtr]::Zero -or $inputHandle.ToInt64() -eq -1) { exit 42 }
                if ([Console]::In.Read() -ne -1) { exit 43 }
                [Console]::Out.WriteLine('discarded stdout');
                [Console]::Error.WriteLine('discarded stderr');
                exit 0
                """],
            Environment.CurrentDirectory);
 
        Assert.True(await child.StartAsync(TestContext.Current.CancellationToken));
        try
        {
            Assert.Equal(0, await child.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10)));
        }
        finally
        {
            if (!child.HasExited)
            {
                child.Kill(entireProcessTree: true);
                await child.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
            }
        }
    }
 
    // Regression test for the duplicate-handle bug that broke `aspire start` on Windows:
    // the Windows detached path points both Stdout and Stderr at the same NUL handle, and
    // PROC_THREAD_ATTRIBUTE_HANDLE_LIST rejects duplicate handle values. Process.Start
    // duplicates the standard handles before building the list, so this spawn must succeed.
    [Fact]
    [SupportedOSPlatform("windows")]
    public async Task StartAsync_OnWindows_WithSharedStdoutStderrHandle_Succeeds()
    {
        Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test.");
 
        // A short-lived child is sufficient: we only need CreateProcessW to return successfully.
        // `cmd.exe /c exit 0` returns immediately and never touches stdout/stderr, so any
        // failure mode here is from the spawn primitive, not from the child itself.
        await using var child = CreateDetachedExecution(
            "cmd.exe",
            ["/c", "exit", "0"],
            Environment.CurrentDirectory);
 
        Assert.True(await child.StartAsync(CancellationToken.None));
        Assert.True(child.ProcessId > 0);
    }
 
    [Fact]
    [SupportedOSPlatform("linux")]
    [SupportedOSPlatform("macos")]
    public async Task StartAsync_OnUnix_StartsDirectChildInNewSessionWithExpectedContext()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only test.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var capturePath = Path.Combine(workspace.WorkspaceRoot.FullName, "capture.txt");
 
        await using var detachedProcess = CreateDetachedExecution(
            "/bin/sh",
            ["-c", """
                {
                  printf 'cwd=%s\n' "$PWD"
                  printf 'pgid=%s\n' "$(ps -o pgid= -p $$ | tr -d ' ')"
                  printf 'ppid=%s\n' "$PPID"
                  printf 'home=%s\n' "${HOME:-missing}"
                  printf 'added=%s\n' "$ASPIRE_TEST_ADDED"
                  if read -r line; then printf 'stdin=data\n'; else printf 'stdin=eof\n'; fi
                } > "$ASPIRE_TEST_CAPTURE"
                echo discarded
                exit 7
                """],
            workspace.WorkspaceRoot.FullName,
            environmentVariableFilter: name => string.Equals(name, "HOME", StringComparison.Ordinal),
            environment: new Dictionary<string, string>
            {
                ["ASPIRE_TEST_ADDED"] = "value",
                ["ASPIRE_TEST_CAPTURE"] = capturePath
            });
 
        Assert.True(await detachedProcess.StartAsync(CancellationToken.None));
 
        // The exit code is observable because StartDetached keeps the child a direct child of the
        // CLI; setsid() only moves it into its own session and process group.
        Assert.Equal(7, await detachedProcess.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10)));
        Assert.Equal([
            $"cwd={PathNormalizer.ResolveSymlinks(workspace.WorkspaceRoot.FullName)}",
            $"pgid={detachedProcess.ProcessId.ToString(CultureInfo.InvariantCulture)}",
            $"ppid={Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}",
            "home=missing",
            "added=value",
            "stdin=eof"
        ], await File.ReadAllLinesAsync(capturePath));
    }
 
    private static IProcessExecution CreateDetachedExecution(
        string fileName,
        IReadOnlyList<string> arguments,
        string workingDirectory,
        Func<string, bool>? environmentVariableFilter = null,
        IReadOnlyDictionary<string, string>? environment = null)
    {
        var factory = new ProcessExecutionFactory(new TestEnvironment(), NullLogger<ProcessExecutionFactory>.Instance);
        return factory.CreateExecution(
            fileName,
            arguments.ToArray(),
            environment?.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value),
            new DirectoryInfo(workingDirectory),
            new ProcessInvocationOptions
            {
                Detached = true,
                IsolateConsole = true,
                EnvironmentVariableFilter = environmentVariableFilter
            });
    }
}