// 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;
}
}