Various fixes in HttpConnectionDispatcher (#151)

- The connection state object is manipulated by multiple parties in a non thread safe way. This change introduces a semaphore that should be used by anyone updating or reading the connection state. 
- Handle cases where there's an active request for a connection id and another incoming request for the same connection id, sse and websockets 409 and long polling kicks out the previous connection (https://github.com/aspnet/SignalR/issues/27 and https://github.com/aspnet/SignalR/issues/4)
- Handle requests being processed for disposed connections. There was a race where the background thread could remove and clean up the connection while it was about to be processed.
- Synchronize between the background scanning thread and the request threads when updating the connection state.
- Added `DisposeAndRemoveAsync` to the connection manager that handles`DisposeAsync` throwing and properly removes connections from connection tracking.
- Added Start to ConnectionManager so that testing is easier (background timer doesn't kick in unless start is called).
- Added RequestId to connection state for easier debugging and correlation (can easily see which request is currently processing the logical connection).
- Added tests
This commit is contained in:
David Fowler 2017-01-25 22:27:55 +00:00 committed by GitHub
parent acd1dc5e24
commit 934f6a70d1
12 changed files with 462 additions and 102 deletions

View File

@ -8,17 +8,27 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading.Tasks.Channels; using System.Threading.Tasks.Channels;
using Microsoft.AspNetCore.Sockets.Internal; using Microsoft.AspNetCore.Sockets.Internal;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Sockets namespace Microsoft.AspNetCore.Sockets
{ {
public class ConnectionManager public class ConnectionManager
{ {
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new ConcurrentDictionary<string, ConnectionState>(); private readonly ConcurrentDictionary<string, ConnectionState> _connections = new ConcurrentDictionary<string, ConnectionState>();
private readonly Timer _timer; private Timer _timer;
private readonly ILogger<ConnectionManager> _logger;
public ConnectionManager() public ConnectionManager(ILogger<ConnectionManager> logger)
{ {
_timer = new Timer(Scan, this, 0, 1000); _logger = logger;
}
public void Start()
{
if (_timer == null)
{
_timer = new Timer(Scan, this, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
} }
public bool TryGetConnection(string id, out ConnectionState state) public bool TryGetConnection(string id, out ConnectionState state)
@ -47,9 +57,11 @@ namespace Microsoft.AspNetCore.Sockets
public void RemoveConnection(string id) public void RemoveConnection(string id)
{ {
ConnectionState state; ConnectionState state;
_connections.TryRemove(id, out state); if (_connections.TryRemove(id, out state))
{
// Remove the connection completely // Remove the connection completely
_logger.LogDebug("Removing {connectionId} from the list of connections", id);
}
} }
private static string MakeNewConnectionId() private static string MakeNewConnectionId()
@ -65,38 +77,76 @@ namespace Microsoft.AspNetCore.Sockets
private void Scan() private void Scan()
{ {
// Scan the registered connections looking for ones that have timed out // Pause the timer while we're running
foreach (var c in _connections) _timer.Change(Timeout.Infinite, Timeout.Infinite);
try
{ {
if (!c.Value.Active && (DateTimeOffset.UtcNow - c.Value.LastSeenUtc).TotalSeconds > 5) // Scan the registered connections looking for ones that have timed out
foreach (var c in _connections)
{ {
ConnectionState s; var status = ConnectionState.ConnectionStatus.Inactive;
if (_connections.TryRemove(c.Key, out s)) var lastSeenUtc = DateTimeOffset.UtcNow;
try
{ {
// REVIEW: Should we keep firing and forgetting this? c.Value.Lock.Wait();
var ignore = s.DisposeAsync();
// Capture the connection state
status = c.Value.Status;
lastSeenUtc = c.Value.LastSeenUtc;
}
finally
{
c.Value.Lock.Release();
}
// Once the decision has been made to to dispose we don't check the status again
if (status == ConnectionState.ConnectionStatus.Inactive && (DateTimeOffset.UtcNow - lastSeenUtc).TotalSeconds > 5)
{
var ignore = DisposeAndRemoveAsync(c.Value);
} }
} }
} }
finally
{
// Resume once we finished processing all connections
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
} }
public void CloseConnections() public void CloseConnections()
{ {
// Stop firing the timer // Stop firing the timer
_timer.Dispose(); _timer?.Dispose();
var tasks = new List<Task>(); var tasks = new List<Task>();
foreach (var c in _connections) foreach (var c in _connections)
{ {
ConnectionState s; tasks.Add(DisposeAndRemoveAsync(c.Value));
if (_connections.TryRemove(c.Key, out s))
{
tasks.Add(s.DisposeAsync());
}
} }
Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(5)); Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(5));
} }
public async Task DisposeAndRemoveAsync(ConnectionState state)
{
try
{
await state.DisposeAsync();
}
catch (Exception ex)
{
_logger.LogError(0, ex, "Failed disposing connection {connectionId}", state.Connection.ConnectionId);
}
finally
{
// Remove it from the list after disposal so that's it's easy to see
// connections that might be in a hung state via the connections list
RemoveConnection(state.Connection.ConnectionId);
}
}
} }
} }

View File

@ -5,6 +5,7 @@ using System;
using System.IO; using System.IO;
using System.IO.Pipelines; using System.IO.Pipelines;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Sockets.Internal; using Microsoft.AspNetCore.Sockets.Internal;
@ -70,8 +71,6 @@ namespace Microsoft.AspNetCore.Sockets
var sse = new ServerSentEventsTransport(state.Application.Input, _loggerFactory); var sse = new ServerSentEventsTransport(state.Application.Input, _loggerFactory);
await DoPersistentConnection(endpoint, sse, context, state); await DoPersistentConnection(endpoint, sse, context, state);
_manager.RemoveConnection(state.Connection.ConnectionId);
} }
else if (context.Request.Path.StartsWithSegments(path + "/ws")) else if (context.Request.Path.StartsWithSegments(path + "/ws"))
{ {
@ -92,8 +91,6 @@ namespace Microsoft.AspNetCore.Sockets
var ws = new WebSocketsTransport(state.Application, _loggerFactory); var ws = new WebSocketsTransport(state.Application, _loggerFactory);
await DoPersistentConnection(endpoint, ws, context, state); await DoPersistentConnection(endpoint, ws, context, state);
_manager.RemoveConnection(state.Connection.ConnectionId);
} }
else if (context.Request.Path.StartsWithSegments(path + "/poll")) else if (context.Request.Path.StartsWithSegments(path + "/poll"))
{ {
@ -111,39 +108,112 @@ namespace Microsoft.AspNetCore.Sockets
return; return;
} }
// Mark the connection as active try
state.Active = true;
// Raise OnConnected for new connections only since polls happen all the time
if (state.ApplicationTask == null)
{ {
_logger.LogDebug("Establishing new Long Polling connection: {0}", state.Connection.ConnectionId); await state.Lock.WaitAsync();
// This will re-initialize formatType metadata, but meh... if (state.Status == ConnectionState.ConnectionStatus.Disposed)
state.Connection.Metadata["transport"] = LongPollingTransport.Name; {
_logger.LogDebug("Connection {connectionId} was disposed,", state.Connection.ConnectionId);
state.ApplicationTask = endpoint.OnConnectedAsync(state.Connection); // The connection was disposed
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
if (state.Status == ConnectionState.ConnectionStatus.Active)
{
_logger.LogDebug("Connection {connectionId} is already active via {requestId}. Cancelling previous request.", state.Connection.ConnectionId, state.RequestId);
using (state.Cancellation)
{
// Cancel the previous request
state.Cancellation.Cancel();
try
{
// Wait for the previous request to drain
await state.TransportTask;
}
catch (OperationCanceledException)
{
// Should be a cancelled task
}
_logger.LogDebug("Previous poll cancelled for {connectionId} on {requestId}.", state.Connection.ConnectionId, state.RequestId);
}
}
// Mark the request identifier
state.RequestId = context.TraceIdentifier;
// Mark the connection as active
state.Status = ConnectionState.ConnectionStatus.Active;
// Raise OnConnected for new connections only since polls happen all the time
if (state.ApplicationTask == null)
{
_logger.LogDebug("Establishing new connection: {connectionId} on {requestId}", state.Connection.ConnectionId, state.RequestId);
state.Connection.Metadata["transport"] = LongPollingTransport.Name;
state.ApplicationTask = endpoint.OnConnectedAsync(state.Connection);
}
else
{
_logger.LogDebug("Resuming existing connection: {connectionId} on {requestId}", state.Connection.ConnectionId, state.RequestId);
}
var longPolling = new LongPollingTransport(state.Application.Input, _loggerFactory);
state.Cancellation = new CancellationTokenSource();
// REVIEW: Performance of this isn't great as this does a bunch of per request allocations
var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(state.Cancellation.Token, context.RequestAborted);
// Start the transport
state.TransportTask = longPolling.ProcessRequestAsync(context, tokenSource.Token);
} }
else finally
{ {
_logger.LogDebug("Resuming existing Long Polling connection: {0}", state.Connection.ConnectionId); state.Lock.Release();
} }
var longPolling = new LongPollingTransport(state.Application.Input, _loggerFactory);
// Start the transport
state.TransportTask = longPolling.ProcessRequestAsync(context);
var resultTask = await Task.WhenAny(state.ApplicationTask, state.TransportTask); var resultTask = await Task.WhenAny(state.ApplicationTask, state.TransportTask);
// If the application ended before the transport task then we need to end the connection completely
// so there is no future polling
if (resultTask == state.ApplicationTask) if (resultTask == state.ApplicationTask)
{ {
await state.DisposeAsync(); await _manager.DisposeAndRemoveAsync(state);
} }
else if (!resultTask.IsCanceled)
{
// Otherwise, we update the state to inactive again and wait for the next poll
try
{
await state.Lock.WaitAsync();
// Mark the connection as inactive if (state.Status == ConnectionState.ConnectionStatus.Active)
state.LastSeenUtc = DateTime.UtcNow; {
state.Active = false; // Mark the connection as inactive
state.LastSeenUtc = DateTime.UtcNow;
state.Status = ConnectionState.ConnectionStatus.Inactive;
state.RequestId = null;
// Dispose the cancellation token
state.Cancellation.Dispose();
state.Cancellation = null;
}
}
finally
{
state.Lock.Release();
}
}
} }
} }
@ -163,22 +233,55 @@ namespace Microsoft.AspNetCore.Sockets
return state; return state;
} }
private static async Task DoPersistentConnection(EndPoint endpoint, private async Task DoPersistentConnection(EndPoint endpoint,
IHttpTransport transport, IHttpTransport transport,
HttpContext context, HttpContext context,
ConnectionState state) ConnectionState state)
{ {
// Call into the end point passing the connection try
state.ApplicationTask = endpoint.OnConnectedAsync(state.Connection); {
await state.Lock.WaitAsync();
// Start the transport if (state.Status == ConnectionState.ConnectionStatus.Disposed)
state.TransportTask = transport.ProcessRequestAsync(context); {
_logger.LogDebug("Connection {connectionId} was disposed,", state.Connection.ConnectionId);
// Connection was disposed
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
// There's already an active request
if (state.Status == ConnectionState.ConnectionStatus.Active)
{
_logger.LogDebug("Connection {connectionId} is already active via {requestId}.", state.Connection.ConnectionId, state.RequestId);
// Reject the request with a 409 conflict
context.Response.StatusCode = StatusCodes.Status409Conflict;
return;
}
// Mark the connection as active
state.Status = ConnectionState.ConnectionStatus.Active;
// Store the request identifier
state.RequestId = context.TraceIdentifier;
// Call into the end point passing the connection
state.ApplicationTask = endpoint.OnConnectedAsync(state.Connection);
// Start the transport
state.TransportTask = transport.ProcessRequestAsync(context, context.RequestAborted);
}
finally
{
state.Lock.Release();
}
// Wait for any of them to end // Wait for any of them to end
await Task.WhenAny(state.ApplicationTask, state.TransportTask); await Task.WhenAny(state.ApplicationTask, state.TransportTask);
// Kill the channel await _manager.DisposeAndRemoveAsync(state);
await state.DisposeAsync();
} }
private Task ProcessNegotiate(HttpContext context) private Task ProcessNegotiate(HttpContext context)
@ -243,7 +346,7 @@ namespace Microsoft.AspNetCore.Sockets
} }
else if (!string.Equals(transport, transportName, StringComparison.Ordinal)) else if (!string.Equals(transport, transportName, StringComparison.Ordinal))
{ {
context.Response.StatusCode = 400; context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Cannot change transports mid-connection"); await context.Response.WriteAsync("Cannot change transports mid-connection");
return false; return false;
} }

