File: Projects\AppHostServerSessionTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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 Aspire.Cli.Bundles;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Layout;
using Aspire.Cli.NuGet;
using Aspire.Cli.Processes;
using Aspire.Cli.Projects;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Aspire.Tests;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Projects;
 
public class AppHostServerSessionTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task Start_DoesNotMutateCallerEnvironmentVariables()
    {
        var project = new RecordingAppHostServerProject();
        var environmentVariables = new Dictionary<string, string>
        {
            ["EXISTING_VALUE"] = "present"
        };
 
        await using var session = CreateSession(
            project,
            CancellationToken.None,
            environmentVariables: environmentVariables);
        await session.StartAsync();
 
        Assert.Equal("present", environmentVariables["EXISTING_VALUE"]);
        Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken));
 
        Assert.NotNull(project.ReceivedEnvironmentVariables);
        Assert.Equal("present", project.ReceivedEnvironmentVariables["EXISTING_VALUE"]);
        Assert.Equal(session.AuthenticationToken, project.ReceivedEnvironmentVariables[KnownConfigNames.RemoteAppHostToken]);
    }
 
    [Fact]
    public async Task Start_PropagatesProfilingContextToServerEnvironment()
    {
        var project = new RecordingAppHostServerProject();
        var environmentVariables = new Dictionary<string, string>
        {
            ["EXISTING_VALUE"] = "present"
        };
        using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration(
            (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"),
            (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1")));
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource);
 
        await using var session = CreateSession(
            project,
            CancellationToken.None,
            environmentVariables: environmentVariables,
            profilingTelemetry: profilingTelemetry);
        await session.StartAsync();
 
        Assert.Equal("present", environmentVariables["EXISTING_VALUE"]);
        Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken));
        Assert.False(environmentVariables.ContainsKey(ProfilingTelemetry.EnvironmentVariables.Enabled));
 
        var receivedEnvironmentVariables = Assert.IsType<Dictionary<string, string>>(project.ReceivedEnvironmentVariables);
        Assert.Equal("present", receivedEnvironmentVariables["EXISTING_VALUE"]);
        Assert.Equal(session.AuthenticationToken, receivedEnvironmentVariables[KnownConfigNames.RemoteAppHostToken]);
        Assert.Equal("true", receivedEnvironmentVariables[ProfilingTelemetry.EnvironmentVariables.Enabled]);
        Assert.Equal("true", receivedEnvironmentVariables[KnownConfigNames.Legacy.StartupProfilingEnabled]);
        Assert.Equal("session-1", receivedEnvironmentVariables[ProfilingTelemetry.EnvironmentVariables.SessionId]);
        Assert.Equal("session-1", receivedEnvironmentVariables[KnownConfigNames.Legacy.StartupOperationId]);
        Assert.StartsWith("00-", receivedEnvironmentVariables[ProfilingTelemetry.EnvironmentVariables.TraceParent], StringComparison.Ordinal);
        Assert.Equal(
            receivedEnvironmentVariables[ProfilingTelemetry.EnvironmentVariables.TraceParent],
            receivedEnvironmentVariables[KnownConfigNames.Legacy.StartupTraceParent]);
    }
 
    [Fact]
    public async Task Start_DoesNotLeaveServerProcessActivityAmbient()
    {
        var project = new RecordingAppHostServerProject();
        using var parentSource = new ActivitySource("test-apphost-server-parent");
        using var parentListener = ActivityListenerHelper.Create(parentSource);
        using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration(
            (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"),
            (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1")));
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource);
 
        using var parentActivity = parentSource.StartActivity("aspire/cli/run");
        Assert.NotNull(parentActivity);
 
        await using var session = CreateSession(
            project,
            CancellationToken.None,
            profilingTelemetry: profilingTelemetry);
        await session.StartAsync();
 
        Assert.Same(parentActivity, Activity.Current);
 
        var receivedEnvironmentVariables = Assert.IsType<Dictionary<string, string>>(project.ReceivedEnvironmentVariables);
        Assert.NotEqual(parentActivity.Id, receivedEnvironmentVariables[ProfilingTelemetry.EnvironmentVariables.TraceParent]);
    }
 
    [Fact]
    public void AuthenticationToken_IsAvailableBeforeStart()
    {
        var project = new RecordingAppHostServerProject();
        var session = CreateSession(
            project,
            CancellationToken.None);
 
        Assert.False(string.IsNullOrEmpty(session.AuthenticationToken));
    }
 
    [Fact]
    public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_FailsWithoutWaitingForConnectionTimeout()
    {
        // RecordingAppHostServerProject spawns `dotnet --version`, which exits almost immediately
        // with exit code 0 against a socket path ("test.sock") that never hosts an RPC server.
        // The connection race in GetRpcClientAsync should observe the server-exit signal and fail
        // fast rather than burning the full connection-retry timeout.
        var project = new RecordingAppHostServerProject();
 
        await using var session = CreateSession(
            project,
            CancellationToken.None);
        await session.StartAsync();
 
        // Wait for the process to exit so the stopwatch measures only the early-exit detection
        // latency, not the variable execution time of "dotnet --version" on loaded CI machines.
        // Poll the OS by pid rather than awaiting the execution's WaitForExitAsync, which the
        // session's own drive loop is already awaiting on the same execution instance.
        Assert.True(
            WaitForProcessExit(project.StartedExecution!.ProcessId, TimeSpan.FromSeconds(30)),
            "Expected the server probe process to exit.");
 
        var stopwatch = Stopwatch.StartNew();
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => session.GetRpcClientAsync(TestContext.Current.CancellationToken)).DefaultTimeout();
        stopwatch.Stop();
 
        Assert.Equal("AppHost server process exited before the RPC connection could be established. Exit code: 0.", exception.Message);
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5),
            $"Expected RPC connection to fail promptly after the server process exited, but it took {stopwatch.Elapsed}.");
    }
 
    [Fact]
    public void SessionState_BeforeStart_IsNull()
    {
        var project = new RecordingAppHostServerProject();
        var session = CreateSession(
            project,
            CancellationToken.None);
 
        Assert.Null(session.SocketPath);
        Assert.Null(session.Output);
        Assert.Null(session.ServerProcessId);
    }
 
    [Fact]
    public async Task Start_CalledTwice_Throws()
    {
        var project = new RecordingAppHostServerProject();
        await using var session = CreateSession(
            project,
            CancellationToken.None);
 
        await session.StartAsync();
 
        await Assert.ThrowsAsync<InvalidOperationException>(session.StartAsync);
    }
 
    [Fact]
    public async Task Start_StopRequested_KillsProcessAndCompletesTask()
    {
        var project = new LongRunningAppHostServerProject();
        using var stopCts = new CancellationTokenSource();
        await using var session = CreateSession(
            project,
            stopCts.Token);
 
        await session.StartAsync();
        var completion = session.WaitForExitAsync();
 
        // Process should be running before we ask the session to stop.
        Assert.False(completion.IsCompleted);
        Assert.False(session.HasServerExited);
 
        stopCts.Cancel();
 
        // The session's stop registration fires Kill synchronously inline. The Exited
        // event fires asynchronously, so allow the completion task to observe the result.
        var exitCode = await completion.WaitAsync(TimeSpan.FromSeconds(30));
        Assert.True(session.HasServerExited);
        Assert.Equal(session.TryGetServerExitCode(), exitCode);
    }
 
    [Fact]
    public async Task Start_StopRequested_WithGracefulServices_InvokesGracefulSignalerAndExits()
    {
        // External-stop flow with graceful infra wired: the session's ladder must invoke the
        // injected graceful signaler before falling through to wait-for-exit. Simulating "graceful
        // succeeded" by having the fake signaler kill the process keeps the ladder out of the
        // escalation branch and verifies the happy path end-to-end.
        var project = new LongRunningAppHostServerProject();
        using var stopCts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
 
        // Model the run path: graceful shutdown is enabled so the session routes through the ladder.
 
        var signaler = new RecordingGracefulSignaler(onSignal: pid =>
        {
            try
            {
                using var proc = Process.GetProcessById(pid);
                proc.Kill(entireProcessTree: true);
            }
            catch (ArgumentException)
            {
                // Already exited; treat as graceful success.
            }
            return Task.FromResult(true);
        });
 
        await using var session = CreateSession(
            project,
            stopCts.Token,
            gracefulShutdownSignaler: signaler,
            shutdownService: shutdownService);
 
        await session.StartAsync();
        var completion = session.WaitForExitAsync();
        Assert.False(completion.IsCompleted);
        var serverPid = session.ServerProcessId!.Value;
 
        stopCts.Cancel();
 
        await completion.WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.True(session.HasServerExited);
        Assert.Contains(serverPid, signaler.Pids);
        // Graceful budget was never exhausted in this scenario — the signaler simulated success
        // and WaitForExitAsync observed the exit before anyone called Expire().
        Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested);
    }
 
    [Fact]
    public async Task Start_StopRequested_GracefulIgnored_ExpireEscalatesToTreeKill()
    {
        // External stop fires, the fake signaler accepts the request but the process refuses to
        // exit (simulating a child that ignores SIGTERM/Ctrl+C). Expiring the central token must
        // make the ladder break out of WaitForExitAsync and escalate to Kill so the process tree
        // is guaranteed to die — covering the "DCP signal didn't take" failure mode.
        var project = new LongRunningAppHostServerProject();
        using var stopCts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
 
        // Model the run path: graceful shutdown is enabled so the session routes through the ladder.
        // Escalation is driven by the explicit Expire() below, not by the budget elapsing.
 
        var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var signaler = new RecordingGracefulSignaler(onSignal: _ =>
        {
            signaled.TrySetResult();
            // Pretend the OS signal was issued but the process is a bad citizen and ignores it.
            return Task.FromResult(true);
        });
 
        await using var session = CreateSession(
            project,
            stopCts.Token,
            gracefulShutdownSignaler: signaler,
            shutdownService: shutdownService);
 
        await session.StartAsync();
        var completion = session.WaitForExitAsync();
        Assert.False(completion.IsCompleted);
 
        stopCts.Cancel();
 
        // Wait until the ladder has actually called the signaler; otherwise Expire() could fire
        // before the ladder reaches WaitForExitAsync and the cancellation observation race would
        // get tangled with the signaler-cancellation race.
        await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10));
 
        shutdownService.Expire();
 
        await completion.WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.True(session.HasServerExited);
    }
 
    [Fact]
    public async Task Start_StopRequested_GracefulSignalerThrows_StillEscalatesToKill()
    {
        // Best-effort contract: a thrown exception from the signaler (e.g. DCP layout discovery
        // failed, network blip, anything) must not strand the kill ladder. The ladder logs and
        // falls through to wait+escalate. Expiring the token triggers the kill exactly like the
        // "signal succeeded but process ignored it" case above.
        var project = new LongRunningAppHostServerProject();
        using var stopCts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow();
 
        // Model the run path: graceful shutdown is enabled so the session routes through the ladder.
 
        var signaler = new RecordingGracefulSignaler(onSignal: _ =>
            throw new InvalidOperationException("simulated DCP failure"));
 
        await using var session = CreateSession(
            project,
            stopCts.Token,
            gracefulShutdownSignaler: signaler,
            shutdownService: shutdownService);
 
        await session.StartAsync();
        var completion = session.WaitForExitAsync();
        Assert.False(completion.IsCompleted);
 
        stopCts.Cancel();
        shutdownService.Expire();
 
        await completion.WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.True(session.HasServerExited);
        Assert.Single(signaler.Pids);
    }
 
    [Fact]
    public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithoutSignaling()
    {
        // A graceful window with IsEnabled == false models a non-run command. The coordinator's
        // graceful-vs-force decision is all-or-nothing per command and keys off IsEnabled, so
        // dispose-only teardown here must take the force-kill path and never invoke the signaler.
        // (On the run path IsEnabled is true, so completion routes through the bounded ladder instead —
        // that bound comes from the central graceful clock, not from a per-session external-stop flag.)
        var project = new LongRunningAppHostServerProject();
        using var stopCts = new CancellationTokenSource();
        using var shutdownService = new TestGracefulShutdownWindow { IsEnabled = false };
        var signaler = new RecordingGracefulSignaler();
 
        var session = CreateSession(
            project,
            stopCts.Token,
            gracefulShutdownSignaler: signaler,
            shutdownService: shutdownService);
 
        await session.StartAsync();
        var completion = session.WaitForExitAsync();
        Assert.False(completion.IsCompleted);
        var pid = session.ServerProcessId!.Value;
        Assert.False(session.HasServerExited);
 
        // DisposeAsync must return promptly even though the graceful token will never fire and
        // the process would otherwise run for a minute. The 30 s timeout is the regression check:
        // if the dispose-only safety net regresses and the ladder takes the graceful path,
        // WaitForExitAsync(gracefulToken) will hang and this WaitAsync will throw.
        await session.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(30));
 
        // After DisposeAsync, the Process handle has been disposed, so reading HasExited on the
        // original reference would throw. Probe the OS instead: a force-killed process is reaped
        // fast on all platforms — within ~tens of ms — but allow a few seconds of slack for
        // loaded CI hosts before failing.
        Assert.True(WaitForProcessExit(pid, TimeSpan.FromSeconds(5)), $"Process {pid} should be exited after DisposeAsync.");
        Assert.Empty(signaler.Pids);
    }
 
    [Fact]
    public async Task Start_ProcessExitsNaturally_CompletionReturnsExitCode()
    {
        var project = new RecordingAppHostServerProject();
        await using var session = CreateSession(
            project,
            CancellationToken.None);
 
        await session.StartAsync();
        var exitCode = await session.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task Start_ProjectRunThrows_DisposesProjectAndFaultsCompletion()
    {
        var project = new ThrowingAppHostServerProject();
 
        // Capture unobserved task exceptions only for the sentinel message we throw, so the test
        // doesn't false-positive on faulted tasks orphaned by other concurrently running xUnit
        // tests sharing the process.
        var unobserved = new List<Exception>();
        void Handler(object? sender, UnobservedTaskExceptionEventArgs e)
        {
            if (e.Exception.InnerExceptions.Any(static ex => ex is InvalidOperationException { Message: "simulated launch failure" }))
            {
                unobserved.Add(e.Exception);
                e.SetObserved();
            }
        }
 
        TaskScheduler.UnobservedTaskException += Handler;
        try
        {
            // Run the session in a separate async method so the session local is unreachable
            // by the time we force GC — otherwise the stack-rooted reference keeps the faulted
            // completion task alive and UnobservedTaskException never fires.
            await RunScenarioAsync(project);
 
            // DisposeAsync must observe the faulted completion task created by Start's catch
            // path. Otherwise the orphaned faulted task surfaces as an UnobservedTaskException once
            // GC reaps the TaskCompletionSource — silent corruption for short-lived RPC callers
            // that intentionally never call WaitForExitAsync.
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
        finally
        {
            TaskScheduler.UnobservedTaskException -= Handler;
        }
 
        Assert.True(project.Disposed);
        Assert.Empty(unobserved);
 
        static async Task RunScenarioAsync(ThrowingAppHostServerProject project)
        {
            var session = CreateSession(
                project,
                CancellationToken.None);
 
            await Assert.ThrowsAsync<InvalidOperationException>(session.StartAsync);
 
            await session.DisposeAsync();
        }
    }
 
    [Fact]
    public void CreatePrebuiltAppHostServer_DisposesLayoutLeaseWhenConstructorFails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appPath = workspace.CreateDirectory("apphost").FullName;
        var integrationCachePathBlockedByFile = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations");
        File.WriteAllText(integrationCachePathBlockedByFile, string.Empty);
 
        var versionDirectory = workspace.CreateDirectory("version");
        var versionLease = BundleVersionLease.Acquire(versionDirectory.FullName, "test", "apphost-server");
        var layoutLease = new BundleLayoutLease(
            new LayoutConfiguration(),
            versionLease);
        var factory = CreateAppHostServerProjectFactory();
 
        Assert.True(BundleVersionLease.HasActiveLease(versionDirectory.FullName));
 
        try
        {
            Assert.ThrowsAny<IOException>(() => factory.CreatePrebuiltAppHostServer(
                appPath,
                "test.sock",
                new LayoutConfiguration(),
                layoutLease));
 
            Assert.False(BundleVersionLease.HasActiveLease(versionDirectory.FullName));
        }
        finally
        {
            layoutLease.Dispose();
        }
    }
 
    private static IProcessExecution CreateServerExecution(ProcessStartInfo startInfo, AppHostServerRunControl? runControl)
    {
        // Build a real execution through the production factory so the test exercises the unified
        // IProcessExecution shutdown ladder rather than a bespoke fake. The options mirror what
        // DotNetBasedAppHostServerProject.Run wires from the run control.
        var options = new ProcessInvocationOptions
        {
            GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler,
            ShutdownService = runControl?.ShutdownService,
            // Matches production: the graceful ladder always tree-kills on escalation; this fallback
            // only governs the no-graceful-services path, where Unix force-kills the tree.
            KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(),
        };
 
        return new ProcessExecutionFactory(new TestEnvironment(), NullLogger<ProcessExecutionFactory>.Instance)
            .CreateExecution(startInfo, options);
    }
 
    private static bool WaitForProcessExit(int pid, TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (DateTime.UtcNow < deadline)
        {
            try
            {
                using var probe = Process.GetProcessById(pid);
                if (probe.HasExited)
                {
                    return true;
                }
            }
            catch (ArgumentException)
            {
                // GetProcessById throws when no process with the given id is running — i.e. the
                // process exited and was reaped from the OS table. That is exactly the success
                // signal we are waiting for.
                return true;
            }
            catch (InvalidOperationException)
            {
                // HasExited can throw if the handle was opened against a process that died and
                // was reaped between the GetProcessById call and the property read. Treat the
                // same as the ArgumentException case.
                return true;
            }
 
            Thread.Sleep(25);
        }
 
        return false;
    }
 
    private static IConfiguration CreateConfiguration(params (string Key, string? Value)[] values)
    {
        return new ConfigurationBuilder()
            .AddInMemoryCollection(values.Select(value => new KeyValuePair<string, string?>(value.Key, value.Value)))
            .Build();
    }
 
    // Constructs a session with the test-default wiring so call sites stay focused on the one or two
    // arguments each scenario actually cares about. The production constructor is intentionally strict
    // (every parameter required); this helper centralizes the defaults that virtually every test shares:
    // a null logger, no profiling, and the codegen-style "no graceful shutdown / no console isolation"
    // configuration. Scenarios that exercise the graceful ladder opt in via the optional parameters.
    private static AppHostServerSession CreateSession(
        IAppHostServerProject project,
        CancellationToken stopRequested,
        Dictionary<string, string>? environmentVariables = null,
        bool debug = false,
        ProfilingTelemetry? profilingTelemetry = null,
        IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler = null,
        IGracefulShutdownWindow? shutdownService = null,
        bool isolateConsole = false) =>
        new(
            project,
            environmentVariables,
            debug,
            new TestEnvironment(),
            NullLogger<AppHostServerSession>.Instance,
            profilingTelemetry,
            gracefulShutdownSignaler,
            shutdownService,
            isolateConsole,
            stopRequested);
 
    private static AppHostServerProjectFactory CreateAppHostServerProjectFactory()
    {
        var executionContext = TestExecutionContextFactory.CreateTestContext();
        var nugetService = new BundleNuGetService(
            new NullLayoutDiscovery(),
            new LayoutProcessRunner(new TestProcessExecutionFactory()),
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<BundleNuGetService>.Instance);
 
        return new AppHostServerProjectFactory(
            new TestDotNetCliRunner(),
            MockPackagingServiceFactory.Create(),
            new NullBundleService(),
            nugetService,
            new TestDotNetSdkInstaller(),
            executionContext,
            new TestEnvironment(),
            new TestProcessExecutionFactory(),
            NullLoggerFactory.Instance);
    }
 
    private sealed class RecordingAppHostServerProject : IAppHostServerProject
    {
        public string AppDirectoryPath => Directory.GetCurrentDirectory();
 
        public Dictionary<string, string>? ReceivedEnvironmentVariables { get; private set; }
 
        public IProcessExecution? StartedExecution { get; private set; }
 
        public string GetInstanceIdentifier() => AppDirectoryPath;
 
        public Task<AppHostServerPrepareResult> PrepareAsync(
            string sdkVersion,
            IEnumerable<IntegrationReference> integrations,
            string? requestedChannel = null,
            string? packageSourceOverride = null,
            CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
 
        public async Task<AppHostServerRunResult> RunAsync(
            int hostPid,
            IReadOnlyDictionary<string, string>? environmentVariables = null,
            string[]? additionalArgs = null,
            bool debug = false,
            AppHostServerRunControl? runControl = null)
        {
            ReceivedEnvironmentVariables = environmentVariables is null
                ? null
                : new Dictionary<string, string>(environmentVariables);
 
            var startInfo = new ProcessStartInfo("dotnet")
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            };
            startInfo.ArgumentList.Add("--version");
 
            var execution = CreateServerExecution(startInfo, runControl);
            await execution.StartAsync(CancellationToken.None);
 
            StartedExecution = execution;
            return new AppHostServerRunResult(
                SocketPath: "test.sock",
                OutputCollector: new OutputCollector(),
                Execution: execution);
        }
    }
 
    private sealed class LongRunningAppHostServerProject : IAppHostServerProject
    {
        public string AppDirectoryPath => Directory.GetCurrentDirectory();
 
        public string GetInstanceIdentifier() => AppDirectoryPath;
 
        public Task<AppHostServerPrepareResult> PrepareAsync(
            string sdkVersion,
            IEnumerable<IntegrationReference> integrations,
            string? requestedChannel = null,
            string? packageSourceOverride = null,
            CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
 
        public async Task<AppHostServerRunResult> RunAsync(
            int hostPid,
            IReadOnlyDictionary<string, string>? environmentVariables = null,
            string[]? additionalArgs = null,
            bool debug = false,
            AppHostServerRunControl? runControl = null)
        {
            // Use a cross-platform long-running command so the test exercises the kill path
            // rather than a quickly-exiting probe like `dotnet --version`.
            var (fileName, arguments) = OperatingSystem.IsWindows()
                ? ("cmd.exe", new[] { "/c", "pause" })
                : ("sleep", new[] { "60" });
 
            var startInfo = new ProcessStartInfo(fileName)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            };
            foreach (var arg in arguments)
            {
                startInfo.ArgumentList.Add(arg);
            }
 
            var execution = CreateServerExecution(startInfo, runControl);
            await execution.StartAsync(CancellationToken.None);
 
            return new AppHostServerRunResult(
                SocketPath: "test.sock",
                OutputCollector: new OutputCollector(),
                Execution: execution);
        }
    }
 
    private sealed class ThrowingAppHostServerProject : IAppHostServerProject, IDisposable
    {
        public string AppDirectoryPath => Directory.GetCurrentDirectory();
 
        public bool Disposed { get; private set; }
 
        public string GetInstanceIdentifier() => AppDirectoryPath;
 
        public Task<AppHostServerPrepareResult> PrepareAsync(
            string sdkVersion,
            IEnumerable<IntegrationReference> integrations,
            string? requestedChannel = null,
            string? packageSourceOverride = null,
            CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
 
        public Task<AppHostServerRunResult> RunAsync(
            int hostPid,
            IReadOnlyDictionary<string, string>? environmentVariables = null,
            string[]? additionalArgs = null,
            bool debug = false,
            AppHostServerRunControl? runControl = null) =>
            throw new InvalidOperationException("simulated launch failure");
 
        public void Dispose() => Disposed = true;
    }
}