File: WindowsTrayLauncherTests.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.Buffers.Binary;
using System.ComponentModel;
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.Versioning;
using Aspire.Tray.Tests.Helpers;
using Microsoft.DotNet.RemoteExecutor;
 
namespace Aspire.Tray.Tests;
 
public class WindowsTrayLauncherTests
{
    public static bool SupportsWindows => OperatingSystem.IsWindows();
    public static bool SupportsWindowsDesktop => OperatingSystem.IsWindows() && TestWindowsProcessJob.HasDesktopShell;
 
    [Theory(Skip = "Requires a Windows desktop shell.", SkipUnless = nameof(SupportsWindowsDesktop))]
    [InlineData(0x1800u, false)]
    [InlineData(0x2000u, false)]
    [InlineData(0x2000u, true)]
    [SupportedOSPlatform("windows")]
    public async Task DetachedChildSurvivesCallerAndItsJobs(uint callerJobFlags, bool nestedBreakawayJob)
    {
        using var callerJob = new TestWindowsProcessJob(callerJobFlags);
        using var innerJob = nestedBreakawayJob ? new TestWindowsProcessJob(0x2800) : null;
        var pipeName = $"aspire-tray-launch-test-{Guid.NewGuid():N}";
        using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1,
            PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        var options = new RemoteInvokeOptions();
        options.StartInfo.CreateNoWindow = true;
        using var caller = RemoteExecutor.Invoke((Func<string, Task>)(async name =>
        {
            if (!OperatingSystem.IsWindows())
            {
                throw new PlatformNotSupportedException();
            }
            using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
            using var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut,
                PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
            await pipe.ConnectAsync(timeout.Token);
            var signal = new byte[1];
            await pipe.ReadExactlyAsync(signal, timeout.Token);
            var ping = Path.Combine(Environment.SystemDirectory, "ping.exe");
            using var child = WindowsTrayLauncher.LaunchDetached(ping, $"\"{ping}\" -n 120 127.0.0.1", Environment.SystemDirectory);
            await WindowsTrayLauncher.CompleteStartupAsync(child, async () =>
            {
                // Hold the exact native child handle until the test owns a process handle.
                // Failed IPC uses the production cleanup path, so no child is left behind.
                var identity = new byte[sizeof(int)];
                BinaryPrimitives.WriteInt32LittleEndian(identity, TestWindowsProcessJob.GetProcessId(child));
                await pipe.WriteAsync(identity, timeout.Token);
                await pipe.ReadExactlyAsync(signal, timeout.Token);
            });
        }), pipeName, options);
        Process? child = null;
        try
        {
            await pipe.WaitForConnectionAsync(timeout.Token);
            callerJob.Assign(caller.Process.SafeHandle);
            innerJob?.Assign(caller.Process.SafeHandle);
            Assert.True(callerJob.Contains(caller.Process.SafeHandle));
            await pipe.WriteAsync(new byte[] { 1 }, timeout.Token);
 
            var identity = new byte[sizeof(int)];
            await pipe.ReadExactlyAsync(identity, timeout.Token);
            child = Process.GetProcessById(BinaryPrimitives.ReadInt32LittleEndian(identity));
            var childHandle = child.SafeHandle;
            await pipe.WriteAsync(new byte[] { 1 }, timeout.Token);
            await caller.Process.WaitForExitAsync(timeout.Token);
 
            Assert.False(callerJob.Contains(childHandle));
            if (innerJob is not null)
            {
                Assert.False(innerJob.Contains(childHandle));
            }
            innerJob?.Dispose();
            callerJob.Dispose();
            Assert.False(child.HasExited);
        }
        finally
        {
            if (child is not null)
            {
                using (child)
                {
                    await CliProcess.TerminateOwnedChildAsync(child);
                }
            }
        }
    }
 
    [Fact(Skip = "Requires a Windows desktop shell.", SkipUnless = nameof(SupportsWindowsDesktop))]
    [SupportedOSPlatform("windows")]
    public void MissingExecutablePreservesNativeLaunchError()
    {
        var executable = Path.Combine(Environment.SystemDirectory, $"aspire-tray-missing-{Guid.NewGuid():N}.exe");
 
        var error = Assert.Throws<Win32Exception>(() =>
            WindowsTrayLauncher.LaunchDetached(executable, $"\"{executable}\"", Environment.SystemDirectory));
 
        Assert.Equal(2, error.NativeErrorCode);
    }
 
    [Theory(Skip = "Uses Windows process handles.", SkipUnless = nameof(SupportsWindows))]
    [InlineData(false)]
    [InlineData(true)]
    [SupportedOSPlatform("windows")]
    public async Task FailedReadinessWaitsForExactChildExitAndLeavesOtherProcessesRunning(bool rejected)
    {
        var start = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "ping.exe"))
        {
            UseShellExecute = false,
            CreateNoWindow = true,
            ArgumentList = { "-t", "127.0.0.1" }
        };
        using var unrelated = Process.Start(start)!;
        using var child = Process.Start(start)!;
        Exception failure = rejected ? new InvalidOperationException("rejected") : new TimeoutException("timed out");
        try
        {
            var actual = await Assert.ThrowsAsync(failure.GetType(),
                () => WindowsTrayLauncher.CompleteStartupAsync(child.SafeHandle, () => Task.FromException(failure)));
            Assert.Same(failure, actual);
            Assert.True(child.HasExited);
            Assert.False(unrelated.HasExited);
        }
        finally
        {
            await CliProcess.TerminateOwnedChildAsync(child);
            await CliProcess.TerminateOwnedChildAsync(unrelated);
        }
    }
 
    [Fact(Skip = "Uses Windows process handles.", SkipUnless = nameof(SupportsWindows))]
    [SupportedOSPlatform("windows")]
    public async Task AlreadyExitedChildPreservesTheReadinessFailure()
    {
        using var child = Process.Start(new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "cmd.exe"))
        {
            UseShellExecute = false,
            CreateNoWindow = true,
            ArgumentList = { "/d", "/c", "exit", "0" }
        })!;
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            // Acquire the handle before exit, just like the production CreateProcess path.
            var handle = child.SafeHandle;
            await child.WaitForExitAsync(timeout.Token);
            var failure = new IOException("disconnected");
            Assert.Same(failure, await Assert.ThrowsAsync<IOException>(() =>
                WindowsTrayLauncher.CompleteStartupAsync(handle, () => Task.FromException(failure))));
            Assert.True(child.HasExited);
        }
        finally
        {
            await CliProcess.TerminateOwnedChildAsync(child);
        }
    }
}