View File

@ -2,7 +2,9 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Sockets.Internal namespace Microsoft.AspNetCore.Sockets.Internal
{ {
@ -11,11 +13,17 @@ namespace Microsoft.AspNetCore.Sockets.Internal
public Connection Connection { get; set; } public Connection Connection { get; set; }
public IChannelConnection<Message> Application { get; } public IChannelConnection<Message> Application { get; }
public CancellationTokenSource Cancellation { get; set; }
public SemaphoreSlim Lock { get; } = new SemaphoreSlim(1, 1);
public string RequestId { get; set; }
public Task TransportTask { get; set; } public Task TransportTask { get; set; }
public Task ApplicationTask { get; set; } public Task ApplicationTask { get; set; }
public DateTime LastSeenUtc { get; set; } public DateTime LastSeenUtc { get; set; }
public bool Active { get; set; } = true; public ConnectionStatus Status { get; set; } = ConnectionStatus.Inactive;
public ConnectionState(Connection connection, IChannelConnection<Message> application) public ConnectionState(Connection connection, IChannelConnection<Message> application)
{ {
@ -26,23 +34,54 @@ namespace Microsoft.AspNetCore.Sockets.Internal
public async Task DisposeAsync() public async Task DisposeAsync()
{ {
// If the application task is faulted, propagate the error to the transport Task applicationTask = TaskCache.CompletedTask;
if (ApplicationTask.IsFaulted) Task transportTask = TaskCache.CompletedTask;
{
Connection.Transport.Output.TryComplete(ApplicationTask.Exception.InnerException);
}
// If the transport task is faulted, propagate the error to the application try
if (TransportTask.IsFaulted)
{ {
Application.Output.TryComplete(TransportTask.Exception.InnerException); await Lock.WaitAsync();
}
Connection.Dispose(); if (Status == ConnectionStatus.Disposed)
Application.Dispose(); {
return;
}
Status = ConnectionStatus.Disposed;
RequestId = null;
// If the application task is faulted, propagate the error to the transport
if (ApplicationTask.IsFaulted)
{
Connection.Transport.Output.TryComplete(ApplicationTask.Exception.InnerException);
}
// If the transport task is faulted, propagate the error to the application
if (TransportTask.IsFaulted)
{
Application.Output.TryComplete(TransportTask.Exception.InnerException);
}
Connection.Dispose();
Application.Dispose();
applicationTask = ApplicationTask;
transportTask = TransportTask;
}
finally
{
Lock.Release();
}
// REVIEW: Add a timeout so we don't wait forever // REVIEW: Add a timeout so we don't wait forever
await Task.WhenAll(ApplicationTask, TransportTask); await Task.WhenAll(applicationTask, transportTask);
}
public enum ConnectionStatus
{
Inactive,
Active,
Disposed
} }
} }
} }

