| File: Backchannel\ExtensionBackchannelTests.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 Aspire.Cli.Backchannel; using Aspire.Cli.Tests.Utils; using Aspire.Hosting; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Backchannel; public class ExtensionBackchannelTests(ITestOutputHelper outputHelper) { [Fact] public async Task ConnectAsync_WhenConnectionSetupFails_PropagatesFailureAndAllowsRetry() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var backchannel = CreateBackchannel("not-a-valid-endpoint", workspace.CreateExecutionContext()); await Assert.ThrowsAsync<ArgumentException>(() => backchannel.ConnectAsync(CancellationToken.None)).DefaultTimeout(); await Assert.ThrowsAsync<ArgumentException>(() => backchannel.ConnectAsync(CancellationToken.None)).DefaultTimeout(); } [Fact] public async Task ConnectAsync_WhenConnectionSetupFails_PropagatesFailureToConcurrentWaitersAndAllowsRetry() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var setupEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseSetup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var setupException = new InvalidOperationException("Simulated setup failure."); var backchannel = CreateBackchannel( "127.0.0.1:1", workspace.CreateExecutionContext(), async _ => { setupEntered.TrySetResult(); await releaseSetup.Task; throw setupException; }); var firstConnectTask = backchannel.ConnectAsync(CancellationToken.None); await setupEntered.Task.DefaultTimeout(); var waiterTasks = Enumerable.Range(0, 4) .Select(_ => backchannel.ConnectAsync(CancellationToken.None)) .ToArray(); await Task.Delay(100).DefaultTimeout(); releaseSetup.SetResult(); var exceptions = await Task.WhenAll( waiterTasks.Prepend(firstConnectTask).Select(async task => await Record.ExceptionAsync(() => task))) .DefaultTimeout(); Assert.All(exceptions, exception => Assert.Same(setupException, exception)); var retryException = await Assert.ThrowsAsync<InvalidOperationException>(() => backchannel.ConnectAsync(CancellationToken.None)).DefaultTimeout(); Assert.Same(setupException, retryException); } [Fact] public async Task ConnectAsync_WhenExtensionIsIncompatible_PropagatesFailureToConcurrentWaitersWithoutRetrying() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var setupEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseSetup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var setupException = new ExtensionIncompatibleException("Simulated incompatible extension.", "test-capability"); var connectAttempts = 0; var backchannel = CreateBackchannel( "127.0.0.1:1", workspace.CreateExecutionContext(), async _ => { Interlocked.Increment(ref connectAttempts); setupEntered.TrySetResult(); await releaseSetup.Task; throw setupException; }); var firstConnectTask = backchannel.ConnectAsync(CancellationToken.None); await setupEntered.Task.DefaultTimeout(); var waiterTasks = Enumerable.Range(0, 4) .Select(_ => backchannel.ConnectAsync(CancellationToken.None)) .ToArray(); await Task.Delay(100).DefaultTimeout(); releaseSetup.SetResult(); var exceptions = await Task.WhenAll( waiterTasks.Prepend(firstConnectTask).Select(async task => await Record.ExceptionAsync(() => task))) .DefaultTimeout(); Assert.All(exceptions, exception => Assert.Same(setupException, exception)); var retryException = await Assert.ThrowsAsync<ExtensionIncompatibleException>(() => backchannel.ConnectAsync(CancellationToken.None)).DefaultTimeout(); Assert.Same(setupException, retryException); Assert.Equal(1, connectAttempts); } [Fact] public async Task ConnectAsync_WhenConnectorIsCanceled_ConcurrentWaiterTakesOverSetup() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var firstConnectorCts = new CancellationTokenSource(); var firstSetupEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var takeoverSetupEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseTakeoverSetup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var setupException = new ExtensionIncompatibleException("Simulated takeover setup failure.", "test-capability"); var connectAttempts = 0; var backchannel = CreateBackchannel( "127.0.0.1:1", workspace.CreateExecutionContext(), async cancellationToken => { var attempt = Interlocked.Increment(ref connectAttempts); if (attempt == 1) { firstSetupEntered.TrySetResult(); await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return; } takeoverSetupEntered.TrySetResult(); await releaseTakeoverSetup.Task; throw setupException; }); var firstConnectTask = backchannel.ConnectAsync(firstConnectorCts.Token); await firstSetupEntered.Task.DefaultTimeout(); var waiterTasks = Enumerable.Range(0, 4) .Select(_ => backchannel.ConnectAsync(CancellationToken.None)) .ToArray(); await Task.Delay(100).DefaultTimeout(); await firstConnectorCts.CancelAsync(); await Assert.ThrowsAsync<TaskCanceledException>(() => firstConnectTask).DefaultTimeout(); await takeoverSetupEntered.Task.DefaultTimeout(); releaseTakeoverSetup.SetResult(); var waiterExceptions = await Task.WhenAll( waiterTasks.Select(async task => await Record.ExceptionAsync(() => task))) .DefaultTimeout(); Assert.All(waiterExceptions, exception => Assert.Same(setupException, exception)); Assert.Equal(2, connectAttempts); } private static ExtensionBackchannel CreateBackchannel( string endpoint, CliExecutionContext executionContext, Func<CancellationToken, Task>? connectCoreAsyncOverride = null) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { [KnownConfigNames.ExtensionEndpoint] = endpoint, [KnownConfigNames.ExtensionToken] = "test-token" }) .Build(); return new ExtensionBackchannel(NullLogger<ExtensionBackchannel>.Instance, new ExtensionRpcTarget(configuration, executionContext), configuration, connectCoreAsyncOverride); } }