From a5fa6d8abcb774a2349b4e5872174db7e3deed59 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 15 Jun 2026 14:21:08 +0800 Subject: [PATCH 1/7] Fix stale version-skew warning during aspire update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During 'aspire update', WarnIfCliSdkVersionSkew reads the SDK version from disk. At that point the in-memory config has already been updated to the CLI's version but hasn't been saved yet, causing a false positive warning about version mismatch. Thread the target SDK version through GenerateCodeViaRpcAsync to WarnIfCliSdkVersionSkew. When the target aligns with the CLI version, skip the warning since the on-disk config is stale and about to be overwritten. Also extract IAppHostServerSessionFactory.Start() to enable unit testing the full UpdatePackagesAsync → BuildAndGenerateSdkAsync → GenerateCodeViaRpcAsync → WarnIfCliSdkVersionSkew code path without starting a real process. Fixes #18103 --- .../Projects/AppHostServerSession.cs | 14 ++ .../Projects/GuestAppHostProject.cs | 47 +++-- .../Projects/IAppHostServerSession.cs | 12 ++ .../Projects/GuestAppHostProjectTests.cs | 194 ++++++++++++++++++ .../TestServices/FakeAppHostServerSession.cs | 72 +++++++ .../FakeSucceedingAppHostServerProject.cs | 40 ++++ .../TestAppHostServerSessionFactory.cs | 18 +- 7 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 2865791a67c..d908221b776 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -280,4 +280,18 @@ public async Task CreateAsync( BuildOutput: prepareResult.Output, ChannelName: prepareResult.ChannelName); } + + /// + public IAppHostServerSession Start( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug) + { + return AppHostServerSession.Start( + appHostServerProject, + environmentVariables, + debug, + _logger, + _profilingTelemetry); + } } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 28d9ce96e0b..fb94b657a4c 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -35,6 +35,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly IInteractionService _interactionService; private readonly IAppHostCliBackchannel _backchannel; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; + private readonly IAppHostServerSessionFactory _appHostServerSessionFactory; private readonly ICertificateService _certificateService; private readonly IDotNetCliRunner _runner; private readonly IPackagingService _packagingService; @@ -57,6 +58,7 @@ public GuestAppHostProject( IInteractionService interactionService, IAppHostCliBackchannel backchannel, IAppHostServerProjectFactory appHostServerProjectFactory, + IAppHostServerSessionFactory appHostServerSessionFactory, ICertificateService certificateService, IDotNetCliRunner runner, IPackagingService packagingService, @@ -73,6 +75,7 @@ public GuestAppHostProject( _interactionService = interactionService; _backchannel = backchannel; _appHostServerProjectFactory = appHostServerProjectFactory; + _appHostServerSessionFactory = appHostServerSessionFactory; _certificateService = certificateService; _runner = runner; _packagingService = packagingService; @@ -283,12 +286,10 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir } // Step 2: Start the AppHost server temporarily for code generation - await using var serverSession = AppHostServerSession.Start( + await using var serverSession = _appHostServerSessionFactory.Start( appHostServerProject, environmentVariables: null, - debug: false, - _logger, - _profilingTelemetry); + debug: false); // Step 3: Connect to server var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -301,6 +302,7 @@ await GenerateCodeViaRpcAsync( appHostFile: null, rpcClient, integrations, + targetSdkVersion: config.SdkVersion, cancellationToken); // Step 5: Install dependencies using GuestRuntime (best effort - don't block code generation) @@ -435,16 +437,14 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false); // Start the AppHost server process - AppHostServerSession serverSession; + IAppHostServerSession serverSession; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession = AppHostServerSession.Start( + serverSession = _appHostServerSessionFactory.Start( appHostServerProject, launchSettingsEnvVars, - context.Debug, - _logger, - _profilingTelemetry); + context.Debug); try { // Step 5: Connect to server for RPC calls. The connection helper retries until @@ -462,7 +462,7 @@ await GenerateCodeViaRpcAsync( appHostFile, rpcClient, integrations, - cancellationToken); + cancellationToken: cancellationToken); } await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); @@ -1014,16 +1014,14 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca launchSettingsEnvVars[KnownConfigNames.AspireUserSecretsId] = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName); // Step 2: Start the AppHost server process(it opens the backchannel for progress reporting) - AppHostServerSession serverSession; + IAppHostServerSession serverSession; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession = AppHostServerSession.Start( + serverSession = _appHostServerSessionFactory.Start( appHostServerProject, launchSettingsEnvVars, - context.Debug, - _logger, - _profilingTelemetry); + context.Debug); try { @@ -1042,7 +1040,7 @@ await GenerateCodeViaRpcAsync( appHostFile, rpcClient, integrations, - cancellationToken); + cancellationToken: cancellationToken); } await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); @@ -1532,7 +1530,8 @@ private async Task GenerateCodeViaRpcAsync( FileInfo? appHostFile, IAppHostRpcClient rpcClient, IEnumerable integrations, - CancellationToken cancellationToken) + string? targetSdkVersion = null, + CancellationToken cancellationToken = default) { var integrationsList = integrations.ToList(); @@ -1540,7 +1539,7 @@ private async Task GenerateCodeViaRpcAsync( // The code generator is registered by its Language property, not the runtime ID var codeGenerator = _resolvedLanguage.CodeGenerator; - WarnIfCliSdkVersionSkew(appPath); + WarnIfCliSdkVersionSkew(appPath, targetSdkVersion); _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count); @@ -1633,10 +1632,19 @@ private bool ShouldEmitLegacyTypeScriptGeneratedFiles(string appPath, FileInfo? /// purely informational and let code-generation try first so that benign skew (e.g. a /// daily-build CLI against a stable SDK) doesn't block valid scenarios. /// - private void WarnIfCliSdkVersionSkew(string appPath) + private void WarnIfCliSdkVersionSkew(string appPath, string? targetSdkVersion = null) { try { + var cliVersion = _executionContext.IdentitySdkVersion; + + // When the caller is actively updating TO a version that matches the CLI, + // the on-disk config is stale and about to be overwritten — skip the warning. + if (targetSdkVersion is not null && !IsKnownIncompatibleSkew(cliVersion, targetSdkVersion)) + { + return; + } + var configDir = ConfigurationHelper.GetConfigRootDirectory(new DirectoryInfo(appPath)); var config = AspireConfigFile.Load(configDir.FullName); var configuredSdkVersion = config?.SdkVersion; @@ -1645,7 +1653,6 @@ private void WarnIfCliSdkVersionSkew(string appPath) return; } - var cliVersion = _executionContext.IdentitySdkVersion; if (!IsKnownIncompatibleSkew(cliVersion, configuredSdkVersion)) { return; diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 43ea2344245..08f708b80b6 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -61,6 +61,18 @@ Task CreateAsync( Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken); + + /// + /// Starts a server session from an already-prepared server project. + /// + /// The prepared server project to start. + /// Optional environment variables for the server process. + /// Whether to enable debug logging. + /// The started server session. + IAppHostServerSession Start( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug); } /// diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index dc89cd3541d..93de2d8407a 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -6,12 +6,14 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; +using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console; namespace Aspire.Cli.Tests.Projects; @@ -944,6 +946,196 @@ public void IsUsingProjectReferencesReturnsFalseWhenIdentityIsOverridden() private GuestAppHostProject CreateGuestAppHostProject() => CreateGuestAppHostProject(interactionService: null, identityChannel: "local"); + /// + /// Regression test for https://github.com/microsoft/aspire/issues/18103: + /// During aspire update, the code-generation step calls + /// WarnIfCliSdkVersionSkew which reads the SDK version from disk. At that + /// point the in-memory config has already been updated to the CLI's version, but + /// the file hasn't been saved yet. The method should not emit a version-skew warning + /// when the update is actively aligning versions. + /// + /// + /// The test drives to demonstrate + /// the update scenario (stale on-disk SDK version, update available to match CLI), then + /// directly exercises WarnIfCliSdkVersionSkew in the same state that would exist + /// inside BuildAndGenerateSdkAsync (in-memory config updated, disk still stale). + /// The UpdatePackagesAsync call itself will fail at the regeneration step because + /// the test uses , but that is expected — + /// the assertion validates that the skew-warning method does not emit a spurious warning + /// for the stale on-disk version. + /// + [Fact] + public async Task UpdatePackagesAsync_DoesNotEmitStaleVersionSkewWarningDuringUpdate() + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var staleVersion = "1.0.0"; + + var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, $$""" + { + "sdk": { "version": "{{staleVersion}}" }, + "packages": { "Aspire.Hosting": "{{staleVersion}}" } + } + """); + + var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts"); + await File.WriteAllTextAsync(appHostPath, "// test apphost"); + + // Return the CLI version as the latest available, so aspire update would align them. + var fakeCache = new FakeNuGetPackageCache + { + GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) => + Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = cliVersion, Source = "test" } + ]) + }; + + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); + + var interactionService = new TestInteractionService + { + ConfirmCallback = (_, _) => true + }; + + var factory = new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (appPath, _) => + Task.FromResult(new FakeSucceedingAppHostServerProject(appPath)) + }; + + var sessionFactory = new TestAppHostServerSessionFactory(); + + var project = CreateGuestAppHostProject( + interactionService: interactionService, + appHostServerProjectFactory: factory, + appHostServerSessionFactory: sessionFactory); + + var context = new UpdatePackagesContext + { + AppHostFile = new FileInfo(appHostPath), + Channel = implicitChannel, + ConfirmBinding = PromptBinding.CreateDefault(false), + NuGetConfigDirBinding = PromptBinding.CreateDefault(null), + }; + + // UpdatePackagesAsync will go through BuildAndGenerateSdkAsync → GenerateCodeViaRpcAsync + // which calls WarnIfCliSdkVersionSkew reading the stale on-disk config. + // It should NOT warn because the update is aligning versions to match the CLI. + await project.UpdatePackagesAsync(context, CancellationToken.None); + + Assert.Empty(interactionService.DisplayedErrors); + Assert.Collection(interactionService.DisplayedMessages, + m => + { + Assert.Equal("package", m.Emoji.Name); + Assert.Equal($"Aspire SDK {staleVersion} to {cliVersion}", Markup.Remove(m.Message)); + }, + m => + { + Assert.Equal("package", m.Emoji.Name); + Assert.Equal($"Aspire.Hosting {staleVersion} to {cliVersion}", Markup.Remove(m.Message)); + }, + m => + { + Assert.Equal("package", m.Emoji.Name); + Assert.Equal(UpdateCommandStrings.RegeneratedSdkCode, m.Message); + }); + } + + /// + /// Verifies that WarnIfCliSdkVersionSkew emits the + /// when the on-disk SDK version + /// genuinely differs from the CLI version and the update target does NOT align them. + /// + [Fact] + public async Task UpdatePackagesAsync_EmitsVersionSkewWarningWhenTargetDiffersFromCli() + { + var staleVersion = "1.0.0"; + var updateTargetVersion = "2.0.0"; // Different from CLI version — legitimate skew + + var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, $$""" + { + "sdk": { "version": "{{staleVersion}}" }, + "packages": { "Aspire.Hosting": "{{staleVersion}}" } + } + """); + + var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts"); + await File.WriteAllTextAsync(appHostPath, "// test apphost"); + + // Return a version that does NOT match the CLI version — the skew is genuine. + var fakeCache = new FakeNuGetPackageCache + { + GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) => + Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = updateTargetVersion, Source = "test" } + ]) + }; + + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); + + var interactionService = new TestInteractionService + { + ConfirmCallback = (_, _) => true + }; + + var factory = new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (appPath, _) => + Task.FromResult(new FakeSucceedingAppHostServerProject(appPath)) + }; + + var sessionFactory = new TestAppHostServerSessionFactory(); + + var project = CreateGuestAppHostProject( + interactionService: interactionService, + appHostServerProjectFactory: factory, + appHostServerSessionFactory: sessionFactory); + + var context = new UpdatePackagesContext + { + AppHostFile = new FileInfo(appHostPath), + Channel = implicitChannel, + ConfirmBinding = PromptBinding.CreateDefault(false), + NuGetConfigDirBinding = PromptBinding.CreateDefault(null), + }; + + await project.UpdatePackagesAsync(context, CancellationToken.None); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var expectedWarning = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ErrorStrings.CodegenVersionSkewWarning, + cliVersion, + staleVersion); + + Assert.Empty(interactionService.DisplayedErrors); + Assert.Collection(interactionService.DisplayedMessages, + m => + { + Assert.Equal("package", m.Emoji.Name); + Assert.Equal($"Aspire SDK {staleVersion} to {updateTargetVersion}", Markup.Remove(m.Message)); + }, + m => + { + Assert.Equal("package", m.Emoji.Name); + Assert.Equal($"Aspire.Hosting {staleVersion} to {updateTargetVersion}", Markup.Remove(m.Message)); + }, + m => + { + Assert.Equal("warning", m.Emoji.Name); + Assert.Contains(expectedWarning, m.Message); + }, + m => + { + Assert.Equal("package", m.Emoji.Name); + Assert.Equal(UpdateCommandStrings.RegeneratedSdkCode, m.Message); + }); + } + private string CreateMatchingSocketFile(string appHostPath, int pid) { var backchannelsDir = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch"); @@ -962,6 +1154,7 @@ private GuestAppHostProject CreateGuestAppHostProject( TestInteractionService? interactionService = null, string identityChannel = "local", TestAppHostServerProjectFactory? appHostServerProjectFactory = null, + IAppHostServerSessionFactory? appHostServerSessionFactory = null, bool identityOverridden = false) { var language = new LanguageInfo( @@ -984,6 +1177,7 @@ private GuestAppHostProject CreateGuestAppHostProject( interactionService: interactionService ?? new TestInteractionService(), backchannel: new TestAppHostBackchannel(), appHostServerProjectFactory: appHostServerProjectFactory ?? new TestAppHostServerProjectFactory(), + appHostServerSessionFactory: appHostServerSessionFactory ?? new TestAppHostServerSessionFactory(), certificateService: new TestCertificateService(), runner: new TestDotNetCliRunner(), packagingService: new TestPackagingService(), diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs new file mode 100644 index 00000000000..1a5a1caf42e --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.Cli.Commands.Sdk; +using Aspire.Cli.Projects; +using Aspire.Cli.Utils; +using Aspire.TypeSystem; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Fake that returns a +/// without starting a real process or connecting to a socket. +/// +internal sealed class FakeAppHostServerSession : IAppHostServerSession +{ + private readonly FakeAppHostRpcClient _rpcClient = new(); + + public string SocketPath => "fake-socket"; + + public Process ServerProcess { get; } = Process.GetCurrentProcess(); + + public OutputCollector Output { get; } = new(); + + public string AuthenticationToken => "fake-token"; + + public Task GetRpcClientAsync(CancellationToken cancellationToken) + => Task.FromResult(_rpcClient); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +/// +/// Fake RPC client that returns empty results for all operations. +/// Used to exercise code paths that run after RPC connection without needing a real server. +/// +internal sealed class FakeAppHostRpcClient : IAppHostRpcClient +{ + public Task GetRuntimeSpecAsync(string languageId, CancellationToken cancellationToken) + => Task.FromResult(new RuntimeSpec + { + Language = languageId, + DisplayName = "Fake", + CodeGenLanguage = "TypeScript", + DetectionPatterns = ["apphost.ts"], + Execute = new CommandSpec { Command = "node", Args = ["apphost.js"] } + }); + + public Task> ScaffoldAppHostAsync(string languageId, string targetPath, string? projectName, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task> GenerateCodeAsync(string languageId, CancellationToken cancellationToken) + => Task.FromResult(new Dictionary()); + + public Task> GenerateCodeForAssemblyAsync(string languageId, string assemblyName, CancellationToken cancellationToken) + => Task.FromResult(new Dictionary()); + + public Task GetCapabilitiesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task GetCapabilitiesForAssembliesAsync(IReadOnlyList assemblyNames, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs new file mode 100644 index 00000000000..fafd6481a41 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.Cli.Configuration; +using Aspire.Cli.Projects; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// whose returns success. +/// Used with a fake that bypasses +/// so is never called. +/// +internal sealed class FakeSucceedingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject, IDisposable +{ + public string AppDirectoryPath { get; } = appDirectoryPath; + + public string GetInstanceIdentifier() => AppDirectoryPath; + + public Task PrepareAsync( + string sdkVersion, + IEnumerable integrations, + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new AppHostServerPrepareResult(Success: true, Output: null)); + + public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false) => + throw new NotSupportedException("Run should not be invoked when using a fake session starter."); + + public void Dispose() + { + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs index fcaa77b92ef..86d2b9d6d6f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs @@ -8,10 +8,13 @@ namespace Aspire.Cli.Tests.TestServices; /// -/// Test implementation of that returns a failure result. +/// Test implementation of that returns a failure result +/// from and a from . /// internal sealed class TestAppHostServerSessionFactory : IAppHostServerSessionFactory { + public Func?, bool, IAppHostServerSession>? StartCallback { get; set; } + public Task CreateAsync( string appHostPath, string sdkVersion, @@ -28,4 +31,17 @@ public Task CreateAsync( BuildOutput: outputCollector, ChannelName: null)); } + + public IAppHostServerSession Start( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug) + { + if (StartCallback is { } callback) + { + return callback(appHostServerProject, environmentVariables, debug); + } + + return new FakeAppHostServerSession(); + } } From 25a47334339b0523ec409ee1b019c96a04014561 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 15 Jun 2026 15:18:55 +0800 Subject: [PATCH 2/7] Fix inaccurate remarks in UpdatePackagesAsync test doc comment The referenced FakeFailingAppHostServerProject but the test actually uses FakeSucceedingAppHostServerProject. It also stated the call would fail at regeneration, but with the fake session returning empty results the full flow succeeds. --- .../Projects/GuestAppHostProjectTests.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 93de2d8407a..6da4a947802 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -956,13 +956,11 @@ private GuestAppHostProject CreateGuestAppHostProject() /// /// /// The test drives to demonstrate - /// the update scenario (stale on-disk SDK version, update available to match CLI), then - /// directly exercises WarnIfCliSdkVersionSkew in the same state that would exist - /// inside BuildAndGenerateSdkAsync (in-memory config updated, disk still stale). - /// The UpdatePackagesAsync call itself will fail at the regeneration step because - /// the test uses , but that is expected — - /// the assertion validates that the skew-warning method does not emit a spurious warning - /// for the stale on-disk version. + /// the update scenario (stale on-disk SDK version, update available to match CLI). With + /// and + /// (which returns empty results from GenerateCodeAsync), the full update flow + /// succeeds. The assertion validates that the skew-warning method does not emit a spurious + /// warning for the stale on-disk version when the update is aligning versions to the CLI. /// [Fact] public async Task UpdatePackagesAsync_DoesNotEmitStaleVersionSkewWarningDuringUpdate() From 340164d0ac0356599bc796a860b57ccc06f4a201 Mon Sep 17 00:00:00 2001 From: shauryalowkeygotaura <173814476+shauryalowkeygotaura@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:23:14 +0530 Subject: [PATCH 3/7] Fail cleanly when AppHost selection would require a prompt in non-interactive mode When no AppHost is found in the current directory but other AppHosts are running on the system, commands like 'aspire describe --non-interactive' fell through to an interactive selection prompt, which threw and surfaced as 'An unexpected error occurred' with a generic exit code. AppHostConnectionResolver now checks ICliHostEnvironment.SupportsInteractiveInput before prompting: - Only out-of-scope AppHosts running: returns the command's standard not-found error with CliExitCodes.FailedToFindProject. - Multiple in-scope AppHosts: returns a new actionable error telling the user to pass --apphost, also with FailedToFindProject. Fixes #17619 Co-Authored-By: Claude Fable 5 --- .../Backchannel/AppHostConnectionResolver.cs | 24 +++++++ .../Commands/CommonCommandServices.cs | 4 +- src/Aspire.Cli/Commands/DescribeCommand.cs | 2 +- src/Aspire.Cli/Commands/ExportCommand.cs | 2 +- src/Aspire.Cli/Commands/LogsCommand.cs | 2 +- src/Aspire.Cli/Commands/McpCallCommand.cs | 2 +- src/Aspire.Cli/Commands/McpToolsCommand.cs | 2 +- src/Aspire.Cli/Commands/ResourceCommand.cs | 2 +- src/Aspire.Cli/Commands/StopCommand.cs | 2 +- .../Commands/TelemetryLogsCommand.cs | 2 +- .../Commands/TelemetrySpansCommand.cs | 2 +- .../Commands/TelemetryTracesCommand.cs | 2 +- src/Aspire.Cli/Commands/WaitCommand.cs | 2 +- .../SharedCommandStrings.Designer.cs | 9 +++ .../Resources/SharedCommandStrings.resx | 3 + .../Resources/xlf/SharedCommandStrings.cs.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.de.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.es.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.fr.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.it.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.ja.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.ko.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.pl.xlf | 5 ++ .../xlf/SharedCommandStrings.pt-BR.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.ru.xlf | 5 ++ .../Resources/xlf/SharedCommandStrings.tr.xlf | 5 ++ .../xlf/SharedCommandStrings.zh-Hans.xlf | 5 ++ .../xlf/SharedCommandStrings.zh-Hant.xlf | 5 ++ .../AppHostConnectionResolverTests.cs | 63 +++++++++++++++++++ .../NuGet/NuGetPackagePrefetcherTests.cs | 4 +- 30 files changed, 180 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 0f7169bcf6a..5ba0564a766 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -43,6 +43,7 @@ internal sealed class AppHostConnectionResolver( IInteractionService interactionService, IProjectLocator projectLocator, CliExecutionContext executionContext, + ICliHostEnvironment hostEnvironment, ILogger logger, ProfilingTelemetry? profilingTelemetry = null) { @@ -199,6 +200,17 @@ public async Task ResolveConnectionAsync( } else if (inScopeConnections.Count > 1) { + if (!hostEnvironment.SupportsInteractiveInput) + { + // Can't prompt the user to pick an AppHost in non-interactive mode; + // fail with an actionable message instead of letting the prompt throw. + return new AppHostConnectionResult + { + ErrorMessage = SharedCommandStrings.MultipleAppHostsNonInteractive, + ExitCode = CliExitCodes.FailedToFindProject, + }; + } + selectedConnection = await PromptForAppHostSelectionAsync( inScopeConnections, SharedCommandStrings.MultipleInScopeAppHosts, @@ -208,6 +220,18 @@ public async Task ResolveConnectionAsync( } else if (outOfScopeConnections.Count > 0) { + if (!hostEnvironment.SupportsInteractiveInput) + { + // No in-scope AppHosts, and selecting from out-of-scope AppHosts requires + // a prompt. In non-interactive mode treat this as "not found" so scripts + // get a clean error and exit code instead of an unexpected prompt failure. + return new AppHostConnectionResult + { + ErrorMessage = notFoundMessage, + ExitCode = CliExitCodes.FailedToFindProject, + }; + } + selectedConnection = await PromptForAppHostSelectionAsync( outOfScopeConnections, SharedCommandStrings.NoInScopeAppHostsShowingAll, diff --git a/src/Aspire.Cli/Commands/CommonCommandServices.cs b/src/Aspire.Cli/Commands/CommonCommandServices.cs index d6ace2b5688..1386d062127 100644 --- a/src/Aspire.Cli/Commands/CommonCommandServices.cs +++ b/src/Aspire.Cli/Commands/CommonCommandServices.cs @@ -16,7 +16,8 @@ internal sealed class CommonCommandServices( IInteractionService interactionService, AspireCliTelemetry telemetry, ConsoleCancellationManager cancellationManager, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + ICliHostEnvironment hostEnvironment) { public IFeatures Features { get; } = features; public ICliUpdateNotifier UpdateNotifier { get; } = updateNotifier; @@ -25,4 +26,5 @@ internal sealed class CommonCommandServices( public AspireCliTelemetry Telemetry { get; } = telemetry; public ConsoleCancellationManager CancellationManager { get; } = cancellationManager; public ILoggerFactory LoggerFactory { get; } = loggerFactory; + public ICliHostEnvironment HostEnvironment { get; } = hostEnvironment; } diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index 1e485d2bc36..c110493b869 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -111,7 +111,7 @@ public DescribeCommand( { Aliases.Add("resources"); _resourceColorMap = resourceColorMap; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/ExportCommand.cs b/src/Aspire.Cli/Commands/ExportCommand.cs index 49e3c3ea3d4..a95cca0c695 100644 --- a/src/Aspire.Cli/Commands/ExportCommand.cs +++ b/src/Aspire.Cli/Commands/ExportCommand.cs @@ -61,7 +61,7 @@ public ExportCommand( _httpClientFactory = httpClientFactory; _timeProvider = timeProvider; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index fbfe25065f3..0afbd06be67 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -128,7 +128,7 @@ public LogsCommand( _resourceColorMap = resourceColorMap; _hostEnvironment = hostEnvironment; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger, profilingTelemetry); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger, profilingTelemetry); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/McpCallCommand.cs b/src/Aspire.Cli/Commands/McpCallCommand.cs index 72a81ef2a78..e56545ddc7d 100644 --- a/src/Aspire.Cli/Commands/McpCallCommand.cs +++ b/src/Aspire.Cli/Commands/McpCallCommand.cs @@ -45,7 +45,7 @@ public McpCallCommand( CommonCommandServices services) : base("call", McpCommandStrings.CallCommand_Description, services) { - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Arguments.Add(s_toolArgument); diff --git a/src/Aspire.Cli/Commands/McpToolsCommand.cs b/src/Aspire.Cli/Commands/McpToolsCommand.cs index a2dc878021d..ba06f99c87c 100644 --- a/src/Aspire.Cli/Commands/McpToolsCommand.cs +++ b/src/Aspire.Cli/Commands/McpToolsCommand.cs @@ -35,7 +35,7 @@ public McpToolsCommand( CommonCommandServices services) : base("tools", McpCommandStrings.ToolsCommand_Description, services) { - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Options.Add(s_appHostOption); Options.Add(s_formatOption); diff --git a/src/Aspire.Cli/Commands/ResourceCommand.cs b/src/Aspire.Cli/Commands/ResourceCommand.cs index 56ab577e5c8..e460d689078 100644 --- a/src/Aspire.Cli/Commands/ResourceCommand.cs +++ b/src/Aspire.Cli/Commands/ResourceCommand.cs @@ -82,7 +82,7 @@ public ResourceCommand( { _backchannelMonitor = backchannelMonitor; _projectLocator = projectLocator; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); _logger = logger; Arguments.Add(s_resourceArgument); diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 3e4fca9766f..c059050a4d0 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -44,7 +44,7 @@ public StopCommand( CommonCommandServices services) : base("stop", StopCommandStrings.Description, services) { - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, logger, profilingTelemetry); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger, profilingTelemetry); _hostEnvironment = hostEnvironment; _processShutdownService = processShutdownService; _logger = logger; diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index ff479fa5872..37d2ce2e0c0 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -64,7 +64,7 @@ public TelemetryLogsCommand( _resourceColorMap = resourceColorMap; _timeProvider = timeProvider; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 485bb0186a2..546ff7986ee 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -54,7 +54,7 @@ public TelemetrySpansCommand( _resourceColorMap = resourceColorMap; _timeProvider = timeProvider; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index eeacb139b17..8aa6fddb027 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -54,7 +54,7 @@ public TelemetryTracesCommand( _resourceColorMap = resourceColorMap; _timeProvider = timeProvider; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs index a49dac9fea5..d74ff101d6c 100644 --- a/src/Aspire.Cli/Commands/WaitCommand.cs +++ b/src/Aspire.Cli/Commands/WaitCommand.cs @@ -47,7 +47,7 @@ public WaitCommand( TimeProvider? timeProvider = null) : base("wait", WaitCommandStrings.Description, services) { - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, InteractionService, projectLocator, ExecutionContext, services.HostEnvironment, logger); _logger = logger; _timeProvider = timeProvider ?? TimeProvider.System; diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs index b6dd19c5d01..00f08ab79e5 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -171,6 +171,15 @@ internal static string MultipleInScopeAppHosts { } } + /// + /// Looks up a localized string similar to Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use.. + /// + internal static string MultipleAppHostsNonInteractive { + get { + return ResourceManager.GetString("MultipleAppHostsNonInteractive", resourceCulture); + } + } + internal static string PromptRunAgentInit { get { return ResourceManager.GetString("PromptRunAgentInit", resourceCulture); diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index ab8f423998e..807cf3dc81d 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -182,6 +182,9 @@ Multiple running AppHosts found in the current directory. Select from running AppHosts. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Would you like to configure AI agent environments for this project? diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index c8a32b43bd6..f4bb6d65c26 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 5e434376353..062ad0d41b0 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index bf202fc6715..7a0d5e50167 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index 6ab0951b25a..82e237fe28c 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 9269f235e7e..e0134099555 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index 1d67deacc53..c26cf19472d 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index e602e9cfa91..e2a514f3ccb 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index c7ce26d905e..9b42baa8b8e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index a4077bf74bd..3e11258ddfe 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 092d0677494..4a9433742f0 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index a5e5b3b9052..7ca9d8a463c 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index 6ed4ab62cb5..d503c3cc7ba 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index 0d9c9855ef2..6e810d089ac 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -82,6 +82,11 @@ The --stream option requires --format json. + + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + Multiple running AppHosts were found, but the CLI is running in non-interactive mode. Pass --apphost to specify which AppHost to use. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs index 3b6ed2b2a90..539966f6a47 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs @@ -35,6 +35,7 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_PreservesFastPa interactionService, projectLocator, executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), NullLogger.Instance); var result = await resolver.ResolveConnectionAsync( @@ -64,6 +65,7 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_DeletesDeadPidS new TestInteractionService(), new TestProjectLocator(), executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), NullLogger.Instance); var result = await resolver.ResolveConnectionAsync( @@ -109,6 +111,7 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndMultipleAppHost interactionService, projectLocator, executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), NullLogger.Instance); var result = await resolver.ResolveConnectionAsync( @@ -141,6 +144,7 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndNoAppHosts_Retu interactionService, projectLocator, executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), NullLogger.Instance); var result = await resolver.ResolveConnectionAsync( @@ -156,6 +160,65 @@ public async Task ResolveConnectionAsync_WithExplicitDirectoryAndNoAppHosts_Retu Assert.Equal(InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsNoAppHosts, result.ErrorMessage); } + [Fact] + public async Task ResolveConnectionAsync_NonInteractiveWithOnlyOutOfScopeAppHosts_ReturnsNotFoundError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", "socket-other", new TestAppHostAuxiliaryBackchannel { IsInScope = false }); + + var resolver = new AppHostConnectionResolver( + monitor, + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateNonInteractiveHostEnvironment(), + NullLogger.Instance); + + var result = await resolver.ResolveConnectionAsync( + projectFile: null, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.True(result.IsProjectResolutionError); + Assert.Equal(SharedCommandStrings.AppHostNotRunning, result.ErrorMessage); + Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode); + } + + [Fact] + public async Task ResolveConnectionAsync_NonInteractiveWithMultipleInScopeAppHosts_ReturnsActionableError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", "socket-one", new TestAppHostAuxiliaryBackchannel { IsInScope = true }); + monitor.AddConnection("hash2", "socket-two", new TestAppHostAuxiliaryBackchannel { IsInScope = true }); + + var resolver = new AppHostConnectionResolver( + monitor, + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateNonInteractiveHostEnvironment(), + NullLogger.Instance); + + var result = await resolver.ResolveConnectionAsync( + projectFile: null, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.True(result.IsProjectResolutionError); + Assert.Equal(SharedCommandStrings.MultipleAppHostsNonInteractive, result.ErrorMessage); + Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode); + } + private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory) { return TestExecutionContextHelper.CreateExecutionContext( diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs index 42e81e676fe..af5190fcd3a 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs @@ -227,7 +227,7 @@ private static bool IsRuntimeOnlyCommand(BaseCommand command) // Test command implementations internal sealed class TestCommand : BaseCommand { - public TestCommand(string name = "test") : base(name, "Test command", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!)) + public TestCommand(string name = "test") : base(name, "Test command", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!, null!)) { } @@ -239,7 +239,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, Can internal sealed class TestCommandWithInterface : BaseCommand, IPackageMetaPrefetchingCommand { - public TestCommandWithInterface() : base("test-interface", "Test command with interface", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!)) + public TestCommandWithInterface() : base("test-interface", "Test command with interface", new CommonCommandServices(null!, null!, null!, null!, null!, null!, null!, null!)) { } From 77160f6e901c826b4c72fa3fcf5664142b7e0af6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 13:29:17 +1000 Subject: [PATCH 4/7] Fix terminal command resolver wiring after 17619 port Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/TerminalAttachCommand.cs | 2 +- src/Aspire.Cli/Commands/TerminalPsCommand.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/TerminalAttachCommand.cs b/src/Aspire.Cli/Commands/TerminalAttachCommand.cs index 09cde305f72..cf8cd8adb78 100644 --- a/src/Aspire.Cli/Commands/TerminalAttachCommand.cs +++ b/src/Aspire.Cli/Commands/TerminalAttachCommand.cs @@ -66,7 +66,7 @@ public TerminalAttachCommand( { _interactionService = services.InteractionService; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); diff --git a/src/Aspire.Cli/Commands/TerminalPsCommand.cs b/src/Aspire.Cli/Commands/TerminalPsCommand.cs index ec3cd4cd5a8..84b57dd8f66 100644 --- a/src/Aspire.Cli/Commands/TerminalPsCommand.cs +++ b/src/Aspire.Cli/Commands/TerminalPsCommand.cs @@ -63,7 +63,7 @@ public TerminalPsCommand( { _interactionService = services.InteractionService; _logger = logger; - _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, logger); + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, services.InteractionService, projectLocator, services.ExecutionContext, services.HostEnvironment, logger); Options.Add(s_appHostOption); Options.Add(s_formatOption); From eeb484012d287c3a29431c9aae32b17c766399ba Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 13:55:12 +1000 Subject: [PATCH 5/7] Clean up socket file after stopping running AppHost instance Fixes issue where aspire stop leaves socket files behind, causing subsequent aspire add/describe commands to fail with connection timeouts. Resolves #17587: aspire add fails after aspire run --detach and aspire stop The socket file was not being deleted after successfully stopping an AppHost instance. Subsequent commands would attempt to connect to the stale socket path, resulting in timeout errors. Now we explicitly delete the socket file after the instance has been stopped and monitored for process termination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/RunningInstanceManager.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Aspire.Cli/Projects/RunningInstanceManager.cs b/src/Aspire.Cli/Projects/RunningInstanceManager.cs index 4b5e8b4b9bb..e43ac210f8e 100644 --- a/src/Aspire.Cli/Projects/RunningInstanceManager.cs +++ b/src/Aspire.Cli/Projects/RunningInstanceManager.cs @@ -65,6 +65,20 @@ public async Task StopRunningInstanceAsync(string socketPath, Cancellation if (stopped) { _interactionService.DisplaySuccess(RunCommandStrings.RunningInstanceStopped); + // Clean up the socket file now that the instance has been stopped. + // Leaving it behind would cause subsequent operations to fail when trying to connect to a dead process. + try + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + _logger.LogDebug("Cleaned up socket file after stopping instance: {SocketPath}", socketPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogDebug(ex, "Failed to clean up socket file after stopping instance (this may be safe to ignore)"); + } } else { From a762221d903579d67ecb2082deb69cfb31bea42f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 13:58:06 +1000 Subject: [PATCH 6/7] Canonicalize AppHost paths to handle symlinks in aspire describe --apphost Fixes issue where aspire describe --apphost fails when path contains symlinks (e.g., /tmp on macOS which resolves to /private/tmp). Resolves #17618: aspire describe --apphost fails to find running AppHost when path traverses a symlink The socket lookup in AppHostConnectionResolver now canonicalizes the project file path before computing the backchannel socket key, matching the path canonicalization that occurs when the AppHost is started via ProjectLocator. This ensures both the running AppHost and the describe command use the same canonical path when looking up sockets, regardless of whether the user provided a symlinked path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 5ba0564a766..926a7163576 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -8,9 +8,9 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; using Spectre.Console; - namespace Aspire.Cli.Backchannel; /// @@ -135,7 +135,10 @@ public async Task ResolveConnectionAsync( }; } - var targetPath = projectFile.FullName; + // Canonicalize the path to handle symlinks (e.g., /tmp -> /private/tmp on macOS). + // This ensures the socket lookup uses the same path as the running AppHost, + // which canonicalizes paths when computing backchannel socket keys. + var targetPath = PathNormalizer.ResolveToFilesystemPath(projectFile.FullName); var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets( targetPath, executionContext.HomeDirectory.FullName, From d98f1bbce729386ebf782428825dd65d62c22b44 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 16:01:54 +1000 Subject: [PATCH 7/7] Resolve symlinks when matching AppHost socket for --apphost (#17618) The explicit --apphost socket lookup in AppHostConnectionResolver used PathNormalizer.ResolveToFilesystemPath, which only normalizes Windows casing and is a no-op on Linux/macOS. A running AppHost keys its backchannel socket off the symlink-resolved path (its process working directory is reported physically by the OS, e.g. /tmp -> /private/tmp on macOS), so the consumer never matched when the supplied path traversed a symlink and reported 'No AppHost is currently running'. Switch the socket-key computation to PathNormalizer.ResolveSymlinks so the consumer hashes the same canonical path as the producer. The user-facing error path still displays the original supplied path. Adds a regression test that fails on the previous behavior, and updates the dead-PID pruning test to key its socket off the resolved path (matching a real AppHost) since the macOS temp workspace lives under the symlinked /var -> /private/var. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/AppHostConnectionResolver.cs | 20 ++++-- .../AppHostConnectionResolverTests.cs | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 926a7163576..a5b24e22bb4 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -135,12 +135,18 @@ public async Task ResolveConnectionAsync( }; } - // Canonicalize the path to handle symlinks (e.g., /tmp -> /private/tmp on macOS). - // This ensures the socket lookup uses the same path as the running AppHost, - // which canonicalizes paths when computing backchannel socket keys. - var targetPath = PathNormalizer.ResolveToFilesystemPath(projectFile.FullName); + // Resolve symlinks before computing the backchannel socket key so the lookup + // matches the path the running AppHost used. A running AppHost keys its socket + // off Path.GetFullPath(appHostPath) evaluated against its own process working + // directory, which the OS already reports in physical (symlink-resolved) form + // (getcwd canonicalizes, e.g. /tmp -> /private/tmp on macOS). The producer side + // therefore hashes the canonical path, so the consumer must resolve symlinks too. + // ResolveToFilesystemPath only fixes Windows casing and is a no-op on Linux/macOS, + // so it cannot bridge the /tmp -> /private/tmp gap; ResolveSymlinks does. + // See https://github.com/microsoft/aspire/issues/17618. + var socketLookupPath = PathNormalizer.ResolveSymlinks(projectFile.FullName); var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets( - targetPath, + socketLookupPath, executionContext.HomeDirectory.FullName, Environment.ProcessId, logger); @@ -165,7 +171,9 @@ public async Task ResolveConnectionAsync( } } - var displayPath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, targetPath); + // Display the path the user supplied (not the symlink-resolved lookup path) so the + // error message stays relative to the working directory and matches what they typed. + var displayPath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName); return new AppHostConnectionResult { diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs index 539966f6a47..6d07d0638ef 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Backchannel; @@ -59,7 +60,12 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_DeletesDeadPidS using var workspace = TemporaryWorkspace.Create(outputHelper); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectFile = CreateProjectFile(workspace.WorkspaceRoot, "TestAppHost", "TestAppHost.csproj"); - var socketPath = CreateMatchingSocketFile(projectFile.FullName, workspace.WorkspaceRoot, int.MaxValue - 1); + // Key the socket off the symlink-resolved path, matching how a running AppHost computes + // its socket id (its working directory is reported physically by the OS). On macOS the + // temp workspace lives under /var -> /private/var, so the unresolved and resolved paths + // differ and the resolver must resolve symlinks to find this socket. + var resolvedProjectPath = PathNormalizer.ResolveSymlinks(projectFile.FullName); + var socketPath = CreateMatchingSocketFile(resolvedProjectPath, workspace.WorkspaceRoot, int.MaxValue - 1); var resolver = new AppHostConnectionResolver( new TestAuxiliaryBackchannelMonitor(), new TestInteractionService(), @@ -219,6 +225,59 @@ public async Task ResolveConnectionAsync_NonInteractiveWithMultipleInScopeAppHos Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode); } + [Fact] + public async Task ResolveConnectionAsync_WithSymlinkedProjectPath_ResolvesToCanonicalSocketKey() + { + // Regression test for https://github.com/microsoft/aspire/issues/17618. + // A running AppHost keys its backchannel socket off the symlink-resolved path + // (its process working directory is already physical, e.g. /tmp -> /private/tmp + // on macOS). The explicit --apphost lookup must resolve symlinks the same way or + // it computes a different appHostId and reports "no running AppHost" even though + // one is running. We assert the orphaned socket keyed off the canonical path is + // found (and pruned) when the resolver is handed a symlinked project path. + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), + "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + + // Real project file under a "real" directory. + var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real"); + var realProjectFile = new FileInfo(Path.Combine(realDirectory.FullName, "TestAppHost.csproj")); + File.WriteAllText(realProjectFile.FullName, ""); + + // Symlink "link" -> "real"; the project is addressed through the symlink. + var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link"); + Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName); + var projectFileViaSymlink = new FileInfo(Path.Combine(symlinkDirectory, "TestAppHost.csproj")); + + // The producer keys its socket off the canonical (symlink-resolved) path, so create + // the orphaned socket using that same canonical path with a dead PID. + var canonicalPath = PathNormalizer.ResolveSymlinks(projectFileViaSymlink.FullName); + var socketPath = CreateMatchingSocketFile(canonicalPath, workspace.WorkspaceRoot, int.MaxValue - 1); + + var resolver = new AppHostConnectionResolver( + new TestAuxiliaryBackchannelMonitor(), + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance); + + var result = await resolver.ResolveConnectionAsync( + projectFileViaSymlink, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + // The socket was located via the symlink-resolved key and pruned because its PID is dead. + // Before the fix the resolver hashed the unresolved symlink path, never matched this + // socket, and left it on disk. + Assert.False(File.Exists(socketPath)); + } + private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory) { return TestExecutionContextHelper.CreateExecutionContext(