File: TerminalHostSignalTests.cs
Web Access
Project: src\tests\Aspire.Managed.Tests\Aspire.Managed.Tests.csproj (Aspire.Managed.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 System.Globalization;
using System.Runtime.InteropServices;
using Aspire.Hosting;
using Aspire.Managed.NuGet.Commands;
using Aspire.TerminalHost;
using Xunit;
 
namespace Aspire.Managed.Tests;
 
public partial class TerminalHostSignalTests
{
    private const int SigTerm = 15;
 
    [Fact]
    public async Task TerminalHostSubcommandHandlesSigTermAndUnlinksSockets()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "This test sends a Unix SIGTERM directly.");
 
        var socketDirectory = Directory.CreateTempSubdirectory("ath-");
        try
        {
            var producerPath = Path.Combine(socketDirectory.FullName, "p.sock");
            var consumerPath = Path.Combine(socketDirectory.FullName, "h.sock");
            var controlPath = Path.Combine(socketDirectory.FullName, "c.sock");
            var socketPaths = new[] { producerPath, consumerPath, controlPath };
 
            // macOS sockaddr_un.sun_path has 103 usable bytes. Keep this process-level test
            // independent of the repository checkout path so it exercises signal handling there.
            Assert.All(
                socketPaths,
                path => Assert.True(
                    System.Text.Encoding.UTF8.GetByteCount(path) < 104,
                    $"Socket path is too long for macOS: {path}"));
 
            var startInfo = CreateTerminalHostStartInfo(producerPath, consumerPath, controlPath);
 
            using var process = Process.Start(startInfo)
                ?? throw new InvalidOperationException("Failed to start aspire-managed.");
            var standardOutputTask = process.StandardOutput.ReadToEndAsync();
            var standardErrorTask = process.StandardError.ReadToEndAsync();
 
            try
            {
                var readyTask = WaitForFilesAsync(socketPaths, TimeSpan.FromSeconds(10));
                var exitedTask = process.WaitForExitAsync();
                if (await Task.WhenAny(readyTask, exitedTask) == exitedTask)
                {
                    Assert.Fail(
                        $"aspire-managed exited before binding its sockets with code {process.ExitCode}.{Environment.NewLine}" +
                        await standardErrorTask);
                }
 
                await readyTask;
 
                Assert.Equal(0, SendSignal(process.Id, SigTerm));
                await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
 
                var standardOutput = await standardOutputTask;
                var standardError = await standardErrorTask;
                Assert.True(
                    process.ExitCode == 0,
                    $"aspire-managed exited with code {process.ExitCode}.{Environment.NewLine}" +
                    $"stdout: {standardOutput}{Environment.NewLine}stderr: {standardError}");
                Assert.All(socketPaths, path => Assert.False(File.Exists(path), $"Expected '{path}' to be unlinked."));
            }
            finally
            {
                if (!process.HasExited)
                {
                    process.Kill(entireProcessTree: true);
                    await process.WaitForExitAsync();
                }
            }
        }
        finally
        {
            Directory.Delete(socketDirectory.FullName, recursive: true);
        }
    }
 
    [Fact]
    public async Task TerminalHostSubcommandStopsWhenOwningAppHostIsGone()
        => await AssertTerminalHostStopsWhenOwningAppHostIsGoneAsync(hostAssemblyPath: null);
 
    [Fact]
    public async Task StandaloneTerminalHostStopsWhenOwningAppHostIsGone()
        => await AssertTerminalHostStopsWhenOwningAppHostIsGoneAsync(
            typeof(TerminalHostProcessRunner).Assembly.Location);
 
    private static async Task AssertTerminalHostStopsWhenOwningAppHostIsGoneAsync(string? hostAssemblyPath)
    {
        var socketDirectory = Directory.CreateTempSubdirectory("ath-");
        try
        {
            var parentProducerPath = Path.Combine(socketDirectory.FullName, "pp.sock");
            var parentConsumerPath = Path.Combine(socketDirectory.FullName, "ph.sock");
            var parentControlPath = Path.Combine(socketDirectory.FullName, "pc.sock");
            var parentSocketPaths = new[] { parentProducerPath, parentConsumerPath, parentControlPath };
            var producerPath = Path.Combine(socketDirectory.FullName, "p.sock");
            var consumerPath = Path.Combine(socketDirectory.FullName, "h.sock");
            var controlPath = Path.Combine(socketDirectory.FullName, "c.sock");
            var socketPaths = new[] { producerPath, consumerPath, controlPath };
            using var parentProcess = Process.Start(
                CreateTerminalHostStartInfo(parentProducerPath, parentConsumerPath, parentControlPath))
                ?? throw new InvalidOperationException("Failed to start the parent process.");
            var parentStandardOutputTask = parentProcess.StandardOutput.ReadToEndAsync();
            var parentStandardErrorTask = parentProcess.StandardError.ReadToEndAsync();
 
            try
            {
                var parentReadyTask = WaitForFilesAsync(parentSocketPaths, TimeSpan.FromSeconds(10));
                var parentExitedTask = parentProcess.WaitForExitAsync();
                if (await Task.WhenAny(parentReadyTask, parentExitedTask) == parentExitedTask)
                {
                    Assert.Fail(
                        $"The parent terminal host exited before binding its sockets with code {parentProcess.ExitCode}.{Environment.NewLine}" +
                        await parentStandardErrorTask);
                }
 
                await parentReadyTask;
                var parentIdentity = ProcessStartTimeHelper.TryGetProcessStartTimeUnixMilliseconds(parentProcess.Id);
                Assert.NotNull(parentIdentity);
 
                var startInfo = CreateTerminalHostStartInfo(
                    producerPath,
                    consumerPath,
                    controlPath,
                    hostAssemblyPath);
                startInfo.Environment[KnownConfigNames.TerminalHostParentProcessId] =
                    parentProcess.Id.ToString(CultureInfo.InvariantCulture);
                startInfo.Environment[KnownConfigNames.TerminalHostParentProcessStartedStable] =
                    parentIdentity.Value.ToString(CultureInfo.InvariantCulture);
 
                using var process = Process.Start(startInfo)
                    ?? throw new InvalidOperationException("Failed to start aspire-managed.");
                var standardOutputTask = process.StandardOutput.ReadToEndAsync();
                var standardErrorTask = process.StandardError.ReadToEndAsync();
 
                try
                {
                    var readyTask = WaitForFilesAsync(socketPaths, TimeSpan.FromSeconds(20));
                    var exitedTask = process.WaitForExitAsync();
                    if (await Task.WhenAny(readyTask, exitedTask) == exitedTask)
                    {
                        Assert.Fail(
                            $"The terminal host exited before binding its sockets with code {process.ExitCode}.{Environment.NewLine}" +
                            await standardErrorTask);
                    }
 
                    await readyTask;
 
                    parentProcess.Kill(entireProcessTree: true);
                    await parentProcess.WaitForExitAsync();
                    await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
 
                    var standardOutput = await standardOutputTask;
                    var standardError = await standardErrorTask;
                    Assert.True(
                        process.ExitCode == 0,
                        $"aspire-managed exited with code {process.ExitCode}.{Environment.NewLine}" +
                        $"stdout: {standardOutput}{Environment.NewLine}stderr: {standardError}");
                    Assert.All(socketPaths, path => Assert.False(File.Exists(path), $"Expected '{path}' to be unlinked."));
                }
                finally
                {
                    if (!process.HasExited)
                    {
                        process.Kill(entireProcessTree: true);
                        await process.WaitForExitAsync();
                    }
                }
            }
            finally
            {
                if (!parentProcess.HasExited)
                {
                    parentProcess.Kill(entireProcessTree: true);
                    await parentProcess.WaitForExitAsync();
                }
 
                await parentStandardOutputTask;
                await parentStandardErrorTask;
            }
        }
        finally
        {
            Directory.Delete(socketDirectory.FullName, recursive: true);
        }
    }
 
    private static ProcessStartInfo CreateTerminalHostStartInfo(
        string producerPath,
        string consumerPath,
        string controlPath,
        string? hostAssemblyPath = null)
    {
        var startInfo = new ProcessStartInfo("dotnet")
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
        };
        if (hostAssemblyPath is null)
        {
            startInfo.ArgumentList.Add(typeof(LayoutCommand).Assembly.Location);
            startInfo.ArgumentList.Add("terminalhost");
        }
        else
        {
            var testAssemblyPath = typeof(TerminalHostSignalTests).Assembly.Location;
            startInfo.ArgumentList.Add("exec");
            startInfo.ArgumentList.Add("--runtimeconfig");
            startInfo.ArgumentList.Add(Path.ChangeExtension(testAssemblyPath, ".runtimeconfig.json"));
            startInfo.ArgumentList.Add("--depsfile");
            startInfo.ArgumentList.Add(Path.ChangeExtension(testAssemblyPath, ".deps.json"));
            startInfo.ArgumentList.Add(hostAssemblyPath);
        }
        startInfo.ArgumentList.Add("--producer-uds");
        startInfo.ArgumentList.Add(producerPath);
        startInfo.ArgumentList.Add("--consumer-uds");
        startInfo.ArgumentList.Add(consumerPath);
        startInfo.ArgumentList.Add("--control-uds");
        startInfo.ArgumentList.Add(controlPath);
        startInfo.Environment.Remove(KnownConfigNames.TerminalHostParentProcessId);
        startInfo.Environment.Remove(KnownConfigNames.TerminalHostParentProcessStartedStable);
        return startInfo;
    }
 
    private static async Task WaitForFilesAsync(IEnumerable<string> paths, TimeSpan timeout)
    {
        var expectedPaths = paths.ToArray();
        var deadline = DateTime.UtcNow + timeout;
        while (expectedPaths.Any(path => !File.Exists(path)))
        {
            if (DateTime.UtcNow >= deadline)
            {
                throw new TimeoutException($"Timed out waiting for: {string.Join(", ", expectedPaths)}");
            }
 
            await Task.Delay(50);
        }
    }
 
    [LibraryImport("libc", EntryPoint = "kill", SetLastError = true)]
    private static partial int SendSignal(int processId, int signal);
}