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;
 
namespace Aspire.Managed.Tests;
 
public partial class TerminalHostSignalTests
{
    private const int SigTerm = 15;
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task TerminalHostSubcommandUnlinksSocketsAfterTermination(bool forceTermination)
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "This test sends a Unix SIGTERM directly.");
 
        var root = Directory.CreateTempSubdirectory();
        try
        {
            var socketDirectory = new DirectoryInfo(Path.Combine(root.FullName, "terminals"));
            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}" +
                        $"stdout: {await standardOutputTask}{Environment.NewLine}stderr: {await standardErrorTask}");
                }
 
                await readyTask;
 
                if (forceTermination)
                {
                    process.Kill(entireProcessTree: false);
                }
                else
                {
                    Assert.Equal(0, SendSignal(process.Id, SigTerm));
                }
                await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
 
                var standardOutput = await standardOutputTask;
                var standardError = await standardErrorTask;
                Assert.True(
                    forceTermination || process.ExitCode == 0,
                    $"aspire-managed exited with code {process.ExitCode}.{Environment.NewLine}" +
                    $"stdout: {standardOutput}{Environment.NewLine}stderr: {standardError}");
                // A forcibly killed shim cannot wait for its child's parent watchdog.
                using var cleanupCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
                while (socketPaths.Any(File.Exists))
                {
                    await Task.Delay(50, cleanupCts.Token);
                }
            }
            finally
            {
                if (!process.HasExited)
                {
                    process.Kill(entireProcessTree: true);
                    await process.WaitForExitAsync();
                }
            }
        }
        finally
        {
            root.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task TerminalHostSubcommandStopsWhenOwningAppHostIsGone()
        => await AssertTerminalHostStopsWhenOwningAppHostIsGoneAsync(hostAssemblyPath: null);
 
    [Fact]
    public async Task TerminalHostControlShutdownUnlinksSockets()
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            var paths = new[] { "p.sock", "h.sock", "c.sock" }
                .Select(name => Path.Combine(root.FullName, "terminals", name)).ToArray();
            var startInfo = CreateTerminalHostStartInfo(paths[0], paths[1], paths[2]);
            using var process = Process.Start(startInfo)!;
            var stdout = process.StandardOutput.ReadToEndAsync();
            var stderr = process.StandardError.ReadToEndAsync();
            try
            {
                await WaitForFilesAsync(paths, TimeSpan.FromSeconds(10));
                using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
                await TerminalHostForwarder.RequestShutdownAsync(startInfo.ArgumentList.ToArray(), shutdownTimeout.Token);
                await process.WaitForExitAsync().WaitAsync(shutdownTimeout.Token);
                Assert.True(process.ExitCode == 0,
                    $"Terminal host exited with code {process.ExitCode}.{Environment.NewLine}stdout: {await stdout}{Environment.NewLine}stderr: {await stderr}");
                Assert.All(paths, path => Assert.False(File.Exists(path), $"Expected '{path}' to be unlinked."));
            }
            finally
            {
                if (!process.HasExited)
                {
                    process.Kill(entireProcessTree: true);
                    await process.WaitForExitAsync();
                }
            }
        }
        finally
        {
            root.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData(false, 64)]
    [InlineData(true, 1)]
    public async Task TerminalHostForwarderReportsMissingPayloadAndPropagatesExitStatus(bool missingPayload, int expectedExitCode)
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            var startInfo = CreateTerminalHostStartInfo(Path.Combine(root.FullName, "s", "p.sock"), "consumer", "control");
            startInfo.ArgumentList.Add("--invalid");
            if (missingPayload)
            {
                File.Delete(Path.Combine(root.FullName, "terminalhost", OperatingSystem.IsWindows() ? "Aspire.TerminalHost.exe" : "Aspire.TerminalHost"));
            }
 
            using var process = Process.Start(startInfo)!;
            var stdout = process.StandardOutput.ReadToEndAsync();
            var stderr = process.StandardError.ReadToEndAsync();
            await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
            Assert.Equal(expectedExitCode, process.ExitCode);
            Assert.Contains(missingPayload ? "Terminal host executable was not found" : "--invalid", await stderr);
            await stdout;
        }
        finally
        {
            root.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task StandaloneTerminalHostStopsWhenOwningAppHostIsGone()
        => await AssertTerminalHostStopsWhenOwningAppHostIsGoneAsync(
            "Aspire.TerminalHost.dll");
 
    private static async Task AssertTerminalHostStopsWhenOwningAppHostIsGoneAsync(string? hostAssemblyPath)
    {
        var root = Directory.CreateTempSubdirectory();
        try
        {
            var socketDirectory = new DirectoryInfo(Path.Combine(root.FullName, "terminals"));
            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}" +
                        $"stdout: {await parentStandardOutputTask}{Environment.NewLine}stderr: {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}" +
                            $"stdout: {await standardOutputTask}{Environment.NewLine}stderr: {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
        {
            root.Delete(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)
        {
            // Reproduce the shipped sibling layout without sharing mutable output between tests.
            var root = Path.GetDirectoryName(Path.GetDirectoryName(producerPath))!;
            var managed = Directory.CreateDirectory(Path.Combine(root, "managed"));
            var terminal = Directory.CreateDirectory(Path.Combine(root, "terminalhost"));
            foreach (var (fixture, directory) in new[] { ("terminalhost", terminal.FullName), ("managed", managed.FullName) })
            {
                var output = Path.Combine(AppContext.BaseDirectory, "ProcessFixtures", fixture);
                foreach (var file in Directory.EnumerateFiles(output, "*", SearchOption.AllDirectories))
                {
                    var target = Path.Combine(directory, Path.GetRelativePath(output, file));
                    Directory.CreateDirectory(Path.GetDirectoryName(target)!);
                    if (!File.Exists(target))
                    {
                        File.Copy(file, target);
                    }
                }
            }
            var managedAssembly = Path.Combine(managed.FullName, "aspire-managed.dll");
            startInfo.ArgumentList.Add(managedAssembly);
            startInfo.ArgumentList.Add("terminalhost");
        }
        else
        {
            var output = Path.Combine(AppContext.BaseDirectory, "ProcessFixtures", "terminalhost");
            startInfo.ArgumentList.Add(Path.Combine(output, Path.GetFileName(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);
}