Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ public async Task<AppHostConnectionResult> ResolveConnectionAsync(
};
}

var targetPath = projectFile.FullName;
var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
Comment thread
mitchdenny marked this conversation as resolved.
targetPath,
projectFile.FullName,
executionContext.HomeDirectory.FullName,
Environment.ProcessId,
logger);
Expand All @@ -162,7 +161,9 @@ public async Task<AppHostConnectionResult> 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
{
Expand Down
6 changes: 5 additions & 1 deletion src/Aspire.Cli/Utils/AppHostHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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, "<Project />");

// 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(
Expand Down
4 changes: 3 additions & 1 deletion tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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, "<Project />");

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)]
Expand Down
Loading