From d2907ce94beed1c65e58ebbeb712ebd415c05dd8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 13:58:06 +1000 Subject: [PATCH 1/4] 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 fb06aba00bfae7be58ff12f14acfeda0fc9bc8fb Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 17 Jun 2026 16:01:54 +1000 Subject: [PATCH 2/4] 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( From 19004abe3b7135bacf2c9ec00adda9447436e019 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 18 Jun 2026 15:02:21 +1000 Subject: [PATCH 3/4] Centralize symlink resolution for AppHost socket matching Move symlink canonicalization into AppHostHelper.FindMatchingNonOrphanedSockets so all callers consistently match AppHost backchannel sockets keyed on canonical paths, and update tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/AppHostConnectionResolver.cs | 14 ++------ src/Aspire.Cli/Utils/AppHostHelper.cs | 6 +++- .../Commands/AppHostLauncherTests.cs | 4 ++- .../Projects/DotNetAppHostProjectTests.cs | 4 ++- .../Projects/GuestAppHostProjectTests.cs | 4 ++- .../Utils/AppHostHelperTests.cs | 32 +++++++++++++++++++ 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index a5b24e22bb4..e84da284434 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,18 +135,8 @@ public async Task ResolveConnectionAsync( }; } - // 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( - socketLookupPath, + projectFile.FullName, executionContext.HomeDirectory.FullName, Environment.ProcessId, logger); diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 5d51459c7cc..6048efe355c 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Hosting.Backchannel; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; using Semver; @@ -139,7 +140,10 @@ internal static string[] FindMatchingNonOrphanedSockets( int currentPid, ILogger logger) { - var matchingSockets = BackchannelConstants.FindMatchingSockets(appHostPath, homeDirectory); + // Resolve symlinks so callers that provide "/tmp/..." can still match sockets keyed + // off the physical path (for example "/private/tmp/..." on macOS). + var resolvedPath = PathNormalizer.ResolveSymlinks(appHostPath); + var matchingSockets = BackchannelConstants.FindMatchingSockets(resolvedPath, homeDirectory); var remainingSockets = PruneOrphanedSockets(matchingSockets, currentPid, out var deletedCount); if (deletedCount > 0) { diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index f738146ef76..037d4303919 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -15,6 +15,7 @@ using Aspire.Cli.Tests.Telemetry; using Aspire.Cli.Tests.Utils; using Aspire.Hosting; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; @@ -780,7 +781,8 @@ public string CreateMatchingSocketFile(int pid) var backchannelsDir = Path.Combine(_homeDirectory.FullName, ".aspire", "cli", "bch"); Directory.CreateDirectory(backchannelsDir); - var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(AppHostFile.FullName, _homeDirectory.FullName); + var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(AppHostFile.FullName); + var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, _homeDirectory.FullName); var appHostId = Path.GetFileName(prefix); var socketPath = Path.Combine( backchannelsDir, diff --git a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs index 871a3c0d8b7..47c00b2ea90 100644 --- a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Aspire.Shared; using Microsoft.Extensions.DependencyInjection; using System.Text.Json; @@ -1644,7 +1645,8 @@ private string CreateMatchingSocketFile(string appHostPath, int pid) var backchannelsDir = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch"); Directory.CreateDirectory(backchannelsDir); - var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, _workspace.WorkspaceRoot.FullName); + var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath); + var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, _workspace.WorkspaceRoot.FullName); var appHostId = Path.GetFileName(prefix); var socketPath = Path.Combine( backchannelsDir, diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 6da4a947802..575a1a1a176 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Spectre.Console; @@ -1139,7 +1140,8 @@ private string CreateMatchingSocketFile(string appHostPath, int pid) var backchannelsDir = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch"); Directory.CreateDirectory(backchannelsDir); - var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, _workspace.WorkspaceRoot.FullName); + var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath); + var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, _workspace.WorkspaceRoot.FullName); var appHostId = Path.GetFileName(prefix); var socketPath = Path.Combine( backchannelsDir, diff --git a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs index 4bd97d262d2..255e432899f 100644 --- a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Utils; using Aspire.Hosting.Backchannel; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Utils; @@ -403,6 +404,37 @@ public void FindMatchingNonOrphanedSockets_RemovesDeadPidSocketsAndKeepsLiveAndP Assert.True(File.Exists(pidlessSocket)); } + [Fact] + public void FindMatchingNonOrphanedSockets_WithSymlinkedPath_MatchesCanonicalSocket() + { + 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 realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real"); + var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link"); + Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName); + + var projectFileViaSymlink = Path.Combine(symlinkDirectory, "TestAppHost.csproj"); + File.WriteAllText(projectFileViaSymlink, ""); + + var canonicalProjectPath = PathNormalizer.ResolveSymlinks(projectFileViaSymlink); + var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(canonicalProjectPath, workspace.WorkspaceRoot.FullName); + var appHostId = Path.GetFileName(prefix); + var currentPid = Environment.ProcessId; + var liveSocket = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch", $"{appHostId}a1b2C3d4.{currentPid}"); + Directory.CreateDirectory(Path.GetDirectoryName(liveSocket)!); + File.WriteAllText(liveSocket, ""); + + var remainingSockets = AppHostHelper.FindMatchingNonOrphanedSockets( + projectFileViaSymlink, + workspace.WorkspaceRoot.FullName, + currentPid, + NullLogger.Instance); + + Assert.Collection(remainingSockets, socket => Assert.Equal(liveSocket, socket)); + } + [Theory] [InlineData("10.0.0", true)] [InlineData("9.2.0", true)] From 309855a73fa0de2e36d4c0021d9b81387613fbd1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 18 Jun 2026 19:33:09 +1000 Subject: [PATCH 4/4] Fix Windows socket-hash mismatch in FindMatchingNonOrphanedSockets test The test seeded socket files from the raw drive-less path "/path/to/MyApp.AppHost.csproj" but FindMatchingNonOrphanedSockets now resolves symlinks (via Path.GetFullPath) before hashing. On Windows, Path.GetFullPath roots that path to "C:\path\to\...", producing a different hash and zero matches. Key the seeded sockets off the same resolved path so both sides agree on all platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs index 255e432899f..b0908ea0d20 100644 --- a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs @@ -377,7 +377,12 @@ public void FindMatchingNonOrphanedSockets_RemovesDeadPidSocketsAndKeepsLiveAndP Directory.CreateDirectory(backchannelsDir); var appHostPath = "/path/to/MyApp.AppHost.csproj"; - var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, workspace.WorkspaceRoot.FullName); + // FindMatchingNonOrphanedSockets resolves symlinks (which canonicalizes via Path.GetFullPath) + // before hashing, so the socket files must be keyed off the same resolved path. On Windows + // Path.GetFullPath roots the drive-less "/path/to/..." to "C:\path\to\...", giving a different + // hash than the raw string; resolving here keeps both sides consistent across all platforms. + var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath); + var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, workspace.WorkspaceRoot.FullName); var appHostId = Path.GetFileName(prefix); var deadPid = int.MaxValue - 1; var currentPid = Environment.ProcessId;