diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 5ba0564a766..e84da284434 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -135,9 +135,8 @@ public async Task ResolveConnectionAsync( }; } - var targetPath = projectFile.FullName; var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets( - targetPath, + projectFile.FullName, executionContext.HomeDirectory.FullName, Environment.ProcessId, logger); @@ -162,7 +161,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/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/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( 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..b0908ea0d20 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; @@ -376,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; @@ -403,6 +409,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)]