File: src\Aspire.Tray\Mac\MacDetachedProcess.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.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;
 
namespace Aspire.Tray;
 
[SupportedOSPlatform("macos")]
internal sealed partial class MacDetachedProcess
{
    private int _processId;
 
    private MacDetachedProcess(int processId) => _processId = processId;
 
    internal int ProcessId => _processId;
 
    internal async Task CompleteStartupAsync(Func<Task> ready)
    {
        try
        {
            await ready().ConfigureAwait(false);
        }
        catch (Exception startupError)
        {
            try
            {
                await TerminateAndWaitAsync().ConfigureAwait(false);
            }
            catch (Exception cleanupError)
            {
                throw new AggregateException("Tray startup failed and the child could not be cleaned up.", startupError, cleanupError);
            }
            throw;
        }
        Detach();
    }
 
    public static MacDetachedProcess Start(ProcessStartInfo startInfo, SafeFileHandle log)
    {
        Check(InitializeActions(out var actions), "initialize tray file actions");
        try
        {
            Check(InitializeAttributes(out var attributes), "initialize tray launch attributes");
            try
            {
                // SETSID disconnects the GUI from the terminal's process group/session.
                // CLOEXEC_DEFAULT permits only explicitly duplicated stdio descriptors.
                // https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/spawn.h
                Check(SetFlags(ref attributes, 0x0400 | 0x4000 | 0x0008), "detach the tray");
                uint signalMask = 0;
                Check(SetSignalMask(ref attributes, ref signalMask), "reset the tray signal mask");
                var addedReference = false;
                try
                {
                    log.DangerousAddRef(ref addedReference);
                    var descriptor = log.DangerousGetHandle().ToInt32();
                    Check(AddDuplicate(ref actions, descriptor, 1), "redirect tray output");
                    Check(AddDuplicate(ref actions, descriptor, 2), "redirect tray errors");
                    // If the caller closed stdin, opening the log may have reused fd 0.
                    // Copy it to stdout/stderr before replacing the child's stdin.
                    Check(AddOpen(ref actions, 0, "/dev/null", 0, 0), "redirect tray input");
                    Check(AddWorkingDirectory(ref actions, startInfo.WorkingDirectory), "set the tray working directory");
                    using var arguments = new NativeStrings([startInfo.FileName, .. startInfo.ArgumentList]);
                    using var environment = new NativeStrings(startInfo.Environment.Select(pair => $"{pair.Key}={pair.Value}"));
                    Check(Spawn(out var processId, startInfo.FileName, ref actions, ref attributes,
                        arguments.Values, environment.Values), "launch the tray");
                    return new(processId);
                }
                finally
                {
                    if (addedReference)
                    {
                        log.DangerousRelease();
                    }
                }
            }
            finally
            {
                Check(DestroyAttributes(ref attributes), "release tray launch attributes");
            }
        }
        finally
        {
            Check(DestroyActions(ref actions), "release tray file actions");
        }
    }
 
    public async Task TerminateAndWaitAsync()
    {
        if (_processId == 0)
        {
            return;
        }
        // This child is deliberately not reaped before kill. Even if it exits during
        // startup, its PID cannot be reused for another process until our waitpid.
        if (Kill(_processId, 9) != 0 && Marshal.GetLastPInvokeError() != 3)
        {
            throw new Win32Exception(Marshal.GetLastPInvokeError(), "Could not terminate the failed tray launch.");
        }
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            await WaitForExitAsync(timeout.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (timeout.IsCancellationRequested)
        {
            throw new TimeoutException("The failed tray launch did not exit after termination.");
        }
    }
 
    internal async Task WaitForExitAsync(CancellationToken cancellationToken)
    {
        while (_processId != 0)
        {
            var result = WaitPid(_processId, out _, 1);
            if (result == _processId)
            {
                _processId = 0;
                return;
            }
            if (result < 0 && Marshal.GetLastPInvokeError() != 4)
            {
                throw new Win32Exception(Marshal.GetLastPInvokeError(), "Could not wait for the tray launch to exit.");
            }
            await Task.Delay(20, cancellationToken).ConfigureAwait(false);
        }
    }
 
    private void Detach()
    {
        // The launcher normally exits immediately; if hosted longer, reap the child
        // without keeping the process alive or retaining any inherited output pipe.
        _ = ReapAsync();
    }
 
    private async Task ReapAsync()
    {
        try
        {
            await WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
        }
        catch (Win32Exception ex)
        {
            Console.Error.WriteLine($"Could not reap the detached tray process: {ex.Message}");
        }
    }
 
    private static void Check(int error, string operation)
    {
        if (error != 0)
        {
            throw new Win32Exception(error, $"Could not {operation}.");
        }
    }
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawn", StringMarshalling = StringMarshalling.Utf8)]
    private static partial int Spawn(out int processId, string path, ref nint actions, ref nint attributes,
        nint[] arguments, nint[] environment);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawn_file_actions_init")]
    private static partial int InitializeActions(out nint actions);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawn_file_actions_destroy")]
    private static partial int DestroyActions(ref nint actions);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawnattr_init")]
    private static partial int InitializeAttributes(out nint attributes);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawnattr_destroy")]
    private static partial int DestroyAttributes(ref nint attributes);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawnattr_setflags")]
    private static partial int SetFlags(ref nint attributes, short flags);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawnattr_setsigmask")]
    private static partial int SetSignalMask(ref nint attributes, ref uint mask);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawn_file_actions_adddup2")]
    private static partial int AddDuplicate(ref nint actions, int descriptor, int destination);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawn_file_actions_addopen", StringMarshalling = StringMarshalling.Utf8)]
    private static partial int AddOpen(ref nint actions, int descriptor, string path, int flags, ushort mode);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "posix_spawn_file_actions_addchdir_np", StringMarshalling = StringMarshalling.Utf8)]
    private static partial int AddWorkingDirectory(ref nint actions, string path);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "kill", SetLastError = true)]
    private static partial int Kill(int processId, int signal);
 
    [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "waitpid", SetLastError = true)]
    private static partial int WaitPid(int processId, out int status, int options);
 
    private sealed class NativeStrings : IDisposable
    {
        public nint[] Values { get; }
 
        public NativeStrings(IEnumerable<string> values)
        {
            var strings = values.ToArray();
            Values = new nint[strings.Length + 1];
            try
            {
                for (var index = 0; index < strings.Length; index++)
                {
                    Values[index] = Marshal.StringToCoTaskMemUTF8(strings[index]);
                }
            }
            catch
            {
                Dispose();
                throw;
            }
        }
 
        public void Dispose()
        {
            foreach (var value in Values)
            {
                Marshal.FreeCoTaskMem(value);
            }
        }
    }
}