| File: Backchannel\AppHostCliBackchannelTests.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.Net.Sockets; using Aspire.Cli.Backchannel; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Telemetry; using Aspire.Cli.Tests.Utils; using Aspire.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using StreamJsonRpc; namespace Aspire.Cli.Tests.Backchannel; public class AppHostCliBackchannelTests { [Fact] public async Task UploadFileAsync_ExceedsConfiguredLimit_ThrowsWithConfigHint() { var environment = new TestEnvironment(new Dictionary<string, string?> { [KnownConfigNames.MaxFileUploadSize] = "1024" // 1 KB limit }); var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); using var profilingTelemetry = new ProfilingTelemetry(new ConfigurationBuilder().Build()); var backchannel = new AppHostCliBackchannel( NullLogger<AppHostCliBackchannel>.Instance, environment, telemetry, profilingTelemetry); var tempDir = Directory.CreateTempSubdirectory("aspire-upload-test-"); try { var tempFile = Path.Combine(tempDir.FullName, "large.bin"); await File.WriteAllBytesAsync(tempFile, new byte[2048]); // 2 KB exceeds the 1 KB limit var ex = await Assert.ThrowsAsync<InvalidOperationException>( () => backchannel.UploadFileAsync(tempFile, "large.bin", 1, "File", CancellationToken.None)); Assert.Contains("large.bin", ex.Message); Assert.Contains("2048", ex.Message); Assert.Contains("1024", ex.Message); Assert.Contains(KnownConfigNames.MaxFileUploadSize, ex.Message); } finally { tempDir.Delete(recursive: true); } } [Fact] public async Task RequestStopAsync_AppHostClosesBackchannelBeforeAcknowledging_Completes() { // Reproduces the AppHost shutdown race behind `aspire do --list-steps` failing with exit code 6: // the AppHost calls StopApplication() inside RequestStopAsync, and host shutdown closes the // backchannel before StreamJsonRpc writes the response. var cancellationToken = TestContext.Current.CancellationToken; var tempDir = Directory.CreateTempSubdirectory("aspire-bch-"); try { var socketPath = Path.Combine(tempDir.FullName, "apphost.sock"); using var server = new ClosingOnStopAppHostServer(socketPath); var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); using var profilingTelemetry = new ProfilingTelemetry(new ConfigurationBuilder().Build()); var backchannel = new AppHostCliBackchannel( NullLogger<AppHostCliBackchannel>.Instance, new TestEnvironment(), telemetry, profilingTelemetry); var acceptTask = server.AcceptAsync(cancellationToken); await backchannel.ConnectAsync(socketPath, retryCount: 0, cancellationToken); await acceptTask; await backchannel.RequestStopAsync(CancellationToken.None).WaitAsync(cancellationToken); Assert.True(server.StopRequested.Task.IsCompletedSuccessfully); await backchannel.WaitForDisconnectAsync(cancellationToken); } finally { tempDir.Delete(recursive: true); } } [Fact] public async Task RequestStopAsync_CallerCancelsWhileBackchannelIsOpen_Throws() { var cancellationToken = TestContext.Current.CancellationToken; var tempDir = Directory.CreateTempSubdirectory("aspire-bch-"); try { var socketPath = Path.Combine(tempDir.FullName, "apphost.sock"); using var server = new ClosingOnStopAppHostServer(socketPath, closeOnStop: false); var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); using var profilingTelemetry = new ProfilingTelemetry(new ConfigurationBuilder().Build()); var backchannel = new AppHostCliBackchannel( NullLogger<AppHostCliBackchannel>.Instance, new TestEnvironment(), telemetry, profilingTelemetry); var acceptTask = server.AcceptAsync(cancellationToken); await backchannel.ConnectAsync(socketPath, retryCount: 0, cancellationToken); await acceptTask; using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var stopTask = backchannel.RequestStopAsync(cts.Token); await server.StopRequested.Task.WaitAsync(cancellationToken); cts.Cancel(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stopTask); } finally { tempDir.Delete(recursive: true); } } [Fact] public async Task UploadFileAsync_FileBelowConfiguredLimit_PassesSizeCheck() { var environment = new TestEnvironment(new Dictionary<string, string?> { [KnownConfigNames.MaxFileUploadSize] = "4096" // 4 KB limit }); var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); using var profilingTelemetry = new ProfilingTelemetry(new ConfigurationBuilder().Build()); var backchannel = new AppHostCliBackchannel( NullLogger<AppHostCliBackchannel>.Instance, environment, telemetry, profilingTelemetry); var tempDir = Directory.CreateTempSubdirectory("aspire-upload-test-"); try { var tempFile = Path.Combine(tempDir.FullName, "small.bin"); await File.WriteAllBytesAsync(tempFile, new byte[1024]); // 1 KB, below the 4 KB limit // The file is within the limit, so it should pass the size check and fail later // when trying to get the RPC connection (no connection available). A cancellation // exception proves the size check passed. using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>( () => backchannel.UploadFileAsync(tempFile, "small.bin", 1, "File", cts.Token)); // If we get a cancellation exception (waiting for RPC), the size check passed. Assert.DoesNotContain("exceeds the maximum upload size", ex.Message); } finally { tempDir.Delete(recursive: true); } } private sealed class ClosingOnStopAppHostServer : IDisposable { private readonly Socket _listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); private readonly bool _closeOnStop; private JsonRpc? _rpc; public ClosingOnStopAppHostServer(string socketPath, bool closeOnStop = true) { _closeOnStop = closeOnStop; _listener.Bind(new UnixDomainSocketEndPoint(socketPath)); _listener.Listen(1); } public TaskCompletionSource StopRequested { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task AcceptAsync(CancellationToken cancellationToken) { var socket = await _listener.AcceptAsync(cancellationToken); var stream = new NetworkStream(socket, ownsSocket: true); var handler = new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()); _rpc = new JsonRpc(handler, new RpcTarget(this)); _rpc.StartListening(); } public void Dispose() { _rpc?.Dispose(); _listener.Dispose(); } private sealed class RpcTarget(ClosingOnStopAppHostServer server) { private readonly string[] _capabilities = ["baseline.v2"]; public Task<string[]> GetCapabilitiesAsync() => Task.FromResult(_capabilities); public Task RequestStopAsync(CancellationToken cancellationToken) { server.StopRequested.TrySetResult(); if (server._closeOnStop) { // Like AppHost shutdown, close the connection before this call's response is written. server._rpc!.Dispose(); } return Task.Delay(Timeout.Infinite, cancellationToken); } } } }