File: Backchannel\AuxiliaryBackchannelMonitorTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Net.Sockets;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Hosting.Backchannel;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests.Backchannel;
 
public class AuxiliaryBackchannelMonitorTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task DefaultWatchEmitsInitialConnectionOnlyOnce()
    {
        var homeDirectory = CreateSocketSafeHomeDirectory();
        try
        {
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), new FakeTimeProvider(), profiling);
            using var cancellation = new CancellationTokenSource();
            using var server = new TestAuxiliaryBackchannelServer(
                CreateLiveOwnerSocketPath(homeDirectory), Path.Combine(homeDirectory.FullName, "MyApp.AppHost.csproj"));
            var accepted = server.AcceptAsync(cancellation.Token);
            await using var watch = monitor.WatchConnectionsAsync(cancellation.Token).GetAsyncEnumerator();
 
            Assert.True(await watch.MoveNextAsync().AsTask().DefaultTimeout());
            await accepted.DefaultTimeout();
            Assert.Single(watch.Current);
 
            var next = watch.MoveNextAsync().AsTask();
            try
            {
                Assert.False(next.IsCompleted, "The initial scan's connection notification must not repeat the initial snapshot.");
            }
            finally
            {
                await cancellation.CancelAsync();
            }
 
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next).DefaultTimeout();
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task DefaultWatchEmitsReplacementConnectionForSameAppHost()
    {
        var homeDirectory = CreateSocketSafeHomeDirectory();
        try
        {
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), new FakeTimeProvider(), profiling);
            using var cancellation = new CancellationTokenSource();
            var socketPath = CreateLiveOwnerSocketPath(homeDirectory);
            var appHostPath = Path.Combine(homeDirectory.FullName, "MyApp.AppHost.csproj");
            using var first = new TestAuxiliaryBackchannelServer(socketPath, appHostPath);
            var accepted = first.AcceptAsync(cancellation.Token);
            await using var watch = monitor.WatchConnectionsAsync(cancellation.Token).GetAsyncEnumerator();
 
            Assert.True(await watch.MoveNextAsync().AsTask().DefaultTimeout());
            await accepted.DefaultTimeout();
            var firstConnection = Assert.Single(watch.Current);
 
            // Leave the reader at its first yield until replacement completes, so the next
            // snapshot has a new connection but the same AppHost path, PID, and count.
            first.Dispose();
            using var replacement = new TestAuxiliaryBackchannelServer(
                socketPath.Replace("a1b2C3d4", "e5f6G7h8", StringComparison.Ordinal), appHostPath);
            accepted = replacement.AcceptAsync(cancellation.Token);
            await monitor.ScanAsync(cancellation.Token).DefaultTimeout();
            await accepted.DefaultTimeout();
 
            var next = watch.MoveNextAsync().AsTask();
            try
            {
                Assert.True(await next.DefaultTimeout());
            }
            finally
            {
                if (!next.IsCompleted)
                {
                    await cancellation.CancelAsync();
                    await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next).DefaultTimeout();
                }
            }
 
            var replacementConnection = Assert.Single(watch.Current);
            Assert.NotSame(firstConnection, replacementConnection);
            Assert.Equal(firstConnection.AppHostInfo!.AppHostPath, replacementConnection.AppHostInfo!.AppHostPath);
            Assert.Equal(firstConnection.AppHostInfo.ProcessId, replacementConnection.AppHostInfo.ProcessId);
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void FindConnectionByAppHostPath_WithCaseVariant_FollowsCurrentVolumeBehavior()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var directory = workspace.WorkspaceRoot.CreateSubdirectory("CaseSensitiveAppHost");
        var actualPath = Path.Combine(directory.FullName, "CaseSensitive.AppHost.csproj");
        File.WriteAllText(actualPath, "<Project />");
        var caseVariant = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            "casesensitiveapphost",
            "casesensitive.apphost.csproj");
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = actualPath,
                ProcessId = 1
            }
        };
 
        var result = AppHostConnectionHelper.FindConnectionByAppHostPath(
            [connection],
            caseVariant);
 
        Assert.Equal(File.Exists(caseVariant), result is not null);
    }
 
    [Fact]
    public void AppHostPathComparer_WithDistinctCaseSensitivePaths_DoesNotUseCaseInsensitiveFallback()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var lowercaseDirectory = workspace.WorkspaceRoot.CreateSubdirectory("case-sensitive-apphost");
        var uppercaseDirectoryPath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            "CASE-SENSITIVE-APPHOST");
 
        Assert.SkipWhen(
            Directory.Exists(uppercaseDirectoryPath),
            "The current temporary filesystem does not allow case-distinct directories.");
 
        var uppercaseDirectory = Directory.CreateDirectory(uppercaseDirectoryPath);
        var lowercaseAppHostPath = Path.Combine(lowercaseDirectory.FullName, "AppHost.csproj");
        var uppercaseAppHostPath = Path.Combine(uppercaseDirectory.FullName, "AppHost.csproj");
        File.WriteAllText(lowercaseAppHostPath, "<Project />");
        File.WriteAllText(uppercaseAppHostPath, "<Project />");
 
        Assert.False(AppHostPathComparer.PathsEqual(
            lowercaseAppHostPath,
            uppercaseAppHostPath,
            StringComparer.OrdinalIgnoreCase));
    }
 
    [Fact]
    public void AppHostPathComparer_WithExactText_UsesFastPathBeforeFallback()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var missingPath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            "missing",
            "AppHost.csproj");
 
        Assert.True(AppHostPathComparer.PathsEqual(
            missingPath,
            missingPath,
            NeverEqualStringComparer.Instance));
    }
 
    [Fact]
    public void AppHostPathComparer_WhenOnlyOnePathCanonicalizes_DoesNotUseFallback()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var existingPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Existing.AppHost.csproj");
        var missingPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Missing.AppHost.csproj");
        File.WriteAllText(existingPath, "<Project />");
 
        Assert.False(AppHostPathComparer.PathsEqual(
            existingPath,
            missingPath,
            AlwaysEqualStringComparer.Instance));
    }
 
    [Fact]
    public async Task FindConnectionByAppHostPath_WithWindowsCaseSensitiveDirectory_DoesNotMatchCaseVariant()
    {
        Assert.SkipUnless(
            OperatingSystem.IsWindows(),
            "Per-directory case sensitivity is only available on Windows.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var caseSensitiveDirectory = workspace.WorkspaceRoot.CreateSubdirectory("case-sensitive");
        var (caseSensitivityEnabled, failureReason) = await TryEnableWindowsCaseSensitivityAsync(
            caseSensitiveDirectory.FullName,
            TestContext.Current.CancellationToken);
 
        Assert.SkipUnless(
            caseSensitivityEnabled,
            $"The environment could not enable per-directory case sensitivity: {failureReason}");
 
        var actualPath = Path.Combine(caseSensitiveDirectory.FullName, "CaseSensitive.AppHost.csproj");
        var caseVariant = Path.Combine(caseSensitiveDirectory.FullName, "casesensitive.apphost.csproj");
        File.WriteAllText(actualPath, "<Project />");
 
        Assert.SkipWhen(
            File.Exists(caseVariant),
            "The environment did not create a case-sensitive directory.");
 
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = actualPath,
                ProcessId = 1
            }
        };
 
        var result = AppHostConnectionHelper.FindConnectionByAppHostPath(
            [connection],
            caseVariant);
 
        Assert.Null(result);
    }
 
    [Fact]
    public void AppHostPathComparer_ObservesRetargetedSymlink()
    {
        Assert.SkipUnless(
            OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink mutation test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var firstTarget = Path.Combine(workspace.WorkspaceRoot.FullName, "first.csproj");
        var secondTarget = Path.Combine(workspace.WorkspaceRoot.FullName, "second.csproj");
        File.WriteAllText(firstTarget, "<Project />");
        File.WriteAllText(secondTarget, "<Project />");
        var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "current.csproj");
        File.CreateSymbolicLink(linkPath, firstTarget);
 
        Assert.True(AppHostPathComparer.PathsEqual(linkPath, firstTarget));
 
        File.Delete(linkPath);
        File.CreateSymbolicLink(linkPath, secondTarget);
 
        // Canonical path results cannot be memoized: the same lexical path can identify
        // a different AppHost after an ordinary filesystem mutation.
        Assert.False(AppHostPathComparer.PathsEqual(linkPath, firstTarget));
        Assert.True(AppHostPathComparer.PathsEqual(linkPath, secondTarget));
    }
 
    [Fact]
    public async Task ReadOnlyWatchEmitsOnlyConnectionReferenceChanges()
    {
        var homeDirectory = CreateSocketSafeHomeDirectory();
        try
        {
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            var time = new FakeTimeProvider();
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), time, profiling);
            using var cancellation = new CancellationTokenSource();
            await using var watch = monitor.WatchConnectionsAsync(cancellation.Token, readOnly: true).GetAsyncEnumerator();
 
            Assert.True(await watch.MoveNextAsync().AsTask().DefaultTimeout());
            Assert.Empty(watch.Current);
            var next = watch.MoveNextAsync().AsTask();
            for (var tick = 0; tick < 3; tick++)
            {
                time.Advance(TimeSpan.FromSeconds(1));
                Assert.False(next.IsCompleted);
            }
 
            var socketPath = CreateLiveOwnerSocketPath(homeDirectory);
            var appHostPath = Path.Combine(homeDirectory.FullName, "MyApp.AppHost.csproj");
            using var first = new TestAuxiliaryBackchannelServer(socketPath, appHostPath);
            var accepted = first.AcceptAsync(cancellation.Token);
            time.Advance(TimeSpan.FromSeconds(1));
            Assert.True(await next.DefaultTimeout());
            await accepted.DefaultTimeout();
            var firstConnection = Assert.Single(watch.Current);
 
            next = watch.MoveNextAsync().AsTask();
            for (var tick = 0; tick < 3; tick++)
            {
                time.Advance(TimeSpan.FromSeconds(1));
                Assert.False(next.IsCompleted);
            }
 
            var secondSocketPath = socketPath.Replace("a1b2C3d4", "e5f6G7h8", StringComparison.Ordinal);
            using var second = new TestAuxiliaryBackchannelServer(secondSocketPath, appHostPath);
            accepted = second.AcceptAsync(cancellation.Token);
            time.Advance(TimeSpan.FromSeconds(1));
            Assert.True(await next.DefaultTimeout());
            await accepted.DefaultTimeout();
            Assert.Equal(2, watch.Current.Count);
            Assert.Contains(firstConnection, watch.Current);
            var secondConnection = Assert.Single(watch.Current, connection => !ReferenceEquals(connection, firstConnection));
 
            next = watch.MoveNextAsync().AsTask();
            first.RemoveSocketFile();
            time.Advance(TimeSpan.FromSeconds(1));
            Assert.True(await next.DefaultTimeout());
            Assert.Same(secondConnection, Assert.Single(watch.Current));
 
            // Replace the only connection between polls with another connection for the same
            // AppHost identity. Comparing identities or connection counts would miss this.
            next = watch.MoveNextAsync().AsTask();
            second.RemoveSocketFile();
            var replacementSocketPath = socketPath.Replace("a1b2C3d4", "i9j0K1l2", StringComparison.Ordinal);
            using var replacement = new TestAuxiliaryBackchannelServer(replacementSocketPath, appHostPath);
            accepted = replacement.AcceptAsync(cancellation.Token);
            time.Advance(TimeSpan.FromSeconds(1));
            Assert.True(await next.DefaultTimeout());
            await accepted.DefaultTimeout();
            var replacementConnection = Assert.Single(watch.Current);
            Assert.NotSame(secondConnection, replacementConnection);
            Assert.Equal(secondConnection.AppHostInfo!.AppHostPath, replacementConnection.AppHostInfo!.AppHostPath);
            Assert.Equal(secondConnection.AppHostInfo.ProcessId, replacementConnection.AppHostInfo.ProcessId);
 
            next = watch.MoveNextAsync().AsTask();
            replacement.RemoveSocketFile();
            time.Advance(TimeSpan.FromSeconds(1));
            Assert.True(await next.DefaultTimeout());
            Assert.Empty(watch.Current);
 
            next = watch.MoveNextAsync().AsTask();
            time.Advance(TimeSpan.FromSeconds(1));
            Assert.False(next.IsCompleted);
            cancellation.Cancel();
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next).DefaultTimeout();
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task ReadOnlyWatchEmitsInitialEmptyStateWithoutCreatingDirectories()
    {
        var homeDirectory = Directory.CreateTempSubdirectory("tray-readonly-");
        try
        {
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), new FakeTimeProvider(), profiling);
            using var cancellation = new CancellationTokenSource();
            await using var watch = monitor.WatchConnectionsAsync(cancellation.Token, readOnly: true).GetAsyncEnumerator();
 
            Assert.True(await watch.MoveNextAsync().AsTask().DefaultTimeout());
            Assert.Empty(watch.Current);
            Assert.All(AppHostSocketManager.GetSocketDirectories(homeDirectory.FullName),
                directory => Assert.False(Directory.Exists(directory.DirectoryPath)));
 
            cancellation.Cancel();
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => watch.MoveNextAsync().AsTask()).DefaultTimeout();
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ScanAsyncPrunesDeadSocketsOnlyWhenRequested(bool prune)
    {
        var homeDirectory = Directory.CreateTempSubdirectory("tray-orphans-");
        try
        {
            var compactDirectory = BackchannelConstants.GetBackchannelsDirectory(homeDirectory.FullName);
            var legacyDirectory = BackchannelConstants.GetLegacyBackchannelsDirectory(homeDirectory.FullName);
            Directory.CreateDirectory(compactDirectory);
            Directory.CreateDirectory(legacyDirectory);
            var compactPath = Path.Combine(compactDirectory, $"{BackchannelConstants.ComputeAppHostId("AppHost.cs")}a1b2C3d4.{int.MaxValue}");
            var legacyPath = Path.Combine(legacyDirectory, $"auxi.sock.abc123def4567890.{int.MaxValue}");
            File.WriteAllText(compactPath, "orphan");
            File.WriteAllText(legacyPath, "orphan");
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), new FakeTimeProvider(), profiling);
 
            await monitor.ScanAsync(pruneOrphanedSockets: prune, throwOnDiscoveryFailure: true).DefaultTimeout();
 
            Assert.Empty(monitor.Connections);
            Assert.Equal(!prune, File.Exists(compactPath));
            Assert.Equal(!prune, File.Exists(legacyPath));
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ReadOnlyWatchPropagatesDirectoryFailuresBeforeOrAfterInitialState(bool afterInitial)
    {
        var homeDirectory = Directory.CreateTempSubdirectory("tray-discovery-");
        try
        {
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            var time = new FakeTimeProvider();
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), time, profiling);
            await using var watch = monitor.WatchConnectionsAsync(readOnly: true).GetAsyncEnumerator();
            if (afterInitial)
            {
                Assert.True(await watch.MoveNextAsync().AsTask().DefaultTimeout());
                Assert.Empty(watch.Current);
            }
 
            var legacyDirectory = BackchannelConstants.GetLegacyBackchannelsDirectory(homeDirectory.FullName);
            Directory.CreateDirectory(Path.GetDirectoryName(legacyDirectory)!);
            File.WriteAllText(legacyDirectory, "Not a directory.");
            var next = watch.MoveNextAsync().AsTask();
            time.Advance(TimeSpan.FromSeconds(1));
 
            await Assert.ThrowsAnyAsync<IOException>(() => next).DefaultTimeout();
            Assert.True(File.Exists(legacyDirectory));
 
            // Preserve the legacy scanner's permissive handling of unavailable directories.
            await monitor.ScanAsync().DefaultTimeout();
            Assert.Empty(monitor.Connections);
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task FailedConnectionCleanupHonorsReadOnlyForPidlessAndDeadSockets(bool pidQualified, bool prune)
    {
        var homeDirectory = Directory.CreateTempSubdirectory("tray-connect-");
        try
        {
            var socketPath = Path.Combine(homeDirectory.FullName, "stale-socket");
            File.WriteAllText(socketPath, "stale");
            var socket = new TestAppHostSocket(socketPath)
            {
                ProcessId = pidQualified ? int.MaxValue : null,
                ConnectAsyncCallback = _ => ValueTask.FromException<Socket>(new SocketException((int)SocketError.ConnectionRefused))
            };
            using var profiling = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            var time = new FakeTimeProvider(DateTimeOffset.UtcNow.AddSeconds(1));
            using var monitor = new AuxiliaryBackchannelMonitor(
                new CapturingLogger<AuxiliaryBackchannelMonitor>(), CreateExecutionContext(homeDirectory), time, profiling);
 
            await PumpUntilCompletedAsync(monitor.TryConnectToSocketAsync(
                socket, new ConcurrentBag<string>(), prune, CancellationToken.None), time).DefaultTimeout();
 
            Assert.Equal(prune ? 1 : 0, socket.TryDeleteCallCount);
            Assert.Equal(!prune, File.Exists(socketPath));
            Assert.Empty(monitor.Connections);
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void IsAppHostInScopeOfDirectory_WithSymlinkedPaths_IsInScope()
    {
        // The OS reports a process's current directory physically (for example macOS temp dirs under
        // /var -> /private/var), while a file-based AppHost reports its path unresolved. The in-scope check
        // must resolve symlinks on both operands or it treats an in-scope AppHost as out of scope, which made
        // CWD-based 'aspire describe' report "No running AppHost found". See https://github.com/microsoft/aspire/issues/17618.
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-symlink-");
        try
        {
            var realDirectory = Directory.CreateDirectory(Path.Combine(tempRoot.FullName, "real"));
            var symlinkDirectory = Path.Combine(tempRoot.FullName, "link");
            Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
 
            // AppHost reported through the real directory, working directory reached through the symlink.
            var appHostPathViaReal = Path.Combine(realDirectory.FullName, "apphost.cs");
            Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(appHostPathViaReal, symlinkDirectory));
 
            // And the reverse: AppHost reached through the symlink, working directory the real path.
            var appHostPathViaSymlink = Path.Combine(symlinkDirectory, "apphost.cs");
            Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(appHostPathViaSymlink, realDirectory.FullName));
        }
        finally
        {
            tempRoot.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void IsAppHostInScopeOfDirectory_AppHostOutsideWorkingDirectory_IsNotInScope()
    {
        var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-");
        try
        {
            var workingDirectory = Directory.CreateDirectory(Path.Combine(tempRoot.FullName, "wd")).FullName;
            var outsideAppHost = Path.Combine(tempRoot.FullName, "other", "apphost.cs");
 
            Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(outsideAppHost, workingDirectory));
        }
        finally
        {
            tempRoot.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void IsAppHostInScopeOfDirectory_NullOrEmptyAppHostPath_IsNotInScope()
    {
        Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(null, Path.GetTempPath()));
        Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(string.Empty, Path.GetTempPath()));
    }
 
    [Fact]
    public void IsAppHostInScopeOfDirectory_NestedLinkedWorktree_IsNotInScopeOfPrimary()
    {
        var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-worktree-");
        try
        {
            var primaryRoot = tempRoot.FullName;
            Directory.CreateDirectory(Path.Combine(primaryRoot, ".git"));
            var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName;
            TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git"));
 
            var primaryAppHost = Path.Combine(primaryRoot, "AppHost.csproj");
            var nestedAppHost = Path.Combine(worktreeRoot, "AppHost.csproj");
 
            Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, primaryRoot));
            Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, primaryRoot));
            Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, worktreeRoot));
            Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, worktreeRoot));
        }
        finally
        {
            tempRoot.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void IsAppHostInScopeOfDirectory_Submodule_IsInScopeOfPrimary()
    {
        var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-submodule-");
        try
        {
            var primaryRoot = tempRoot.FullName;
            Directory.CreateDirectory(Path.Combine(primaryRoot, ".git"));
            var submoduleRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, "extern", "dep")).FullName;
            TestGitWorktree.WriteGitDirFile(
                submoduleRoot,
                Path.Combine(primaryRoot, ".git", "modules", "dep"));
 
            var submoduleAppHost = Path.Combine(submoduleRoot, "AppHost.csproj");
            Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(submoduleAppHost, primaryRoot));
        }
        finally
        {
            tempRoot.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void IsAppHostInScopeOfDirectory_SubmoduleInsideLinkedWorktree_UsesEnclosingWorktree()
    {
        var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-linked-submodule-");
        try
        {
            var primaryRoot = tempRoot.FullName;
            Directory.CreateDirectory(Path.Combine(primaryRoot, ".git"));
            var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName;
            var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git"));
            var submoduleRoot = Directory.CreateDirectory(Path.Combine(worktreeRoot, "extern", "dep")).FullName;
            TestGitWorktree.WriteGitDirFile(submoduleRoot, Path.Combine(adminDirectory, "modules", "dep"));
            var submoduleAppHost = Path.Combine(submoduleRoot, "AppHost.csproj");
 
            Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(submoduleAppHost, worktreeRoot));
            Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(submoduleAppHost, primaryRoot));
        }
        finally
        {
            tempRoot.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task ScanAsync_UnreachableSocketWithLiveProcess_IsNotRetriedUntilBackoffExpires()
    {
        // A socket file whose PID is alive but that refuses connections is the PID-reuse shape:
        // the AppHost is gone, yet its PID was recycled by an unrelated process, so the monitor is
        // not allowed to delete the file (deleting a socket whose AppHost is actually alive makes
        // that AppHost undiscoverable for the rest of its lifetime). Before the backoff was added,
        // such a socket was pushed back onto the "new sockets" list on every scan, so every single
        // scan paid the full connect retry budget. MCP tools scan frequently, so that was seconds
        // of dead time per call, forever.
        var homeDirectory = CreateSocketSafeHomeDirectory();
        try
        {
            var socketPath = CreateLiveOwnerSocketPath(homeDirectory);
 
            // Bound but never listening, so every connect attempt is refused while the file stays on disk.
            using var unreachableSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            unreachableSocket.Bind(new UnixDomainSocketEndPoint(socketPath));
 
            var logger = new CapturingLogger<AuxiliaryBackchannelMonitor>();
            var timeProvider = new FakeTimeProvider();
            using var profilingTelemetry = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            using var monitor = new AuxiliaryBackchannelMonitor(logger, CreateExecutionContext(homeDirectory), timeProvider, profilingTelemetry);
 
            // The socket is discovered and the connect retry budget is burned down once.
            await PumpUntilCompletedAsync(monitor.ScanAsync(), timeProvider).DefaultTimeout();
            Assert.Equal(1, CountConnectAttempts(logger, socketPath));
 
            // The file must survive: its PID is alive, so the monitor cannot prove the socket is dead.
            Assert.True(File.Exists(socketPath));
 
            // The regression: a second scan inside the backoff window must not touch the socket at all.
            // If it did, this await would hang because the retry loop's delays run on the fake clock.
            await monitor.ScanAsync().DefaultTimeout();
            Assert.Equal(1, CountConnectAttempts(logger, socketPath));
 
            // Once the backoff expires the socket is reconsidered, so a genuinely restarted AppHost
            // reusing the same socket path is still picked up.
            timeProvider.Advance(TimeSpan.FromMinutes(1));
            await PumpUntilCompletedAsync(monitor.ScanAsync(), timeProvider).DefaultTimeout();
            Assert.Equal(2, CountConnectAttempts(logger, socketPath));
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task ScanAsync_ConcurrentScansDoNotFanOutRetriesForTheSameSocket()
    {
        // Retry candidates are selected under _scanLock, but the connect attempts are awaited after the
        // lock is released and the backoff is only escalated once the retry budget is exhausted. That
        // leaves a window, as wide as the whole retry budget, in which another scan re-selects the same
        // socket and starts its own connect loop. MCP tools scan frequently enough to overlap, so a
        // single stale socket could still fan out concurrent retries and undo the backoff.
        var homeDirectory = CreateSocketSafeHomeDirectory();
        try
        {
            var socketPath = CreateLiveOwnerSocketPath(homeDirectory);
 
            using var unreachableSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            unreachableSocket.Bind(new UnixDomainSocketEndPoint(socketPath));
 
            var logger = new CapturingLogger<AuxiliaryBackchannelMonitor>();
            var timeProvider = new FakeTimeProvider();
            using var profilingTelemetry = new ProfilingTelemetry(new ConfigurationBuilder().Build());
            using var monitor = new AuxiliaryBackchannelMonitor(logger, CreateExecutionContext(homeDirectory), timeProvider, profilingTelemetry);
 
            await PumpUntilCompletedAsync(monitor.ScanAsync(), timeProvider).DefaultTimeout();
            Assert.Equal(1, CountConnectAttempts(logger, socketPath));
 
            timeProvider.Advance(TimeSpan.FromMinutes(1));
 
            // The first scan claims the due retry and parks in its connect loop waiting on the fake clock.
            // Deliberately left unpumped so the claim is still in flight for the whole of the second scan.
            var claimingScan = monitor.ScanAsync();
            await WaitForConnectAttemptsAsync(logger, socketPath, expectedAttempts: 2).DefaultTimeout();
 
            // The overlapping scan must find nothing to do. Were it to re-select the claimed socket it
            // would start a second connect loop and this await would hang, because those retry delays
            // also run on the fake clock and nothing is advancing it.
            await monitor.ScanAsync().DefaultTimeout();
            Assert.Equal(2, CountConnectAttempts(logger, socketPath));
 
            await PumpUntilCompletedAsync(claimingScan, timeProvider).DefaultTimeout();
            Assert.Equal(2, CountConnectAttempts(logger, socketPath));
        }
        finally
        {
            homeDirectory.Delete(recursive: true);
        }
    }
 
    /// <summary>
    /// Waits until <paramref name="expectedAttempts"/> connect attempts have been made against
    /// <paramref name="socketPath"/>, failing with the captured log rather than hanging if they never arrive.
    /// </summary>
    private static async Task WaitForConnectAttemptsAsync(CapturingLogger<AuxiliaryBackchannelMonitor> logger, string socketPath, int expectedAttempts)
    {
        var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
        while (CountConnectAttempts(logger, socketPath) < expectedAttempts)
        {
            if (DateTime.UtcNow > deadline)
            {
                Assert.Fail(
                    $"Timed out waiting for {expectedAttempts} connect attempt(s) on '{socketPath}'. " +
                    $"Saw {CountConnectAttempts(logger, socketPath)}. Captured log:{Environment.NewLine}" +
                    string.Join(Environment.NewLine, logger.Entries.Select(entry => $"  [{entry.Level}/{entry.EventId}] {entry.Message}")));
            }
 
            await Task.Delay(1).ConfigureAwait(false);
        }
    }
 
    private static int CountConnectAttempts(CapturingLogger<AuxiliaryBackchannelMonitor> logger, string socketPath)
        => logger.Entries.Count(entry =>
            entry.EventId == AuxiliaryBackchannelMonitor.ConnectingToSocketEvent &&
            entry.Message.Contains(socketPath, StringComparison.Ordinal));
 
    /// <summary>
    /// Creates a stand-in home directory whose generated socket paths fit the platform's AF_UNIX byte limit.
    /// </summary>
    /// <remarks>
    /// The path the monitor ends up binding against is <c>{home}/.aspire/cli/bch/{19 chars}.{pid}</c>,
    /// which adds a fixed ~45 bytes on top of the home directory. macOS allows only 104 bytes for the
    /// whole path and its per-user temp root (<c>/var/folders/&lt;2&gt;/&lt;30&gt;/T</c>) already spends 48,
    /// so the prefix here is kept to a single character to stay inside the budget. Lengthening it puts
    /// the generated path over the limit on macOS and silently skips every test that calls this.
    /// </remarks>
    private static DirectoryInfo CreateSocketSafeHomeDirectory() => Directory.CreateTempSubdirectory("a");
 
    /// <summary>
    /// Builds a socket path under <paramref name="homeDirectory"/> whose embedded PID belongs to a
    /// process that is definitely alive, so the monitor is never permitted to delete the file.
    /// </summary>
    /// <remarks>
    /// The name is composed by hand rather than through <c>ComputeSocketPathFromAppHostId</c> because
    /// that helper throws on an over-long path, and whether the path fits is exactly what the skip below
    /// needs to decide. Callers bind a real AF_UNIX socket here: connecting to a regular file fails with
    /// ENOTSOCK rather than ECONNREFUSED and would take a different code path entirely.
    /// </remarks>
    private static string CreateLiveOwnerSocketPath(DirectoryInfo homeDirectory)
    {
        var backchannelsDirectory = BackchannelConstants.GetBackchannelsDirectory(homeDirectory.FullName);
        Directory.CreateDirectory(backchannelsDirectory);
 
        var appHostId = BackchannelConstants.ComputeAppHostId(Path.Combine(homeDirectory.FullName, "MyApp.AppHost.csproj"));
        var socketPath = Path.Combine(backchannelsDirectory, $"{appHostId}a1b2C3d4.{Environment.ProcessId}");
        Assert.SkipWhen(
            BackchannelConstants.GetSocketPathByteCountIncludingNull(socketPath) > BackchannelConstants.GetMaxSocketPathBytesIncludingNull(),
            $"The temp directory is too long to host an AF_UNIX socket on this platform: '{socketPath}'.");
 
        return socketPath;
    }
 
    /// <summary>
    /// Drives <paramref name="task"/> to completion while advancing <paramref name="timeProvider"/>,
    /// which the monitor's connect retry loop uses for both its elapsed-time budget and its delays.
    /// </summary>
    private static async Task PumpUntilCompletedAsync(Task task, FakeTimeProvider timeProvider)
    {
        while (!task.IsCompleted)
        {
            timeProvider.Advance(TimeSpan.FromSeconds(1));
 
            // Yield on the real clock so the retry loop can observe the advance and register its next delay.
            await Task.Delay(1).ConfigureAwait(false);
        }
 
        await task.ConfigureAwait(false);
    }
 
    private static CliExecutionContext CreateExecutionContext(DirectoryInfo homeDirectory)
        => new(
            workingDirectory: homeDirectory,
            hivesDirectory: homeDirectory,
            cacheDirectory: homeDirectory,
            sdksDirectory: homeDirectory,
            logsDirectory: homeDirectory,
            logFilePath: Path.Combine(homeDirectory.FullName, "test.log"),
            identityChannel: "local",
            homeDirectory: homeDirectory);
 
    private static async Task<(bool Success, string FailureReason)> TryEnableWindowsCaseSensitivityAsync(
        string directoryPath,
        CancellationToken cancellationToken)
    {
        var startInfo = new ProcessStartInfo
        {
            FileName = "fsutil.exe",
            CreateNoWindow = true,
            RedirectStandardError = true,
            RedirectStandardOutput = true,
            UseShellExecute = false
        };
        startInfo.ArgumentList.Add("file");
        startInfo.ArgumentList.Add("setCaseSensitiveInfo");
        startInfo.ArgumentList.Add(directoryPath);
        startInfo.ArgumentList.Add("enable");
 
        try
        {
            using var process = Process.Start(startInfo);
            if (process is null)
            {
                return (false, "fsutil.exe did not start.");
            }
 
            // Read both streams concurrently because fsutil output can otherwise fill one redirected
            // pipe while the test is waiting for the process to exit.
            var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
            var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
            await process.WaitForExitAsync(cancellationToken);
            var standardOutput = await standardOutputTask;
            var standardError = await standardErrorTask;
 
            return process.ExitCode == 0
                ? (true, string.Empty)
                : (false, $"fsutil.exe exited with code {process.ExitCode}. {standardOutput} {standardError}".Trim());
        }
        catch (Exception ex) when (ex is Win32Exception or IOException or InvalidOperationException)
        {
            return (false, ex.Message);
        }
    }
 
    private sealed class AlwaysEqualStringComparer : StringComparer
    {
        public static AlwaysEqualStringComparer Instance { get; } = new();
 
        public override int Compare(string? x, string? y) => 0;
 
        public override bool Equals(string? x, string? y) => true;
 
        public override int GetHashCode(string obj) => 0;
    }
 
    private sealed class NeverEqualStringComparer : StringComparer
    {
        public static NeverEqualStringComparer Instance { get; } = new();
 
        public override int Compare(string? x, string? y) => string.CompareOrdinal(x, y);
 
        public override bool Equals(string? x, string? y) => false;
 
        public override int GetHashCode(string obj) => 0;
    }
}