420 lines
19 KiB
C#
420 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Connections;
|
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
|
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<string, Func<HubConnection, Task>> MethodsThatRequireActiveConnection = new Dictionary<string, Func<HubConnection, Task>>()
|
|
{
|
|
{ nameof(HubConnection.InvokeAsync), (connection) => connection.InvokeAsync("Foo") },
|
|
{ nameof(HubConnection.SendAsync), (connection) => connection.SendAsync("Foo") },
|
|
{ nameof(HubConnection.StreamAsChannelAsync), (connection) => connection.StreamAsChannelAsync<object>("Foo") },
|
|
};
|
|
|
|
public static IEnumerable<object[]> MethodsNamesThatRequireActiveConnection => MethodsThatRequireActiveConnection.Keys.Select(k => new object[] { k });
|
|
|
|
private HubConnection CreateHubConnection(TestConnection testConnection)
|
|
{
|
|
var builder = new HubConnectionBuilder();
|
|
builder.WithConnectionFactory(format => testConnection.StartAsync(format));
|
|
return builder.Build();
|
|
}
|
|
|
|
private HubConnection CreateHubConnection(Func<TransferFormat, Task<ConnectionContext>> connectionFactory)
|
|
{
|
|
var builder = new HubConnectionBuilder();
|
|
builder.WithConnectionFactory(format => connectionFactory(format));
|
|
return builder.Build();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsyncStartsTheUnderlyingConnection()
|
|
{
|
|
var testConnection = new TestConnection();
|
|
await AsyncUsing(CreateHubConnection(testConnection), 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(CreateHubConnection(testConnection), async connection =>
|
|
{
|
|
var firstStart = connection.StartAsync().OrTimeout();
|
|
Assert.False(firstStart.IsCompleted);
|
|
|
|
// Wait for us to be in IConnectionFactory.ConnectAsync
|
|
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;
|
|
Task<ConnectionContext> ConnectionFactory(TransferFormat format)
|
|
{
|
|
createCount += 1;
|
|
return new TestConnection().StartAsync(format);
|
|
}
|
|
|
|
await AsyncUsing(CreateHubConnection(ConnectionFactory), 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 StartAsyncWithFailedHandshakeCanBeStopped()
|
|
{
|
|
var testConnection = new TestConnection(autoHandshake: false);
|
|
await AsyncUsing(CreateHubConnection(testConnection), 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(CreateHubConnection(testConnection), async connection =>
|
|
{
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => 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(CreateHubConnection(testConnection), 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(CreateHubConnection(testConnection), async connection =>
|
|
{
|
|
await connection.StartAsync().OrTimeout();
|
|
Assert.True(testConnection.Started.IsCompleted);
|
|
|
|
await connection.StopAsync().OrTimeout();
|
|
await testConnection.Disposed.OrTimeout();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsyncNoOpsIfConnectionNotYetStarted()
|
|
{
|
|
var testConnection = new TestConnection();
|
|
await AsyncUsing(CreateHubConnection(testConnection), async connection =>
|
|
{
|
|
await connection.StopAsync().OrTimeout();
|
|
Assert.False(testConnection.Disposed.IsCompleted);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsyncNoOpsIfConnectionAlreadyStopped()
|
|
{
|
|
var testConnection = new TestConnection();
|
|
await AsyncUsing(CreateHubConnection(testConnection), async connection =>
|
|
{
|
|
await connection.StartAsync().OrTimeout();
|
|
Assert.True(testConnection.Started.IsCompleted);
|
|
|
|
await connection.StopAsync().OrTimeout();
|
|
await testConnection.Disposed.OrTimeout();
|
|
|
|
await connection.StopAsync().OrTimeout();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletingTheTransportSideMarksConnectionAsClosed()
|
|
{
|
|
var testConnection = new TestConnection();
|
|
var closed = new TaskCompletionSource<object>();
|
|
await AsyncUsing(CreateHubConnection(testConnection), 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<InvalidOperationException>(() => 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<object>();
|
|
var connectionClosed = new TaskCompletionSource<object>();
|
|
await AsyncUsing(CreateHubConnection(testConnection), 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<InvalidOperationException>(() => connection.SendAsync("Foo").OrTimeout());
|
|
Assert.Equal($"The '{nameof(HubConnection.SendAsync)}' method cannot be called if the connection is not active", ex.Message);
|
|
|
|
await testConnection.Disposed.OrTimeout();
|
|
|
|
Assert.Equal(1, testConnection.DisposeCount);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsyncDuringUnderlyingConnectionCloseWaitsAndNoOps()
|
|
{
|
|
var testConnection = new TestConnection();
|
|
var connectionClosed = new TaskCompletionSource<object>();
|
|
await AsyncUsing(CreateHubConnection(testConnection), 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<InvalidOperationException>(() => 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(CreateHubConnection(testConnection), 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<InvalidOperationException>(() => targetTask);
|
|
Assert.Equal($"The '{methodName}' method cannot be called if the connection is not active", ex.Message);
|
|
|
|
await disposeTask;
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClientTimesoutWhenHandshakeResponseTakesTooLong()
|
|
{
|
|
var connection = new TestConnection(autoHandshake: false);
|
|
var hubConnection = CreateHubConnection(connection);
|
|
try
|
|
{
|
|
hubConnection.HandshakeTimeout = TimeSpan.FromMilliseconds(1);
|
|
|
|
await Assert.ThrowsAsync<OperationCanceledException>(() => hubConnection.StartAsync().OrTimeout());
|
|
}
|
|
finally
|
|
{
|
|
await hubConnection.DisposeAsync().OrTimeout();
|
|
await connection.DisposeAsync().OrTimeout();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsyncWithTriggeredCancellationTokenIsCanceled()
|
|
{
|
|
var onStartCalled = false;
|
|
var connection = new TestConnection(onStart: () =>
|
|
{
|
|
onStartCalled = true;
|
|
return Task.CompletedTask;
|
|
});
|
|
var hubConnection = CreateHubConnection(connection);
|
|
try
|
|
{
|
|
await Assert.ThrowsAsync<OperationCanceledException>(() => hubConnection.StartAsync(new CancellationToken(canceled: true)).OrTimeout());
|
|
Assert.False(onStartCalled);
|
|
}
|
|
finally
|
|
{
|
|
await hubConnection.DisposeAsync().OrTimeout();
|
|
await connection.DisposeAsync().OrTimeout();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsyncCanTriggerCancellationTokenToCancelHandshake()
|
|
{
|
|
var cts = new CancellationTokenSource();
|
|
var connection = new TestConnection(onStart: () =>
|
|
{
|
|
cts.Cancel();
|
|
return Task.CompletedTask;
|
|
}, autoHandshake: false);
|
|
var hubConnection = CreateHubConnection(connection);
|
|
// We want to make sure the cancellation is because of the token passed to StartAsync
|
|
hubConnection.HandshakeTimeout = Timeout.InfiniteTimeSpan;
|
|
try
|
|
{
|
|
var startTask = hubConnection.StartAsync(cts.Token);
|
|
var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => startTask.OrTimeout());
|
|
Assert.Equal("The operation was canceled.", exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
await hubConnection.DisposeAsync().OrTimeout();
|
|
await connection.DisposeAsync().OrTimeout();
|
|
}
|
|
}
|
|
|
|
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<HubConnection, Task> 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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|