File: MacTrayLauncherTests.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.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.Versioning;
using Aspire.Tray.Tests.Helpers;
 
namespace Aspire.Tray.Tests;
 
public class MacTrayLauncherTests
{
    public static bool SupportsMac => OperatingSystem.IsMacOS();
 
    [Fact(Skip = "Uses Darwin file descriptors and spawn.", SkipUnless = nameof(SupportsMac))]
    [SupportedOSPlatform("macos")]
    public async Task DetachedChildAppendsToProtectedLogAndHasItsOwnProcessGroup()
    {
        using var directory = new TestMacTrayLogDirectory();
        Directory.CreateDirectory(Path.GetDirectoryName(directory.LogPath)!);
        await File.WriteAllTextAsync(directory.LogPath, "existing\n");
        using var log = MacTrayLog.Open(directory.LogPath);
        var start = CreateCommand(directory.Root, "printf 'stdout\\n'; printf 'stderr\\n' >&2; /bin/ps -o pgid= -p $$");
        var child = MacDetachedProcess.Start(start, log);
        var id = child.ProcessId;
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            await child.WaitForExitAsync(timeout.Token);
        }
        finally
        {
            await child.TerminateAndWaitAsync();
        }
 
        var lines = await File.ReadAllLinesAsync(directory.LogPath);
        Assert.Equal(4, lines.Length);
        Assert.Equal(["existing", "stdout", "stderr"], lines[..3]);
        Assert.Equal(id, int.Parse(lines[3], CultureInfo.InvariantCulture));
        Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(directory.LogPath));
        Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
            File.GetUnixFileMode(Path.GetDirectoryName(directory.LogPath)!));
    }
 
    [Theory(Skip = "Uses Darwin no-follow opens.", SkipUnless = nameof(SupportsMac))]
    [InlineData(false)]
    [InlineData(true)]
    [SupportedOSPlatform("macos")]
    public async Task ExistingFileAndParentSymlinksAreRejectedWithoutWritingTargets(bool parentLink)
    {
        using var directory = new TestMacTrayLogDirectory();
        var targetDirectory = Directory.CreateDirectory(Path.Combine(directory.Root, "target")).FullName;
        var target = Path.Combine(targetDirectory, "tray.log");
        await File.WriteAllTextAsync(target, "untouched");
        if (parentLink)
        {
            Directory.CreateSymbolicLink(Path.GetDirectoryName(directory.LogPath)!, targetDirectory);
        }
        else
        {
            Directory.CreateDirectory(Path.GetDirectoryName(directory.LogPath)!);
            File.CreateSymbolicLink(directory.LogPath, target);
        }
 
        Assert.Throws<IOException>(() => MacTrayLog.Open(directory.LogPath));
        Assert.Equal("untouched", await File.ReadAllTextAsync(target));
    }
 
    [Fact(Skip = "Uses Darwin no-follow opens.", SkipUnless = nameof(SupportsMac))]
    [SupportedOSPlatform("macos")]
    public async Task HardLinkedLogIsRejectedWithoutChangingTheTarget()
    {
        using var directory = new TestMacTrayLogDirectory();
        Directory.CreateDirectory(Path.GetDirectoryName(directory.LogPath)!);
        var target = Path.Combine(directory.Root, "target");
        await File.WriteAllTextAsync(target, "untouched");
        File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead);
        var result = await TestNativeCommand.RunAsync("/bin/ln", target, directory.LogPath);
        Assert.Equal(0, result.ExitCode);
 
        Assert.Throws<IOException>(() => MacTrayLog.Open(directory.LogPath));
        Assert.Equal("untouched", await File.ReadAllTextAsync(target));
        Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead, File.GetUnixFileMode(target));
    }
 
    [Fact(Skip = "Uses Darwin nonblocking opens.", SkipUnless = nameof(SupportsMac))]
    [SupportedOSPlatform("macos")]
    public async Task FifoLogIsRejectedWithoutWaitingForAReader()
    {
        using var directory = new TestMacTrayLogDirectory();
        Directory.CreateDirectory(Path.GetDirectoryName(directory.LogPath)!);
        var result = await TestNativeCommand.RunAsync("/usr/bin/mkfifo", directory.LogPath);
        Assert.Equal(0, result.ExitCode);
 
        Assert.Throws<IOException>(() => MacTrayLog.Open(directory.LogPath));
    }
 
    [Fact(Skip = "Uses Darwin descriptor inheritance.", SkipUnless = nameof(SupportsMac))]
    [SupportedOSPlatform("macos")]
    public async Task DetachedChildCannotInheritParentLeaseDescriptors()
    {
        using var directory = new TestMacTrayLogDirectory();
        var leasePath = Path.Combine(directory.Root, "parent.lease");
        var sentinel = Guid.NewGuid().ToString("N");
        await File.WriteAllTextAsync(leasePath, sentinel + "\n");
        using var lease = File.OpenHandle(leasePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
        using var inheritable = TestMacProcessInterop.DuplicateInheritable(lease);
        using var log = MacTrayLog.Open(directory.LogPath);
        var descriptor = inheritable.DangerousGetHandle().ToInt32();
        // Darwin /dev/fd entries have their own stat identity. Read a unique sentinel
        // instead, and prove the probe detects an open descriptor before testing spawn.
        var command = $"if {{ IFS= read -r value < /dev/fd/{descriptor}; }} 2>/dev/null && [ \"$value\" = \"$LEASE_SENTINEL\" ]; then printf inherited; else printf isolated; fi";
        var control = await TestNativeCommand.RunAsync("/bin/sh", "-c",
            $"LEASE_SENTINEL=$2; exec {descriptor}< \"$1\"; {command}", "--", leasePath, sentinel);
        Assert.Equal((0, "inherited", ""), control);
        var start = CreateCommand(directory.Root, command);
        start.Environment["LEASE_SENTINEL"] = sentinel;
        var child = MacDetachedProcess.Start(start, log);
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            await child.WaitForExitAsync(timeout.Token);
        }
        finally
        {
            await child.TerminateAndWaitAsync();
        }
        Assert.Equal("isolated", await File.ReadAllTextAsync(directory.LogPath));
    }
 
    [Theory(Skip = "Uses Darwin descriptor inheritance.", SkipUnless = nameof(SupportsMac))]
    [InlineData(false)]
    [InlineData(true)]
    [SupportedOSPlatform("macos")]
    public async Task ReplacingLogOrParentAfterOpenCannotRedirectChildOutput(bool replaceParent)
    {
        using var directory = new TestMacTrayLogDirectory();
        var targetDirectory = Directory.CreateDirectory(Path.Combine(directory.Root, "target")).FullName;
        var target = Path.Combine(targetDirectory, "tray.log");
        await File.WriteAllTextAsync(target, "untouched");
        using var log = MacTrayLog.Open(directory.LogPath);
        string retained;
        if (replaceParent)
        {
            var moved = Path.Combine(directory.Root, "retained");
            Directory.Move(Path.GetDirectoryName(directory.LogPath)!, moved);
            retained = Path.Combine(moved, "tray.log");
            Directory.CreateSymbolicLink(Path.GetDirectoryName(directory.LogPath)!, targetDirectory);
        }
        else
        {
            retained = Path.Combine(directory.Root, "retained.log");
            File.Move(directory.LogPath, retained);
            File.CreateSymbolicLink(directory.LogPath, target);
        }
 
        var child = MacDetachedProcess.Start(CreateCommand(directory.Root, "printf stdout; printf stderr >&2"), log);
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            await child.WaitForExitAsync(timeout.Token);
        }
        finally
        {
            await child.TerminateAndWaitAsync();
        }
        Assert.Equal("stdoutstderr", await File.ReadAllTextAsync(retained));
        Assert.Equal("untouched", await File.ReadAllTextAsync(target));
    }
 
    [Theory(Skip = "Uses Darwin child ownership.", SkipUnless = nameof(SupportsMac))]
    [InlineData(false)]
    [InlineData(true)]
    [SupportedOSPlatform("macos")]
    public async Task FailedReadinessTerminatesAndReapsOnlyTheExactChild(bool rejected)
    {
        using var directory = new TestMacTrayLogDirectory();
        using var log = MacTrayLog.Open(directory.LogPath);
        using var unrelated = Process.Start("/bin/sleep", "60")!;
        var child = MacDetachedProcess.Start(CreateCommand(directory.Root, "exec /bin/sleep 60"), log);
        Exception failure = rejected ? new InvalidOperationException("rejected") : new TimeoutException("timed out");
        try
        {
            Assert.Equal(child.ProcessId, TestMacProcessInterop.GetSessionId(child.ProcessId));
            var actual = await Assert.ThrowsAsync(failure.GetType(),
                () => child.CompleteStartupAsync(() => Task.FromException(failure)));
            Assert.Same(failure, actual);
            Assert.Equal(0, child.ProcessId);
            Assert.False(unrelated.HasExited);
        }
        finally
        {
            await child.TerminateAndWaitAsync();
            await CliProcess.TerminateOwnedChildAsync(unrelated);
        }
    }
 
    [Fact(Skip = "Uses Darwin child ownership.", SkipUnless = nameof(SupportsMac))]
    [SupportedOSPlatform("macos")]
    public async Task FailedReadinessCanReapAnAlreadyExitedChild()
    {
        using var directory = new TestMacTrayLogDirectory();
        using var log = MacTrayLog.Open(directory.LogPath);
        var child = MacDetachedProcess.Start(CreateCommand(directory.Root, "exit 0"), log);
        try
        {
            await Assert.ThrowsAsync<IOException>(() => child.CompleteStartupAsync(async () =>
            {
                // The callback models an IPC failure independent of whether exit preceded it.
                await Task.Yield();
                throw new IOException("disconnected");
            }));
            Assert.Equal(0, child.ProcessId);
        }
        finally
        {
            await child.TerminateAndWaitAsync();
        }
    }
 
    private static ProcessStartInfo CreateCommand(string workingDirectory, string command)
    {
        var start = new ProcessStartInfo("/bin/sh") { WorkingDirectory = workingDirectory };
        start.ArgumentList.Add("-c");
        start.ArgumentList.Add(command);
        return start;
    }
}