File: Commands\AppHostLauncherTests.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 System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Layout;
using Aspire.Cli.Processes;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Tests;
using Aspire.Cli.Utils;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Telemetry;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests.Commands;
 
public class AppHostLauncherTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode()
    {
        var message = AppHostLauncher.GetDetachedFailureMessage(CliExitCodes.FailedToBuildArtifacts);
 
        Assert.Equal(RunCommandStrings.AppHostFailedToBuild, message);
    }
 
    [Fact]
    public void GetDetachedFailureMessage_ReturnsExitCodeMessage_ForUnknownExitCode()
    {
        var message = AppHostLauncher.GetDetachedFailureMessage(123);
 
        Assert.Contains("123", message, StringComparison.Ordinal);
    }
 
    [Theory]
    [InlineData(CliExitCodes.Success, true)]
    [InlineData(CliExitCodes.FailedToDotnetRunAppHost, false)]
    public void IsSuccessfulDetachedEarlyExit_OnlyTreatsZeroAsSuccess(int exitCode, bool expected)
    {
        var result = AppHostLauncher.IsSuccessfulDetachedEarlyExit(exitCode);
 
        Assert.Equal(expected, result);
    }
 
    [Fact]
    public void GenerateChildLogFilePath_UsesDetachChildNamingWithoutProcessId()
    {
        var logsDirectory = Path.Combine(Path.GetTempPath(), "aspire-cli-tests");
        var now = new DateTimeOffset(2026, 02, 12, 18, 00, 00, TimeSpan.Zero);
        var timeProvider = new FixedTimeProvider(now);
 
        var path = AppHostLauncher.GenerateChildLogFilePath(logsDirectory, timeProvider);
        var fileName = Path.GetFileName(path);
 
        Assert.StartsWith(logsDirectory, path, StringComparison.OrdinalIgnoreCase);
        Assert.Matches("^cli_20260212T180000000_detach-child_[0-9a-f]{32}\\.log$", fileName);
    }
 
    [Fact]
    public void ComputeDetachedMatchHashes_ResolvesSymlinkForPrimaryHash_AndKeepsRawHashInFallback()
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var homeDirectory = workspace.WorkspaceRoot.FullName;
 
        // Reference the same AppHost through a directory symlink ("link" -> "real"). This mirrors the
        // macOS temp-path shape (/var/folders/... is a symlink to /private/var/folders/...) that made
        // detached `aspire start` wait on a hash the AppHost never used.
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
        Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
 
        var realProjectPath = Path.Combine(realDirectory.FullName, "AppHost.csproj");
        File.WriteAllText(realProjectPath, "<Project />");
        var appHostPathViaSymlink = Path.Combine(symlinkDirectory, "AppHost.csproj");
 
        var resolvedPath = PathNormalizer.ResolveSymlinks(appHostPathViaSymlink);
        // The test is only meaningful when resolution actually rewrites the path.
        Assert.NotEqual(appHostPathViaSymlink, resolvedPath);
 
        var resolvedPathHash = AppHostHelper.ExtractHashFromSocketPath(
            AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedPath, homeDirectory))!;
        var rawPathHash = AppHostHelper.ExtractHashFromSocketPath(
            AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPathViaSymlink, homeDirectory))!;
        var rawFallbackHashes = AppHostHelper.ComputeLegacyHashes(appHostPathViaSymlink);
        var resolvedFallbackHashes = AppHostHelper.ComputeLegacyHashes(resolvedPath);
 
        var (expectedHash, fallbackHashes) = AppHostLauncher.ComputeDetachedMatchHashes(appHostPathViaSymlink, homeDirectory);
 
        // The primary hash is computed from the resolved path — the value the AppHost actually keys
        // its socket on — and therefore differs from the raw-path hash the buggy code waited on.
        Assert.Equal(resolvedPathHash, expectedHash);
        Assert.NotEqual(rawPathHash, expectedHash);
 
        // The fallback set keeps the raw path's compact hash so an AppHost still keyed on the
        // unresolved path continues to match, plus the legacy hex hashes of both paths.
        Assert.Contains(rawPathHash, fallbackHashes);
        Assert.Contains(rawFallbackHashes[0], fallbackHashes);
        Assert.Contains(resolvedFallbackHashes[0], fallbackHashes);
 
        // Cross-side agreement: the AppHost builds its socket file name with the same shared code,
        // keyed on the resolved path (AuxiliaryBackchannelService resolves symlinks before naming the
        // socket via ComputeSocketPath, which embeds ComputeAppHostId(resolvedPath)). The CLI's
        // primary hash must equal that embedded id so it waits on exactly the AppHost's socket.
        Assert.Equal(BackchannelConstants.ComputeAppHostId(resolvedPath), expectedHash);
    }
 
    [Fact]
    public async Task WaitForAppHostReadyAsync_ReturnsNullWhenReadinessIsUnavailable()
    {
        var connection = new TestAppHostAuxiliaryBackchannel();
 
        var ready = await AppHostLauncher.WaitForAppHostReadyAsync(connection, CancellationToken.None).DefaultTimeout();
 
        Assert.Null(ready);
    }
 
    [Fact]
    public async Task WaitForAppHostReadyAsync_PropagatesReadinessFailures()
    {
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            WaitForAppHostReadyHandler = _ => throw new IOException("connection lost")
        };
 
        var exception = await Assert.ThrowsAsync<IOException>(() => AppHostLauncher.WaitForAppHostReadyAsync(connection, CancellationToken.None)).DefaultTimeout();
        Assert.Equal("connection lost", exception.Message);
    }
 
    [Fact]
    public async Task WaitForLegacyDetachedStartupStabilityAsync_ReturnsFalseWhenChildExitsDuringStabilityWindow()
    {
        var stable = await AppHostLauncher.WaitForLegacyDetachedStartupStabilityAsync(
            new TestAppHostAuxiliaryBackchannel { SupportsV2 = false },
            Task.CompletedTask,
            TimeSpan.FromSeconds(120),
            TimeProvider.System,
            CancellationToken.None).DefaultTimeout();
 
        Assert.False(stable);
    }
 
    [Fact]
    public async Task WaitForLegacyDetachedStartupStabilityAsync_ReturnsTrueWhenChildStaysAliveForStabilityWindow()
    {
        var childExitTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task;
 
        var stable = await AppHostLauncher.WaitForLegacyDetachedStartupStabilityAsync(
            new TestAppHostAuxiliaryBackchannel { SupportsV2 = false },
            childExitTask,
            TimeSpan.FromMilliseconds(1),
            TimeProvider.System,
            CancellationToken.None).DefaultTimeout();
 
        Assert.True(stable);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_WaitsForReadinessRpcBeforeReportingSuccess()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        var readiness = new TaskCompletionSource<WaitForAppHostReadyResponse?>(TaskCreationOptions.RunContinuationsAsynchronously);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = ct => readiness.Task.WaitAsync(ct)
        });
        harness.ProcessFactory.Mode = TestDetachedProcessFactory.ChildProcessMode.StayAlive;
 
        var launchTask = harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None);
 
        await harness.ProcessFactory.Started.Task.DefaultTimeout();
        Assert.NotSame(launchTask, await Task.WhenAny(launchTask, Task.Delay(TimeSpan.FromMilliseconds(100))).DefaultTimeout());
 
        readiness.SetResult(new WaitForAppHostReadyResponse { IsReady = true });
 
        var result = await launchTask.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Contains(RunCommandStrings.StartingAppHostInBackground, harness.InteractionService.DynamicStatusTexts);
        Assert.Empty(harness.InteractionService.DisplayedErrors);
    }
 
    [Theory]
    [InlineData(false, null, false, "default-discovery")]
    [InlineData(false, "", false, "default-discovery")]
    [InlineData(true, null, false, null)]
    [InlineData(true, "", false, null)]
    [InlineData(false, "user-selection", false, "user-selection")]
    [InlineData(true, "explicit-launch-configuration", false, "explicit-launch-configuration")]
    [InlineData(true, null, true, "user-selection")]
    [InlineData(true, "explicit-cli", true, "user-selection")]
    public async Task LaunchDetachedAsync_PropagatesPersistentSelectionOriginToChild(
        bool passedAppHost,
        string? inheritedOrigin,
        bool selectionWasPrompted,
        string? expectedOrigin)
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper, inheritedOrigin, selectionWasPrompted);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => Task.FromResult<WaitForAppHostReadyResponse?>(new WaitForAppHostReadyResponse { IsReady = true })
        });
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            passedAppHost ? harness.AppHostFile : null,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        var hasOrigin = harness.ProcessFactory.CreatedEnvironmentVariables.TryGetValue(
            KnownConfigNames.CliAppHostSelectionOrigin,
            out var actualOrigin);
        Assert.Equal(expectedOrigin is not null, hasOrigin);
        Assert.Equal(expectedOrigin, actualOrigin);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ExplicitFalseFromLinkedWorktree_ForwardsFalse()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.SetLinkedWorktree();
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => Task.FromResult<WaitForAppHostReadyResponse?>(new WaitForAppHostReadyResponse { IsReady = true })
        });
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Equal(["--isolated", "false"], harness.ProcessFactory.CreatedArguments!.TakeLast(2));
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_OmittedFromLinkedWorktree_DoesNotInferIsolation()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.SetLinkedWorktree();
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => Task.FromResult<WaitForAppHostReadyResponse?>(new WaitForAppHostReadyResponse { IsReady = true })
        });
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: null,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Equal(0, harness.ProcessFactory.CreatedArguments!.Count(argument => argument == "--isolated"));
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ExplicitTrue_ForwardsFlag()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => Task.FromResult<WaitForAppHostReadyResponse?>(new WaitForAppHostReadyResponse { IsReady = true })
        });
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: true,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Equal("--isolated", harness.ProcessFactory.CreatedArguments![^1]);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_OmittedFromPrimaryCheckout_DoesNotForwardOption()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => Task.FromResult<WaitForAppHostReadyResponse?>(new WaitForAppHostReadyResponse { IsReady = true })
        });
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: null,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Equal(0, harness.ProcessFactory.CreatedArguments!.Count(argument => argument == "--isolated"));
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_DeletesDeadPidSocketBeforeStartingChildProcess()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        var socketPath = harness.CreateMatchingSocketFile(int.MaxValue - 1);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => Task.FromResult<WaitForAppHostReadyResponse?>(new WaitForAppHostReadyResponse { IsReady = true })
        });
        harness.ProcessFactory.Mode = TestDetachedProcessFactory.ChildProcessMode.StayAlive;
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.False(File.Exists(socketPath));
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ReportsFailureWhenReadinessWaitIsInterruptedByChildExit()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = async ct =>
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return null;
            }
        });
        harness.ProcessFactory.Mode = TestDetachedProcessFactory.ChildProcessMode.ExitWithFailure;
        harness.ProcessFactory.ChildLogLines =
        [
            "[2026-05-15 17:07:30.501] [INFO] [AppHost] apphost.ts(5,22): error TS1109: Expression expected.",
            "[2026-05-15 17:07:30.521] [FAIL] [GuestAppHostProject] TypeScript (Node.js) apphost exited with code 2"
        ];
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, result.ExitCode);
        Assert.Contains(RunCommandStrings.FailedToStartAppHost, harness.InteractionService.DisplayedErrors);
        Assert.Contains(RunCommandStrings.AppHostFailedToBuild, harness.InteractionService.DisplayedErrors);
        Assert.Contains(harness.InteractionService.DisplayedMessages, m => m.Message == $"{RunCommandStrings.RecentAppHostStartupOutput}:");
        Assert.Contains(harness.InteractionService.DisplayedLines, line => line.Line == "apphost.ts(5,22): error TS1109: Expression expected.");
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_UpdatesStatusAndWaitsForChildExitWhenReadinessRpcFails()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        var connectionLostStatusDisplayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        harness.InteractionService.ShowDynamicStatusCallback = status =>
        {
            if (status == RunCommandStrings.AppHostConnectionLostWaitingForExit)
            {
                connectionLostStatusDisplayed.TrySetResult();
            }
        };
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            WaitForAppHostReadyHandler = _ => throw new IOException("connection lost")
        });
        harness.ProcessFactory.Mode = TestDetachedProcessFactory.ChildProcessMode.StayAlive;
        harness.ProcessFactory.ChildLogLines =
        [
            "[2026-05-15 17:07:30.501] [FAIL] [GuestAppHostProject] AppHost failed after the backchannel was established."
        ];
 
        var launchTask = harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None);
 
        await connectionLostStatusDisplayed.Task.DefaultTimeout();
        Assert.NotSame(launchTask, await Task.WhenAny(launchTask, Task.Delay(TimeSpan.FromMilliseconds(100))).DefaultTimeout());
 
        harness.ProcessFactory.StopStartedProcess();
        var result = await launchTask.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, result.ExitCode);
        Assert.Contains(RunCommandStrings.StartingAppHostInBackground, harness.InteractionService.DynamicStatusTexts);
        Assert.Contains(RunCommandStrings.AppHostConnectionLostWaitingForExit, harness.InteractionService.DynamicStatusTexts);
        Assert.Contains(RunCommandStrings.FailedToStartAppHost, harness.InteractionService.DisplayedErrors);
        Assert.Contains(harness.InteractionService.DisplayedMessages, m => m.Message == $"{RunCommandStrings.RecentAppHostStartupOutput}:");
        Assert.Contains(harness.InteractionService.DisplayedLines, line => line.Line.Contains("AppHost failed after the backchannel was established.", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task WaitForLegacyDetachedStartupStabilityAsync_UsesV2ResourceSnapshotProbeWhenAvailable()
    {
        var probeCount = 0;
        var childExitTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task;
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            SupportsV2 = true,
            GetResourceSnapshotsHandler = _ =>
            {
                probeCount++;
                return Task.FromResult<List<ResourceSnapshot>>([]);
            }
        };
 
        var stable = await AppHostLauncher.WaitForLegacyDetachedStartupStabilityAsync(
            connection,
            childExitTask,
            TimeSpan.FromSeconds(120),
            TimeProvider.System,
            CancellationToken.None).DefaultTimeout();
 
        Assert.True(stable);
        Assert.Equal(1, probeCount);
    }
 
    [Fact]
    public async Task WaitForLegacyDetachedStartupStabilityAsync_RetriesV2ProbeUntilChildExits()
    {
        var probeCount = 0;
        var childExitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            SupportsV2 = true,
            GetResourceSnapshotsHandler = _ =>
            {
                probeCount++;
                // Signal child exit after the first probe so the method observes the exit
                // during the retry delay, making the test deterministic.
                childExitTcs.TrySetResult();
                throw new IOException("model unavailable");
            }
        };
 
        // Use FakeTimeProvider so the test never waits for real time and is fully deterministic.
        var timeProvider = new FakeTimeProvider();
 
        var stable = await AppHostLauncher.WaitForLegacyDetachedStartupStabilityAsync(
            connection,
            childExitTcs.Task,
            TimeSpan.FromSeconds(120),
            timeProvider,
            CancellationToken.None).DefaultTimeout();
 
        Assert.False(stable);
        Assert.True(probeCount > 0);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ReportsSuccessWhenLegacyV2ProbeSucceeds()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        var probeCount = 0;
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = false,
            SupportsV2 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            GetResourceSnapshotsHandler = _ =>
            {
                probeCount++;
                return Task.FromResult<List<ResourceSnapshot>>([]);
            }
        });
        harness.ProcessFactory.Mode = TestDetachedProcessFactory.ChildProcessMode.StayAlive;
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Empty(harness.InteractionService.DisplayedErrors);
        Assert.False(harness.ProcessFactory.StartedProcess?.HasExited);
        Assert.Equal(1, probeCount);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ReportsFailureWhenLegacyV2ProbeDoesNotSucceedBeforeChildExit()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.AddConnection(new TestAppHostAuxiliaryBackchannel
        {
            SupportsV3 = false,
            SupportsV2 = true,
            DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" },
            GetResourceSnapshotsHandler = _ => throw new IOException("model unavailable")
        });
        harness.ProcessFactory.Mode = TestDetachedProcessFactory.ChildProcessMode.ExitWithFailure;
        harness.ProcessFactory.ChildLogLines =
        [
            "[2026-05-16 19:07:52.383] [INFO] [Build] /work/BrokenAppHost/Program.cs(3,41): error CS1002: ; expected [/work/BrokenAppHost/BrokenAppHost.csproj]",
            "[2026-05-16 19:07:52.392] [INFO] [Build] Build FAILED."
        ];
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, result.ExitCode);
        Assert.Contains(RunCommandStrings.FailedToStartAppHost, harness.InteractionService.DisplayedErrors);
        Assert.Contains(RunCommandStrings.AppHostFailedToBuild, harness.InteractionService.DisplayedErrors);
        Assert.Contains(harness.InteractionService.DisplayedLines, line => line.Line.Contains("error CS1002", StringComparison.Ordinal));
        Assert.Contains(harness.InteractionService.DisplayedLines, line => line.Line == "Build FAILED.");
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ReportsForkProcessExitCodeWhenChildExitsBeforeMonitorAndStartTimeIsUnavailable()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only test.");
 
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        Process? startedProcess = null;
        Process? detachedHandle = null;
        Process? forkProcess = null;
        harness.ProcessFactory.StartHandler = async (_, _, _, _, _, cancellationToken) =>
        {
            startedProcess = Process.Start(new ProcessStartInfo
            {
                FileName = "/bin/sh",
                UseShellExecute = false,
                RedirectStandardInput = true,
                ArgumentList = { "-c", "read value; exit 11" }
            }) ?? throw new InvalidOperationException("Failed to start test child process.");
 
            detachedHandle = Process.GetProcessById(startedProcess.Id);
 
            forkProcess = Process.Start(new ProcessStartInfo
            {
                FileName = "/bin/sh",
                UseShellExecute = false,
                RedirectStandardInput = true,
                ArgumentList = { "-c", "read value; exit 11" }
            }) ?? throw new InvalidOperationException("Failed to start test DCP monitor process.");
 
            startedProcess.StandardInput.Close();
            await startedProcess.WaitForExitAsync(cancellationToken).DefaultTimeout();
            forkProcess.StandardInput.Close();
 
            return new MonitoredProcessExecutionAdapter(detachedHandle, forkProcess, startTime: null, useSuppliedStartTime: true);
        };
 
        try
        {
            var result = await harness.Launcher.LaunchDetachedAsync(
                harness.AppHostFile,
                format: null,
                isolated: false,
                launchProfile: null,
                isExtensionHost: false,
                waitForDebugger: false,
                timeoutSeconds: 5,
                globalArgs: [],
                additionalArgs: [],
                stopAfterLaunchDelay: null,
                CancellationToken.None).DefaultTimeout();
 
            Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, result.ExitCode);
            Assert.Collection(harness.InteractionService.DisplayedErrors,
                error => Assert.Equal(RunCommandStrings.FailedToStartAppHost, error),
                error => Assert.Equal(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, 11), error));
        }
        finally
        {
            if (startedProcess is { HasExited: false })
            {
                startedProcess.Kill(entireProcessTree: true);
                await startedProcess.WaitForExitAsync().DefaultTimeout();
            }
 
            if (forkProcess is { HasExited: false })
            {
                forkProcess.Kill(entireProcessTree: true);
                await forkProcess.WaitForExitAsync().DefaultTimeout();
            }
 
            detachedHandle?.Dispose();
            forkProcess?.Dispose();
            startedProcess?.Dispose();
        }
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_ForwardsCancellationTokenToDetachedLauncher()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync().DefaultTimeout();
 
        var launcherCalled = false;
        var observedToken = default(CancellationToken);
        harness.ProcessFactory.StartHandler = (_, _, _, _, _, cancellationToken) =>
        {
            launcherCalled = true;
            observedToken = cancellationToken;
            cancellationToken.ThrowIfCancellationRequested();
 
            throw new InvalidOperationException("Expected the cancelled token to stop launch.");
        };
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            cts.Token).DefaultTimeout();
 
        Assert.True(launcherCalled);
        Assert.Equal(cts.Token, observedToken);
        Assert.True(observedToken.IsCancellationRequested);
        Assert.Equal(CliExitCodes.Success, result.ExitCode);
        Assert.Empty(harness.InteractionService.DisplayedErrors);
        Assert.Equal(1, harness.ProcessFactory.CreatedExecutionDisposeCount);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_DisposesExecutionWhenDetachedStartFails()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        harness.ProcessFactory.StartHandler = (_, _, _, _, _, _) =>
        {
            throw new InvalidOperationException("start failed");
        };
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, result.ExitCode);
        Assert.Contains(RunCommandStrings.FailedToStartAppHost, harness.InteractionService.DisplayedErrors);
        Assert.Equal(1, harness.ProcessFactory.CreatedExecutionDisposeCount);
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_CleansUpChildProcessWhenCancelledAfterStart()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        using var cts = new CancellationTokenSource();
 
        var launchTask = harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 120,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            cts.Token);
 
        await harness.ProcessFactory.Started.Task.DefaultTimeout();
        var startedProcess = harness.ProcessFactory.StartedProcess ?? throw new InvalidOperationException("Expected child process to start.");
 
        try
        {
            Assert.False(startedProcess.HasExited);
 
            await cts.CancelAsync().DefaultTimeout();
            var result = await launchTask.DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, result.ExitCode);
            Assert.Empty(harness.InteractionService.DisplayedErrors);
            await startedProcess.WaitForExitAsync().DefaultTimeout();
            Assert.True(startedProcess.HasExited);
        }
        finally
        {
            harness.ProcessFactory.StopStartedProcess();
        }
    }
 
    [Fact]
    public async Task LaunchDetachedAsync_UsesSingleUncancelledChildExitObservationWhileWaitingForBackchannel()
    {
        using var harness = AppHostLauncherHarness.Create(outputHelper);
        using var cts = new CancellationTokenSource();
        var execution = new NonExitingProcessExecution();
        harness.ProcessFactory.StartHandler = (_, _, _, _, _, _) => Task.FromResult<IProcessExecution>(execution);
 
        var result = await harness.Launcher.LaunchDetachedAsync(
            harness.AppHostFile,
            format: null,
            isolated: false,
            launchProfile: null,
            isExtensionHost: false,
            waitForDebugger: false,
            timeoutSeconds: 1,
            globalArgs: [],
            additionalArgs: [],
            stopAfterLaunchDelay: null,
            cts.Token).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, result.ExitCode);
        Assert.Equal(1, execution.WaitForExitCallCount);
        Assert.Equal(0, execution.WaitForExitWithCancelableTokenCount);
    }
 
    [Fact]
    public void DetachedChildEnvironmentFilter_PreservesDebugSessionVariables()
    {
        Assert.True(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.ExtensionEndpoint));
        Assert.True(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.ExtensionDebugSessionId));
 
        Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionInfo));
        Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionRunMode));
        Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionPort));
        Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionToken));
        Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionServerCertificate));
        Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DcpInstanceIdPrefix));
    }
 
    [Fact]
    public void DetachedChildEnvironment_IncludesProfilingTelemetryContext()
    {
        using var source = new ActivitySource("test-detached-child-environment");
        using var listener = ActivityListenerHelper.Create(source);
        using var activity = source.StartActivity("parent");
        Assert.NotNull(activity);
        activity.SetBaggage(ProfilingTelemetry.Baggage.SessionId, "session-1");
        activity.TraceStateString = "state-1";
 
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(activity, appHostSelectionOrigin: null);
 
        Assert.Equal("true", environment[KnownConfigNames.CliRunDetached]);
        Assert.Equal("true", environment[ProfilingTelemetry.EnvironmentVariables.Enabled]);
        Assert.Equal("session-1", environment[ProfilingTelemetry.EnvironmentVariables.SessionId]);
        Assert.Equal("session-1", environment[KnownConfigNames.Legacy.StartupOperationId]);
        Assert.Equal(activity.Id, environment[ProfilingTelemetry.EnvironmentVariables.TraceParent]);
        Assert.Equal("state-1", environment[ProfilingTelemetry.EnvironmentVariables.TraceState]);
    }
 
    [Fact]
    public void DetachedChildEnvironment_ForwardsAppHostSelectionOriginToChildCli()
    {
        // The detached child re-resolves the AppHost, so it needs the same selection origin to avoid
        // clobbering the workspace default in aspire.config.json.
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(null, appHostSelectionOrigin: "explicit-launch-configuration");
 
        Assert.Equal("explicit-launch-configuration", environment[KnownConfigNames.CliAppHostSelectionOrigin]);
    }
 
    [Fact]
    public void DetachedChildEnvironment_OmitsAppHostSelectionOriginWhenNoOriginWasRecorded()
    {
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(null, appHostSelectionOrigin: null);
 
        Assert.False(environment.ContainsKey(KnownConfigNames.CliAppHostSelectionOrigin));
    }
 
    [Fact]
    public void DetachedChildEnvironment_DoesNotIncludeStartupStatusFile()
    {
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(null, appHostSelectionOrigin: null);
 
        Assert.False(environment.ContainsKey("ASPIRE_CLI_START_READY_FILE"));
    }
 
    [Fact]
    public void DetachedChildEnvironment_StampsLauncherIdentityForLivenessMonitor()
    {
        // The detached child's LauncherLivenessMonitor watches this launcher identity (PID + start time)
        // to tear the AppHost down if the foreground launcher dies before readiness. Without it the child
        // cannot detect a dead launcher and the AppHost + dashboard leak as orphaned processes.
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(null, appHostSelectionOrigin: null);
 
        Assert.Equal("true", environment[KnownConfigNames.CliRunDetached]);
        Assert.Equal(
            Environment.ProcessId.ToString(CultureInfo.InvariantCulture),
            environment[KnownConfigNames.CliLauncherProcessId]);
        Assert.NotNull(ProcessStartTimeHelper.TryParseStartTimeUnixSeconds(environment[KnownConfigNames.CliLauncherProcessStarted]));
    }
 
    [Fact]
    public void DetachedChildEnvironment_IncludesProfilingTelemetryContextFromActiveProfilingSpan()
    {
        using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration(
            (ProfilingTelemetry.EnvironmentVariables.Enabled, "true")));
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource);
 
        using var activity = profilingTelemetry.StartDetachedSpawnChild("aspire", ["run"], childCommand: "run");
        Assert.True(activity.IsRunning);
 
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(Activity.Current, appHostSelectionOrigin: null);
 
        Assert.Equal("true", environment[KnownConfigNames.CliRunDetached]);
        Assert.Equal("true", environment[ProfilingTelemetry.EnvironmentVariables.Enabled]);
        var sessionId = environment[ProfilingTelemetry.EnvironmentVariables.SessionId];
        Assert.False(string.IsNullOrWhiteSpace(sessionId));
        Assert.Equal(sessionId, environment[KnownConfigNames.Legacy.StartupOperationId]);
        Assert.Equal(Activity.Current?.Id, environment[ProfilingTelemetry.EnvironmentVariables.TraceParent]);
    }
 
    [Fact]
    public void DetachedChildEnvironment_DoesNotEnableProfilingForNonProfilingActivity()
    {
        using var source = new ActivitySource("test-detached-child-environment");
        using var listener = ActivityListenerHelper.Create(source);
        using var activity = source.StartActivity("parent");
        Assert.NotNull(activity);
 
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(activity, appHostSelectionOrigin: null);
 
        Assert.Equal("true", environment[KnownConfigNames.CliRunDetached]);
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.Enabled));
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.SessionId));
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.TraceParent));
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.TraceState));
    }
 
    [Fact]
    public void DetachedChildEnvironment_AllowsMissingProfilingTelemetryContext()
    {
        var environment = AppHostLauncher.CreateDetachedChildEnvironment(null, appHostSelectionOrigin: null);
 
        Assert.Equal("true", environment[KnownConfigNames.CliRunDetached]);
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.Enabled));
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.SessionId));
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.TraceParent));
        Assert.False(environment.ContainsKey(ProfilingTelemetry.EnvironmentVariables.TraceState));
    }
 
    [Fact]
    public async Task ReadChildLogTail_ReturnsBoundedRelevantNonEmptyTail()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var childLogFile = Path.Combine(workspace.WorkspaceRoot.FullName, "child.log");
        await File.WriteAllLinesAsync(childLogFile, [
            "[2026-05-15 17:07:24.674] [DBUG] [Features] Feature updateNotificationsEnabled = True (default: True)",
            "[2026-05-15 17:07:25.069] [INFO] [Stdout] :gear: Preparing Aspire server...",
            "[2026-05-15 17:07:27.381] [INFO] [Stdout] Connecting to AppHost...",
            "[2026-05-15 17:07:28.618] [DBUG] [GuestAppHostProject] Executing: /opt/homebrew/bin/npm install",
            "[2026-05-15 17:07:29.512] [INFO] [AppHost] up to date, audited 116 packages in 619ms",
            "[2026-05-15 17:07:29.520] [DBUG] [GuestAppHostProject] Executing: /opt/homebrew/bin/npx --no-install tsc --noEmit -p tsconfig.apphost.json",
            "[2026-05-15 17:07:30.501] [INFO] [AppHost] apphost.ts(5,22): error TS1109: Expression expected.",
            "[2026-05-15 17:07:30.521] [FAIL] [GuestAppHostProject] TypeScript (Node.js) apphost exited with code 2",
            "[2026-05-15 17:07:30.522] [FAIL] [GuestAppHostProject] AppHost server process has exited. Unable to connect to backchannel at /tmp/cli.sock",
            "[2026-05-15 17:07:30.528] [FAIL] [AspireCliTelemetry] An unexpected error occurred: The TypeScript (Node.js) apphost failed.",
            "System.InvalidOperationException: The TypeScript (Node.js) apphost failed.",
            "[2026-05-15 17:07:30.534] [INFO] [Stdout] An unexpected error occurred: The TypeScript (Node.js) apphost failed.",
            "[2026-05-15 17:07:30.540] [INFO] [Stdout] See logs at /tmp/child.log"
        ]).DefaultTimeout();
 
        var lines = AppHostLauncher.ReadChildLogTail(childLogFile, maxLines: 5);
 
        Assert.Equal([
            "apphost.ts(5,22): error TS1109: Expression expected."
        ], lines);
    }
 
    [Fact]
    public async Task ReadChildLogTail_IncludesBuildOutput()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var childLogFile = Path.Combine(workspace.WorkspaceRoot.FullName, "child.log");
        await File.WriteAllLinesAsync(childLogFile, [
            "[2026-05-16 19:07:51.709] [INFO] [Build]   Determining projects to restore...",
            "[2026-05-16 19:07:51.743] [INFO] [Build]   All projects are up-to-date for restore.",
            "[2026-05-16 19:07:52.383] [INFO] [Build] /work/BrokenAppHost/Program.cs(3,41): error CS1002: ; expected [/work/BrokenAppHost/BrokenAppHost.csproj]",
            "[2026-05-16 19:07:52.392] [INFO] [Build] Build FAILED.",
            "[2026-05-16 19:07:52.392] [INFO] [Build]     1 Error(s)"
        ]).DefaultTimeout();
 
        var lines = AppHostLauncher.ReadChildLogTail(childLogFile, maxLines: 4);
 
        Assert.Equal([
            "  All projects are up-to-date for restore.",
            "/work/BrokenAppHost/Program.cs(3,41): error CS1002: ; expected [/work/BrokenAppHost/BrokenAppHost.csproj]",
            "Build FAILED.",
            "    1 Error(s)"
        ], lines);
    }
 
    [Fact]
    public async Task ReadChildLogReplayTail_ReturnsRicherBoundedRelevantTail()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var childLogFile = Path.Combine(workspace.WorkspaceRoot.FullName, "child.log");
        await File.WriteAllLinesAsync(childLogFile, [
            "[2026-05-15 17:07:24.674] [DBUG] [Features] Feature updateNotificationsEnabled = True (default: True)",
            "[2026-05-15 17:07:25.069] [INFO] [Stdout] :gear: Preparing Aspire server...",
            "[2026-05-15 17:07:27.381] [INFO] [Stdout] Connecting to AppHost...",
            "[2026-05-15 17:07:28.618] [DBUG] [GuestAppHostProject] Executing: /opt/homebrew/bin/npm install",
            "[2026-05-15 17:07:29.512] [INFO] [AppHost] up to date, audited 116 packages in 619ms",
            "[2026-05-15 17:07:29.520] [DBUG] [GuestAppHostProject] Executing: /opt/homebrew/bin/npx --no-install tsc --noEmit -p tsconfig.apphost.json",
            "[2026-05-15 17:07:30.501] [INFO] [AppHost] apphost.ts(5,22): error TS1109: Expression expected.",
            "[2026-05-15 17:07:30.521] [FAIL] [GuestAppHostProject] TypeScript (Node.js) apphost exited with code 2",
            "[2026-05-15 17:07:30.522] [FAIL] [GuestAppHostProject] AppHost server process has exited. Unable to connect to backchannel at /tmp/cli.sock",
            "[2026-05-15 17:07:30.528] [FAIL] [AspireCliTelemetry] An unexpected error occurred: The TypeScript (Node.js) apphost failed.",
            "System.InvalidOperationException: The TypeScript (Node.js) apphost failed.",
            "[2026-05-15 17:07:30.534] [INFO] [Stdout] An unexpected error occurred: The TypeScript (Node.js) apphost failed.",
            "[2026-05-15 17:07:30.540] [INFO] [Stdout] See logs at /tmp/child.log"
        ]).DefaultTimeout();
 
        var entries = AppHostLauncher.ReadChildLogReplayTail(childLogFile, maxLines: 6);
 
        Assert.Collection(entries,
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Debug, entry.Level);
                Assert.Equal(CliLogFormat.Categories.GuestAppHostProject, entry.Category);
                Assert.Equal("Executing: /opt/homebrew/bin/npm install", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Information, entry.Level);
                Assert.Equal(CliLogFormat.Categories.AppHost, entry.Category);
                Assert.Equal("up to date, audited 116 packages in 619ms", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Debug, entry.Level);
                Assert.Equal(CliLogFormat.Categories.GuestAppHostProject, entry.Category);
                Assert.Equal("Executing: /opt/homebrew/bin/npx --no-install tsc --noEmit -p tsconfig.apphost.json", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Information, entry.Level);
                Assert.Equal(CliLogFormat.Categories.AppHost, entry.Category);
                Assert.Equal("apphost.ts(5,22): error TS1109: Expression expected.", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Error, entry.Level);
                Assert.Equal(CliLogFormat.Categories.GuestAppHostProject, entry.Category);
                Assert.Equal("TypeScript (Node.js) apphost exited with code 2", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Error, entry.Level);
                Assert.Equal(CliLogFormat.Categories.GuestAppHostProject, entry.Category);
                Assert.Equal("AppHost server process has exited. Unable to connect to backchannel at /tmp/cli.sock", entry.Message);
            });
    }
 
    [Fact]
    public async Task ReadChildLogReplayTail_IncludesBuildOutput()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var childLogFile = Path.Combine(workspace.WorkspaceRoot.FullName, "child.log");
        await File.WriteAllLinesAsync(childLogFile, [
            "[2026-05-16 19:07:51.709] [INFO] [Build]   Determining projects to restore...",
            "[2026-05-16 19:07:52.383] [INFO] [Build] /work/BrokenAppHost/Program.cs(3,41): error CS1002: ; expected [/work/BrokenAppHost/BrokenAppHost.csproj]",
            "[2026-05-16 19:07:52.392] [INFO] [Build] Build FAILED."
        ]).DefaultTimeout();
 
        var entries = AppHostLauncher.ReadChildLogReplayTail(childLogFile, maxLines: 3);
 
        Assert.Collection(entries,
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Information, entry.Level);
                Assert.Equal(CliLogFormat.Categories.Build, entry.Category);
                Assert.Equal("  Determining projects to restore...", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Information, entry.Level);
                Assert.Equal(CliLogFormat.Categories.Build, entry.Category);
                Assert.Equal("/work/BrokenAppHost/Program.cs(3,41): error CS1002: ; expected [/work/BrokenAppHost/BrokenAppHost.csproj]", entry.Message);
            },
            entry =>
            {
                Assert.Equal(CliLogFormat.FileLevelTokens.Information, entry.Level);
                Assert.Equal(CliLogFormat.Categories.Build, entry.Category);
                Assert.Equal("Build FAILED.", entry.Message);
            });
    }
 
    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();
    }
 
    private sealed class AppHostLauncherHarness : IDisposable
    {
        private readonly TemporaryWorkspace _workspace;
        private readonly DirectoryInfo _homeDirectory;
        private readonly FileLoggerProvider _fileLoggerProvider;
 
        private AppHostLauncherHarness(
            TemporaryWorkspace workspace,
            DirectoryInfo homeDirectory,
            FileLoggerProvider fileLoggerProvider,
            AppHostLauncher launcher,
            FileInfo appHostFile,
            TestInteractionService interactionService,
            TestAuxiliaryBackchannelMonitor monitor,
            TestDetachedProcessFactory processFactory)
        {
            _workspace = workspace;
            _homeDirectory = homeDirectory;
            _fileLoggerProvider = fileLoggerProvider;
            Launcher = launcher;
            AppHostFile = appHostFile;
            InteractionService = interactionService;
            Monitor = monitor;
            ProcessFactory = processFactory;
        }
 
        public AppHostLauncher Launcher { get; }
 
        public FileInfo AppHostFile { get; }
 
        public TestInteractionService InteractionService { get; }
 
        public TestAuxiliaryBackchannelMonitor Monitor { get; }
 
        public TestDetachedProcessFactory ProcessFactory { get; }
 
        public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) =>
            Create(outputHelper, appHostSelectionOrigin: null);
 
        public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper, string? appHostSelectionOrigin)
        {
            return Create(outputHelper, appHostSelectionOrigin, selectionWasPrompted: false);
        }
 
        public static AppHostLauncherHarness Create(
            ITestOutputHelper outputHelper,
            string? appHostSelectionOrigin,
            bool selectionWasPrompted)
        {
            var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
            var homeDirectory = workspace.WorkspaceRoot.CreateSubdirectory("home");
            var hivesDirectory = workspace.WorkspaceRoot.CreateSubdirectory("hives");
            var cacheDirectory = workspace.WorkspaceRoot.CreateSubdirectory("cache");
            var sdkDirectory = workspace.WorkspaceRoot.CreateSubdirectory("sdks");
            var logsDirectory = workspace.WorkspaceRoot.CreateSubdirectory("logs");
            var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
            File.WriteAllText(appHostFile.FullName, "<Project />");
 
            var executionContext = new CliExecutionContext(
                workspace.WorkspaceRoot,
                hivesDirectory,
                cacheDirectory,
                sdkDirectory,
                logsDirectory,
                Path.Combine(logsDirectory.FullName, "parent.log"),
                identityChannel: "local",
                homeDirectory: homeDirectory);
            var interactionService = new TestInteractionService();
            var monitor = new TestAuxiliaryBackchannelMonitor();
            var processFactory = new TestDetachedProcessFactory();
            var fileLoggerProvider = new FileLoggerProvider(executionContext.LogFilePath, new TestStartupErrorWriter());
            var processShutdownService = new ProcessTreeGracefulShutdownService(
                new FixedLayoutDiscovery(),
                new NullBundleService(),
                new LayoutProcessRunner(new TestProcessExecutionFactory()),
                executionContext, new TestEnvironment(),
                NullLogger<ProcessTreeGracefulShutdownService>.Instance,
                TimeProvider.System);
            var projectLocator = new TestProjectLocator
            {
                UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                    Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])
                    {
                        WasExplicitDirectorySelectionPrompted = selectionWasPrompted
                    })
            };
            var projectFactory = new TestAppHostProjectFactory();
            var launcher = new AppHostLauncher(
                projectLocator,
                projectFactory,
                executionContext,
                interactionService,
                monitor,
                TestHelpers.CreateInteractiveHostEnvironment(),
                TestTelemetryHelper.CreateInitializedTelemetry(),
                new ProfilingTelemetry(new ConfigurationBuilder().Build()),
                fileLoggerProvider,
                processShutdownService,
                processFactory,
                CreateConfiguration((KnownConfigNames.CliAppHostSelectionOrigin, appHostSelectionOrigin)),
                NullLogger<AppHostLauncher>.Instance,
                TimeProvider.System);
 
            return new AppHostLauncherHarness(
                workspace,
                homeDirectory,
                fileLoggerProvider,
                launcher,
                appHostFile,
                interactionService,
                monitor,
                processFactory);
        }
 
        public void AddConnection(TestAppHostAuxiliaryBackchannel connection)
        {
            var socketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(AppHostFile.FullName, _homeDirectory.FullName);
            var hash = AppHostHelper.ExtractHashFromSocketPath(socketPrefix) ?? throw new InvalidOperationException("Expected socket hash.");
            connection.Hash = hash;
            connection.AppHostInfo ??= new AppHostInformation
            {
                AppHostPath = AppHostFile.FullName,
                ProcessId = Environment.ProcessId,
                StartedAt = DateTimeOffset.UtcNow
            };
 
            Monitor.AddConnection(hash, $"{socketPrefix}.sock", connection);
        }
 
        public void SetLinkedWorktree()
        {
            TestGitWorktree.WriteLinkedWorktreeMetadata(
                _workspace.WorkspaceRoot.FullName,
                Path.Combine(_workspace.WorkspaceRoot.FullName, "common", ".git"));
        }
 
        public string CreateMatchingSocketFile(int pid)
        {
            var backchannelsDir = Path.Combine(_homeDirectory.FullName, ".aspire", "cli", "bch");
            Directory.CreateDirectory(backchannelsDir);
 
            var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(AppHostFile.FullName);
            var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, _homeDirectory.FullName);
            var appHostId = Path.GetFileName(prefix);
            var socketPath = Path.Combine(
                backchannelsDir,
                $"{appHostId}a1b2C3d4.{pid.ToString(CultureInfo.InvariantCulture)}");
            File.WriteAllText(socketPath, "");
            return socketPath;
        }
 
        public void Dispose()
        {
            ProcessFactory.Dispose();
            _fileLoggerProvider.Dispose();
            _workspace.Dispose();
        }
    }
 
    private sealed class TestDetachedProcessFactory : IProcessExecutionFactory, IDisposable
    {
        public enum ChildProcessMode
        {
            StayAlive,
            ExitWithFailure
        }
 
        public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
        public ChildProcessMode Mode { get; set; } = ChildProcessMode.StayAlive;
 
        public IReadOnlyList<string> ChildLogLines { get; set; } = [];
 
        public Process? StartedProcess { get; private set; }
 
        private TestDetachedProcessExecution? CreatedExecution { get; set; }
 
        public IReadOnlyList<string>? CreatedArguments => CreatedExecution?.Arguments;
 
        public int CreatedExecutionDisposeCount => CreatedExecution?.DisposeCount ?? 0;
 
        public IReadOnlyDictionary<string, string?> CreatedEnvironmentVariables =>
            CreatedExecution?.EnvironmentVariables ?? throw new InvalidOperationException("Expected a detached process execution.");
 
        public Func<string, IReadOnlyList<string>, string, Func<string, bool>?, IReadOnlyDictionary<string, string>?, CancellationToken, Task<IProcessExecution>>? StartHandler { get; set; }
 
        public void StopStartedProcess()
        {
            if (StartedProcess is { HasExited: false })
            {
                StartedProcess.Kill(entireProcessTree: true);
                StartedProcess.WaitForExit(TimeSpan.FromSeconds(10));
            }
        }
 
        public IProcessExecution CreateExecution(string fileName, string[] args, IDictionary<string, string>? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options)
        {
            var environment = env is null ? null : new Dictionary<string, string>(env);
            CreatedExecution = new TestDetachedProcessExecution(this, fileName, args, workingDirectory.FullName, environment, options);
            return CreatedExecution;
        }
 
        public IProcessExecution CreateExecution(ProcessStartInfo startInfo, ProcessInvocationOptions options)
        {
            var args = startInfo.ArgumentList.ToArray();
            var env = startInfo.Environment
                .Where(static kvp => kvp.Value is not null)
                .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value!);
            var workingDirectory = string.IsNullOrEmpty(startInfo.WorkingDirectory) ? Directory.GetCurrentDirectory() : startInfo.WorkingDirectory;
 
            CreatedExecution = new TestDetachedProcessExecution(this, startInfo.FileName, args, workingDirectory, env, options);
            return CreatedExecution;
        }
 
        private Task<IProcessExecution> StartCoreAsync(
            string fileName,
            IReadOnlyList<string> arguments,
            string workingDirectory,
            ProcessInvocationOptions options,
            IReadOnlyDictionary<string, string>? additionalEnvironmentVariables,
            CancellationToken cancellationToken)
        {
            if (StartHandler is not null)
            {
                return StartHandler(fileName, arguments, workingDirectory, options.EnvironmentVariableFilter, additionalEnvironmentVariables, cancellationToken);
            }
 
            _ = fileName;
            _ = options;
            _ = additionalEnvironmentVariables;
            _ = cancellationToken;
 
            var childLogFile = GetChildLogFile(arguments);
            if (ChildLogLines.Count > 0)
            {
                File.WriteAllLines(childLogFile, ChildLogLines);
            }
 
            StartedProcess = Process.Start(CreateProcessStartInfo(workingDirectory)) ?? throw new InvalidOperationException("Failed to start test child process.");
            Started.SetResult();
            return Task.FromResult<IProcessExecution>(new MonitoredProcessExecutionAdapter(StartedProcess));
        }
 
        public void Dispose()
        {
            StopStartedProcess();
 
            StartedProcess?.Dispose();
        }
 
        private ProcessStartInfo CreateProcessStartInfo(string workingDirectory)
        {
            var (fileName, arguments) = OperatingSystem.IsWindows()
                ? CreateWindowsProcessCommand()
                : CreateUnixProcessCommand();
 
            return new ProcessStartInfo(fileName)
            {
                Arguments = arguments,
                WorkingDirectory = workingDirectory,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                CreateNoWindow = true
            };
        }
 
        private (string FileName, string Arguments) CreateWindowsProcessCommand()
            => Mode switch
            {
                ChildProcessMode.StayAlive => ("cmd.exe", "/c ping -n 60 127.0.0.1 >NUL"),
                ChildProcessMode.ExitWithFailure => ("cmd.exe", "/c ping -n 1 127.0.0.1 >NUL & exit /b 6"),
                _ => throw new InvalidOperationException($"Unexpected child process mode: {Mode}")
            };
 
        private (string FileName, string Arguments) CreateUnixProcessCommand()
            => Mode switch
            {
                ChildProcessMode.StayAlive => ("/bin/sh", "-c \"sleep 60\""),
                ChildProcessMode.ExitWithFailure => ("/bin/sh", "-c \"sleep 0.1; exit 6\""),
                _ => throw new InvalidOperationException($"Unexpected child process mode: {Mode}")
            };
 
        private static string GetChildLogFile(IReadOnlyList<string> arguments)
        {
            var logFileIndex = -1;
            for (var i = 0; i < arguments.Count; i++)
            {
                if (arguments[i] == "--log-file")
                {
                    logFileIndex = i;
                    break;
                }
            }
 
            if (logFileIndex < 0 || logFileIndex + 1 >= arguments.Count)
            {
                throw new InvalidOperationException("Expected child arguments to include --log-file.");
            }
 
            return arguments[logFileIndex + 1];
        }
 
        private sealed class TestDetachedProcessExecution(
            TestDetachedProcessFactory launcher,
            string fileName,
            IReadOnlyList<string> arguments,
            string workingDirectory,
            IReadOnlyDictionary<string, string>? environment,
            ProcessInvocationOptions options) : IProcessExecution
        {
            private IProcessExecution? _inner;
 
            public string FileName => fileName;
 
            public IReadOnlyList<string> Arguments => arguments;
 
            public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; } =
                environment?.ToDictionary(static kvp => kvp.Key, static kvp => (string?)kvp.Value)
                ?? new Dictionary<string, string?>();
 
            public int ProcessId => Inner.ProcessId;
 
            public DateTimeOffset? StartTime => Inner.StartTime;
 
            public bool HasExited => Inner.HasExited;
 
            public int ExitCode => Inner.ExitCode;
 
            public int DisposeCount { get; private set; }
 
            private IProcessExecution Inner =>
                _inner ?? throw new InvalidOperationException("Test detached process has not been started.");
 
            public async Task<bool> StartAsync(CancellationToken cancellationToken)
            {
                _inner = await launcher.StartCoreAsync(fileName, arguments, workingDirectory, options, environment, cancellationToken).ConfigureAwait(false);
                return true;
            }
 
            public Task<int> WaitForExitAsync(CancellationToken cancellationToken) => Inner.WaitForExitAsync(cancellationToken);
 
            public void Kill(bool entireProcessTree) => Inner.Kill(entireProcessTree);
 
            public ValueTask DisposeAsync()
            {
                DisposeCount++;
                return _inner?.DisposeAsync() ?? ValueTask.CompletedTask;
            }
        }
    }
 
    private sealed class MonitoredProcessExecutionAdapter(Process process, Process? exitMonitorProcess = null, DateTimeOffset? startTime = null, bool useSuppliedStartTime = false) : IProcessExecution
    {
        public string FileName => string.Empty;
 
        public IReadOnlyList<string> Arguments => [];
 
        public IReadOnlyDictionary<string, string?> EnvironmentVariables => new Dictionary<string, string?>();
 
        public int ProcessId { get; } = process.Id;
 
        public DateTimeOffset? StartTime { get; } = useSuppliedStartTime ? startTime : startTime ?? GetStartTime(process);
 
        public bool HasExited => exitMonitorProcess?.HasExited ?? process.HasExited;
 
        public int ExitCode
        {
            get
            {
                if (exitMonitorProcess is { HasExited: true } monitorProcess)
                {
                    return monitorProcess.ExitCode;
                }
 
                return process.ExitCode;
            }
        }
 
        public Task<bool> StartAsync(CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(true);
        }
 
        public async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
        {
            if (exitMonitorProcess is not null)
            {
                await exitMonitorProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            }
            else
            {
                await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            }
 
            return ExitCode;
        }
 
        public void Kill(bool entireProcessTree) => process.Kill(entireProcessTree);
 
        public ValueTask DisposeAsync()
        {
            process.Dispose();
            exitMonitorProcess?.Dispose();
            return ValueTask.CompletedTask;
        }
 
        private static DateTimeOffset? GetStartTime(Process process)
        {
            try
            {
                return ProcessStartTimeHelper.TryGetProcessStartTime(process.Id) ?? new DateTimeOffset(process.StartTime);
            }
            catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception)
            {
                return null;
            }
        }
    }
 
    private sealed class NonExitingProcessExecution : IProcessExecution
    {
        private readonly TaskCompletionSource<int> _exit = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private int _waitForExitCallCount;
        private int _waitForExitWithCancelableTokenCount;
 
        public string FileName => string.Empty;
 
        public IReadOnlyList<string> Arguments => [];
 
        public IReadOnlyDictionary<string, string?> EnvironmentVariables => new Dictionary<string, string?>();
 
        public int ProcessId => int.MaxValue - 12345;
 
        public DateTimeOffset? StartTime => null;
 
        public bool HasExited => false;
 
        public int ExitCode => 0;
 
        public int WaitForExitCallCount => Volatile.Read(ref _waitForExitCallCount);
 
        public int WaitForExitWithCancelableTokenCount => Volatile.Read(ref _waitForExitWithCancelableTokenCount);
 
        public Task<bool> StartAsync(CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(true);
        }
 
        public Task<int> WaitForExitAsync(CancellationToken cancellationToken)
        {
            Interlocked.Increment(ref _waitForExitCallCount);
            if (cancellationToken.CanBeCanceled)
            {
                Interlocked.Increment(ref _waitForExitWithCancelableTokenCount);
            }
 
            return _exit.Task;
        }
 
        public void Kill(bool entireProcessTree)
        {
        }
 
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }
 
    private sealed class FixedLayoutDiscovery : ILayoutDiscovery
    {
        public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => null;
 
        public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => null;
 
        public bool IsBundleModeAvailable(string? projectDirectory = null) => false;
    }
 
    private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
    {
        public override DateTimeOffset GetUtcNow() => utcNow;
    }
}