View File

@ -1,10 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
namespace Microsoft.AspNetCore.Sockets namespace Microsoft.AspNetCore.Sockets
@ -20,6 +16,7 @@ namespace Microsoft.AspNetCore.Sockets
public void Start() public void Start()
{ {
_connectionManager.Start();
} }
public void Stop() public void Stop()

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -12,7 +13,8 @@ namespace Microsoft.AspNetCore.Sockets.Transports
/// Executes the transport /// Executes the transport
/// </summary> /// </summary>
/// <param name="context"></param> /// <param name="context"></param>
/// <param name="token"></param>
/// <returns>A <see cref="Task"/> that completes when the transport has finished processing</returns> /// <returns>A <see cref="Task"/> that completes when the transport has finished processing</returns>
Task ProcessRequestAsync(HttpContext context); Task ProcessRequestAsync(HttpContext context, CancellationToken token);
} }
} }

View File

@ -23,17 +23,14 @@ namespace Microsoft.AspNetCore.Sockets.Transports
_logger = loggerFactory.CreateLogger<LongPollingTransport>(); _logger = loggerFactory.CreateLogger<LongPollingTransport>();
} }
public async Task ProcessRequestAsync(HttpContext context) public async Task ProcessRequestAsync(HttpContext context, CancellationToken token)
{ {
try try
{ {
// TODO: We need the ability to yield the connection without completing the channel. if (!await _application.WaitToReadAsync(token))
// This is to force ReadAsync to yield without data to end to poll but not the entire connection.
// This is for cases when the client reconnects see issue #27
if (!await _application.WaitToReadAsync(context.RequestAborted))
{ {
_logger.LogInformation("Terminating Long Polling connection by sending 204 response."); _logger.LogInformation("Terminating Long Polling connection by sending 204 response.");
context.Response.StatusCode = 204; context.Response.StatusCode = StatusCodes.Status204NoContent;
return; return;
} }
@ -50,14 +47,17 @@ namespace Microsoft.AspNetCore.Sockets.Transports
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Suppress the exception if (!context.RequestAborted.IsCancellationRequested)
{
_logger.LogInformation("Terminating Long Polling connection by sending 204 response.");
context.Response.StatusCode = StatusCodes.Status204NoContent;
throw;
}
// Don't count this as cancellation, this is normal as the poll can end due to the browesr closing.
// The background thread will eventually dispose this connection if it's inactive
_logger.LogDebug("Client disconnected from Long Polling endpoint."); _logger.LogDebug("Client disconnected from Long Polling endpoint.");
} }
catch (Exception ex)
{
_logger.LogError("Error reading next message from Application: {0}", ex);
throw;
}
} }
} }
} }

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading.Tasks.Channels; using System.Threading.Tasks.Channels;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -21,7 +22,7 @@ namespace Microsoft.AspNetCore.Sockets.Transports
_logger = loggerFactory.CreateLogger<ServerSentEventsTransport>(); _logger = loggerFactory.CreateLogger<ServerSentEventsTransport>();
} }
public async Task ProcessRequestAsync(HttpContext context) public async Task ProcessRequestAsync(HttpContext context, CancellationToken token)
{ {
context.Response.ContentType = "text/event-stream"; context.Response.ContentType = "text/event-stream";
context.Response.Headers["Cache-Control"] = "no-cache"; context.Response.Headers["Cache-Control"] = "no-cache";
@ -30,7 +31,7 @@ namespace Microsoft.AspNetCore.Sockets.Transports
try try
{ {
while (await _application.WaitToReadAsync(context.RequestAborted)) while (await _application.WaitToReadAsync(token))
{ {
Message message; Message message;
while (_application.TryRead(out message)) while (_application.TryRead(out message))

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebSockets.Internal; using Microsoft.AspNetCore.WebSockets.Internal;
@ -40,7 +41,7 @@ namespace Microsoft.AspNetCore.Sockets.Transports
_logger = loggerFactory.CreateLogger<WebSocketsTransport>(); _logger = loggerFactory.CreateLogger<WebSocketsTransport>();
} }
public async Task ProcessRequestAsync(HttpContext context) public async Task ProcessRequestAsync(HttpContext context, CancellationToken token)
{ {
var feature = context.Features.Get<IHttpWebSocketConnectionFeature>(); var feature = context.Features.Get<IHttpWebSocketConnectionFeature>();
if (feature == null || !feature.IsWebSocketRequest) if (feature == null || !feature.IsWebSocketRequest)

View File

@ -4,6 +4,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Sockets.Internal; using Microsoft.AspNetCore.Sockets.Internal;
using Microsoft.Extensions.Logging;
using Xunit; using Xunit;
namespace Microsoft.AspNetCore.Sockets.Tests namespace Microsoft.AspNetCore.Sockets.Tests
@ -13,21 +14,23 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[Fact] [Fact]
public void NewConnectionsHaveConnectionId() public void NewConnectionsHaveConnectionId()
{ {
var connectionManager = new ConnectionManager(); var connectionManager = CreateConnectionManager();
var state = connectionManager.CreateConnection(); var state = connectionManager.CreateConnection();
Assert.NotNull(state.Connection); Assert.NotNull(state.Connection);
Assert.NotNull(state.Connection.ConnectionId); Assert.NotNull(state.Connection.ConnectionId);
Assert.True(state.Active); Assert.Equal(ConnectionState.ConnectionStatus.Inactive, state.Status);
Assert.Null(state.ApplicationTask); Assert.Null(state.ApplicationTask);
Assert.Null(state.TransportTask); Assert.Null(state.TransportTask);
Assert.Null(state.Cancellation);
Assert.Null(state.RequestId);
Assert.NotNull(state.Connection.Transport); Assert.NotNull(state.Connection.Transport);
} }
[Fact] [Fact]
public void NewConnectionsCanBeRetrieved() public void NewConnectionsCanBeRetrieved()
{ {
var connectionManager = new ConnectionManager(); var connectionManager = CreateConnectionManager();
var state = connectionManager.CreateConnection(); var state = connectionManager.CreateConnection();
Assert.NotNull(state.Connection); Assert.NotNull(state.Connection);
@ -41,7 +44,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[Fact] [Fact]
public void AddNewConnection() public void AddNewConnection()
{ {
var connectionManager = new ConnectionManager(); var connectionManager = CreateConnectionManager();
var state = connectionManager.CreateConnection(); var state = connectionManager.CreateConnection();
var transport = state.Connection.Transport; var transport = state.Connection.Transport;
@ -59,7 +62,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[Fact] [Fact]
public void RemoveConnection() public void RemoveConnection()
{ {
var connectionManager = new ConnectionManager(); var connectionManager = CreateConnectionManager();
var state = connectionManager.CreateConnection(); var state = connectionManager.CreateConnection();
var transport = state.Connection.Transport; var transport = state.Connection.Transport;
@ -80,7 +83,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[Fact] [Fact]
public async Task CloseConnectionsEndsAllPendingConnections() public async Task CloseConnectionsEndsAllPendingConnections()
{ {
var connectionManager = new ConnectionManager(); var connectionManager = CreateConnectionManager();
var state = connectionManager.CreateConnection(); var state = connectionManager.CreateConnection();
state.ApplicationTask = Task.Run(async () => state.ApplicationTask = Task.Run(async () =>
@ -97,5 +100,10 @@ namespace Microsoft.AspNetCore.Sockets.Tests
await state.DisposeAsync(); await state.DisposeAsync();
} }
private static ConnectionManager CreateConnectionManager()
{
return new ConnectionManager(new Logger<ConnectionManager>(new LoggerFactory()));
}
} }
} }

View File

@ -4,6 +4,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Pipelines;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -21,7 +22,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[Fact] [Fact]
public async Task NegotiateReservesConnectionIdAndReturnsIt() public async Task NegotiateReservesConnectionIdAndReturnsIt()
{ {
var manager = new ConnectionManager(); var manager = CreateConnectionManager();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory()); var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
var services = new ServiceCollection(); var services = new ServiceCollection();
@ -46,7 +47,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[InlineData("/ws")] [InlineData("/ws")]
public async Task EndpointsThatAcceptConnectionId404WhenUnknownConnectionIdProvided(string path) public async Task EndpointsThatAcceptConnectionId404WhenUnknownConnectionIdProvided(string path)
{ {
var manager = new ConnectionManager(); var manager = CreateConnectionManager();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory()); var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
using (var strm = new MemoryStream()) using (var strm = new MemoryStream())
@ -77,7 +78,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
[InlineData("/poll")] [InlineData("/poll")]
public async Task EndpointsThatRequireConnectionId400WhenNoConnectionIdProvided(string path) public async Task EndpointsThatRequireConnectionId400WhenNoConnectionIdProvided(string path)
{ {
var manager = new ConnectionManager(); var manager = CreateConnectionManager();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory()); var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
using (var strm = new MemoryStream()) using (var strm = new MemoryStream())
{ {
@ -95,13 +96,171 @@ namespace Microsoft.AspNetCore.Sockets.Tests
Assert.Equal("Connection ID required", Encoding.UTF8.GetString(strm.ToArray())); Assert.Equal("Connection ID required", Encoding.UTF8.GetString(strm.ToArray()));
} }
} }
[Fact]
public async Task CompletedEndPointEndsConnection()
{
var manager = CreateConnectionManager();
var state = manager.CreateConnection();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context = MakeRequest<ImmediatelyCompleteEndPoint>("/sse", state);
await dispatcher.ExecuteAsync<ImmediatelyCompleteEndPoint>("", context);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
ConnectionState removed;
bool exists = manager.TryGetConnection(state.Connection.ConnectionId, out removed);
Assert.False(exists);
}
[Fact]
public async Task CompletedEndPointEndsLongPollingConnection()
{
var manager = CreateConnectionManager();
var state = manager.CreateConnection();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context = MakeRequest<ImmediatelyCompleteEndPoint>("/poll", state);
await dispatcher.ExecuteAsync<ImmediatelyCompleteEndPoint>("", context);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
ConnectionState removed;
bool exists = manager.TryGetConnection(state.Connection.ConnectionId, out removed);
Assert.False(exists);
}
[Fact]
public async Task RequestToActiveConnectionId409ForStreamingTransports()
{
var manager = CreateConnectionManager();
var state = manager.CreateConnection();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context1 = MakeRequest<TestEndPoint>("/sse", state);
var context2 = MakeRequest<TestEndPoint>("/sse", state);
var request1 = dispatcher.ExecuteAsync<TestEndPoint>("", context1);
await dispatcher.ExecuteAsync<TestEndPoint>("", context2);
Assert.Equal(StatusCodes.Status409Conflict, context2.Response.StatusCode);
manager.CloseConnections();
await request1;
}
[Fact]
public async Task RequestToActiveConnectionIdKillsPreviousConnectionLongPolling()
{
var manager = CreateConnectionManager();
var state = manager.CreateConnection();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context1 = MakeRequest<TestEndPoint>("/poll", state);
var context2 = MakeRequest<TestEndPoint>("/poll", state);
var request1 = dispatcher.ExecuteAsync<TestEndPoint>("", context1);
var request2 = dispatcher.ExecuteAsync<TestEndPoint>("", context2);
await request1;
Assert.Equal(StatusCodes.Status204NoContent, context1.Response.StatusCode);
Assert.Equal(ConnectionState.ConnectionStatus.Active, state.Status);
Assert.False(request2.IsCompleted);
manager.CloseConnections();
await request2;
}
[Theory]
[InlineData("/sse")]
[InlineData("/poll")]
public async Task RequestToDisposedConnectionIdReturns404(string path)
{
var manager = CreateConnectionManager();
var state = manager.CreateConnection();
state.Status = ConnectionState.ConnectionStatus.Disposed;
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context = MakeRequest<TestEndPoint>(path, state);
await dispatcher.ExecuteAsync<TestEndPoint>("", context);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
}
[Fact]
public async Task ConnectionStateSetToInactiveAfterPoll()
{
var manager = CreateConnectionManager();
var state = manager.CreateConnection();
var dispatcher = new HttpConnectionDispatcher(manager, new LoggerFactory());
var context = MakeRequest<TestEndPoint>("/poll", state);
var task = dispatcher.ExecuteAsync<TestEndPoint>("", context);
var buffer = ReadableBuffer.Create(Encoding.UTF8.GetBytes("Hello World")).Preserve();
// Write to the transport so the poll yields
await state.Connection.Transport.Output.WriteAsync(new Message(buffer, Format.Text, endOfMessage: true));
await task;
Assert.Equal(ConnectionState.ConnectionStatus.Inactive, state.Status);
Assert.Null(state.RequestId);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}
private static DefaultHttpContext MakeRequest<TEndPoint>(string path, ConnectionState state) where TEndPoint : EndPoint
{
var context = new DefaultHttpContext();
var services = new ServiceCollection();
services.AddSingleton<TEndPoint>();
context.RequestServices = services.BuildServiceProvider();
context.Request.Path = path;
var values = new Dictionary<string, StringValues>();
values["id"] = state.Connection.ConnectionId;
var qs = new QueryCollection(values);
context.Request.Query = qs;
return context;
}
private static ConnectionManager CreateConnectionManager()
{
return new ConnectionManager(new Logger<ConnectionManager>(new LoggerFactory()));
}
}
public class ImmediatelyCompleteEndPoint : EndPoint
{
public override Task OnConnectedAsync(Connection connection)
{
return Task.CompletedTask;
}
} }
public class TestEndPoint : EndPoint public class TestEndPoint : EndPoint
{ {
public override Task OnConnectedAsync(Connection connection) public override async Task OnConnectedAsync(Connection connection)
{ {
throw new NotImplementedException(); while (await connection.Transport.Input.WaitToReadAsync())
{
}
} }
} }
} }

View File

@ -27,7 +27,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
Assert.True(channel.Out.TryComplete()); Assert.True(channel.Out.TryComplete());
await poll.ProcessRequestAsync(context); await poll.ProcessRequestAsync(context, context.RequestAborted);
Assert.Equal(204, context.Response.StatusCode); Assert.Equal(204, context.Response.StatusCode);
} }
@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
Assert.True(channel.Out.TryComplete()); Assert.True(channel.Out.TryComplete());
await poll.ProcessRequestAsync(context); await poll.ProcessRequestAsync(context, context.RequestAborted);
Assert.Equal(200, context.Response.StatusCode); Assert.Equal(200, context.Response.StatusCode);
Assert.Equal("Hello World", Encoding.UTF8.GetString(ms.ToArray())); Assert.Equal("Hello World", Encoding.UTF8.GetString(ms.ToArray()));

View File

@ -24,7 +24,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
Assert.True(channel.Out.TryComplete()); Assert.True(channel.Out.TryComplete());
await sse.ProcessRequestAsync(context); await sse.ProcessRequestAsync(context, context.RequestAborted);
Assert.Equal("text/event-stream", context.Response.ContentType); Assert.Equal("text/event-stream", context.Response.ContentType);
Assert.Equal("no-cache", context.Response.Headers["Cache-Control"]); Assert.Equal("no-cache", context.Response.Headers["Cache-Control"]);
@ -46,7 +46,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests
Assert.True(channel.Out.TryComplete()); Assert.True(channel.Out.TryComplete());
await sse.ProcessRequestAsync(context); await sse.ProcessRequestAsync(context, context.RequestAborted);
var expected = "data: Hello World\n\n"; var expected = "data: Hello World\n\n";
Assert.Equal(expected, Encoding.UTF8.GetString(ms.ToArray())); Assert.Equal(expected, Encoding.UTF8.GetString(ms.ToArray()));