using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Internal.Protocol; using Microsoft.AspNetCore.Sockets.Client; using Newtonsoft.Json.Linq; using Xunit; namespace Microsoft.AspNetCore.SignalR.Client.Tests { public partial class HubConnectionTests { public class ConnectionLifecycle { // This tactic (using names and a dictionary) allows non-serializable data (like a Func) to be used in a theory AND get it to show in the new hierarchical view in Test Explorer as separate tests you can run individually. private static readonly IDictionary> MethodsThatRequireActiveConnection = new Dictionary>() { { nameof(HubConnection.InvokeAsync), (connection) => connection.InvokeAsync("Foo") }, { nameof(HubConnection.SendAsync), (connection) => connection.SendAsync("Foo") }, { nameof(HubConnection.StreamAsChannelAsync), (connection) => connection.StreamAsChannelAsync("Foo") }, }; public static IEnumerable MethodsNamesThatRequireActiveConnection => MethodsThatRequireActiveConnection.Keys.Select(k => new object[] { k }); [Fact] public async Task StartAsyncStartsTheUnderlyingConnection() { var testConnection = new TestConnection(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { await connection.StartAsync(); Assert.True(testConnection.Started.IsCompleted); }); } [Fact] public async Task StartAsyncWaitsForPreviousStartIfAlreadyStarting() { // Set up StartAsync to wait on the syncPoint when starting var testConnection = new TestConnection(onStart: SyncPoint.Create(out var syncPoint)); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { var firstStart = connection.StartAsync().OrTimeout(); Assert.False(firstStart.IsCompleted); // Wait for us to be in IConnection.StartAsync await syncPoint.WaitForSyncPoint(); // Try starting again var secondStart = connection.StartAsync().OrTimeout(); Assert.False(secondStart.IsCompleted); // Release the sync point syncPoint.Continue(); // Both starts should finish fine await firstStart; await secondStart; }); } [Fact] public async Task StartingAfterStopCreatesANewConnection() { // Set up StartAsync to wait on the syncPoint when starting var createCount = 0; IConnection ConnectionFactory() { createCount += 1; return new TestConnection(); } await AsyncUsing(new HubConnection(ConnectionFactory, new JsonHubProtocol()), async connection => { await connection.StartAsync().OrTimeout(); Assert.Equal(1, createCount); await connection.StopAsync().OrTimeout(); await connection.StartAsync().OrTimeout(); Assert.Equal(2, createCount); }); } [Fact] public async Task StartingDuringStopCreatesANewConnection() { // Set up StartAsync to wait on the syncPoint when starting var createCount = 0; var onDisposeForFirstConnection = SyncPoint.Create(out var syncPoint); IConnection ConnectionFactory() { createCount += 1; return new TestConnection(onDispose: createCount == 1 ? onDisposeForFirstConnection : null); } await AsyncUsing(new HubConnection(ConnectionFactory, new JsonHubProtocol()), async connection => { await connection.StartAsync().OrTimeout(); Assert.Equal(1, createCount); var stopTask = connection.StopAsync().OrTimeout(); // Wait to hit DisposeAsync on TestConnection (which should be after StopAsync has cleared the connection state) await syncPoint.WaitForSyncPoint(); // We should be able to start now, and StopAsync hasn't completed, nor will it complete while Starting Assert.False(stopTask.IsCompleted); await connection.StartAsync().OrTimeout(); Assert.False(stopTask.IsCompleted); // When we release the sync point, the StopAsync task will finish syncPoint.Continue(); await stopTask; }); } [Fact] public async Task StartAsyncWithFailedHandshakeCanBeStopped() { var testConnection = new TestConnection(autoHandshake: false); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { testConnection.Transport.Input.Complete(); try { await connection.StartAsync(); } catch { } await connection.StopAsync(); Assert.True(testConnection.Started.IsCompleted); }); } [Theory] [MemberData(nameof(MethodsNamesThatRequireActiveConnection))] public async Task MethodsThatRequireStartedConnectionFailIfConnectionNotYetStarted(string name) { var method = MethodsThatRequireActiveConnection[name]; var testConnection = new TestConnection(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { var ex = await Assert.ThrowsAsync(() => method(connection)); Assert.Equal($"The '{name}' method cannot be called if the connection is not active", ex.Message); }); } [Theory] [MemberData(nameof(MethodsNamesThatRequireActiveConnection))] public async Task MethodsThatRequireStartedConnectionWaitForStartIfConnectionIsCurrentlyStarting(string name) { var method = MethodsThatRequireActiveConnection[name]; // Set up StartAsync to wait on the syncPoint when starting var testConnection = new TestConnection(onStart: SyncPoint.Create(out var syncPoint)); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { // Start, and wait for the sync point to be hit var startTask = connection.StartAsync().OrTimeout(); Assert.False(startTask.IsCompleted); await syncPoint.WaitForSyncPoint(); // Run the method, but it will be waiting for the lock var targetTask = method(connection).OrTimeout(); // Release the SyncPoint syncPoint.Continue(); // Wait for start to finish await startTask; // We need some special logic to ensure InvokeAsync completes. if (string.Equals(name, nameof(HubConnection.InvokeAsync))) { await ForceLastInvocationToComplete(testConnection); } // Wait for the method to complete. await targetTask; }); } [Fact] public async Task StopAsyncStopsConnection() { var testConnection = new TestConnection(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { await connection.StartAsync().OrTimeout(); Assert.True(testConnection.Started.IsCompleted); await connection.StopAsync().OrTimeout(); Assert.True(testConnection.Disposed.IsCompleted); }); } [Fact] public async Task StopAsyncNoOpsIfConnectionNotYetStarted() { var testConnection = new TestConnection(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { await connection.StopAsync().OrTimeout(); Assert.False(testConnection.Disposed.IsCompleted); }); } [Fact] public async Task StopAsyncNoOpsIfConnectionAlreadyStopped() { var testConnection = new TestConnection(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { await connection.StartAsync().OrTimeout(); Assert.True(testConnection.Started.IsCompleted); await connection.StopAsync().OrTimeout(); Assert.True(testConnection.Disposed.IsCompleted); await connection.StopAsync().OrTimeout(); }); } [Fact] public async Task CompletingTheTransportSideMarksConnectionAsClosed() { var testConnection = new TestConnection(); var closed = new TaskCompletionSource(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { connection.Closed += (e) => closed.TrySetResult(null); await connection.StartAsync().OrTimeout(); Assert.True(testConnection.Started.IsCompleted); // Complete the transport side and wait for the connection to close testConnection.CompleteFromTransport(); await closed.Task.OrTimeout(); // We should be stopped now var ex = await Assert.ThrowsAsync(() => connection.SendAsync("Foo").OrTimeout()); Assert.Equal($"The '{nameof(HubConnection.SendAsync)}' method cannot be called if the connection is not active", ex.Message); }); } [Fact] public async Task TransportCompletionWhileShuttingDownIsNoOp() { var testConnection = new TestConnection(); var testConnectionClosed = new TaskCompletionSource(); var connectionClosed = new TaskCompletionSource(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { // We're hooking the TestConnection shutting down here because the HubConnection one will be blocked on the lock testConnection.Transport.Input.OnWriterCompleted((_, __) => testConnectionClosed.TrySetResult(null), null); connection.Closed += (e) => connectionClosed.TrySetResult(null); await connection.StartAsync().OrTimeout(); Assert.True(testConnection.Started.IsCompleted); // Start shutting down and complete the transport side var stopTask = connection.StopAsync().OrTimeout(); testConnection.CompleteFromTransport(); // Wait for the connection to close. await testConnectionClosed.Task.OrTimeout(); // The stop should be completed. await stopTask; // The HubConnection should now be closed. await connectionClosed.Task.OrTimeout(); // We should be stopped now var ex = await Assert.ThrowsAsync(() => connection.SendAsync("Foo").OrTimeout()); Assert.Equal($"The '{nameof(HubConnection.SendAsync)}' method cannot be called if the connection is not active", ex.Message); Assert.Equal(1, testConnection.DisposeCount); }); } [Fact] public async Task StopAsyncDuringUnderlyingConnectionCloseWaitsAndNoOps() { var testConnection = new TestConnection(); var connectionClosed = new TaskCompletionSource(); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { connection.Closed += (e) => connectionClosed.TrySetResult(null); await connection.StartAsync().OrTimeout(); Assert.True(testConnection.Started.IsCompleted); // Complete the transport side and wait for the connection to close testConnection.CompleteFromTransport(); // Start stopping manually (these can't be synchronized by a Sync Point because the transport is disposed outside the lock) var stopTask = connection.StopAsync().OrTimeout(); await testConnection.Disposed.OrTimeout(); // Wait for the stop task to complete and the closed event to fire await stopTask; await connectionClosed.Task.OrTimeout(); // We should be stopped now var ex = await Assert.ThrowsAsync(() => connection.SendAsync("Foo").OrTimeout()); Assert.Equal($"The '{nameof(HubConnection.SendAsync)}' method cannot be called if the connection is not active", ex.Message); }); } [Theory] [MemberData(nameof(MethodsNamesThatRequireActiveConnection))] public async Task MethodsThatRequireActiveConnectionWaitForStopAndFailIfConnectionIsCurrentlyStopping(string methodName) { var method = MethodsThatRequireActiveConnection[methodName]; // Set up StartAsync to wait on the syncPoint when starting var testConnection = new TestConnection(onDispose: SyncPoint.Create(out var syncPoint)); await AsyncUsing(new HubConnection(() => testConnection, new JsonHubProtocol()), async connection => { await connection.StartAsync().OrTimeout(); // Stop and invoke the method. These two aren't synchronizable via a Sync Point any more because the transport is disposed // outside the lock :( var disposeTask = connection.StopAsync().OrTimeout(); var targetTask = method(connection).OrTimeout(); // Release the sync point syncPoint.Continue(); // Wait for the method to complete, with an expected error. var ex = await Assert.ThrowsAsync(() => targetTask); Assert.Equal($"The '{methodName}' method cannot be called if the connection is not active", ex.Message); await disposeTask; }); } private static async Task ForceLastInvocationToComplete(TestConnection testConnection) { // We need to "complete" the invocation var message = await testConnection.ReadSentTextMessageAsync(); var json = JObject.Parse(message); // Gotta remove the record separator. await testConnection.ReceiveJsonMessage(new { type = HubProtocolConstants.CompletionMessageType, invocationId = json["invocationId"], }); } // A helper that we wouldn't want to use in product code, but is fine for testing until IAsyncDisposable arrives :) private static async Task AsyncUsing(HubConnection connection, Func action) { try { await action(connection); } finally { // Dispose isn't under test here, so fire and forget so that errors/timeouts here don't cause // test errors that mask the real errors. _ = connection.DisposeAsync(); } } } } }