Dylan/request throttle (#10413)

* request throttling -- initial implementation

* prevented semaphore leak; added xml docs

* small doc fixes

* reference document

* Added internals folder, added structured logging,

* removed typo'd dependency

* no default MaxConcurrentRequests; other polishing

* renamed SemaphoreWrapper->RequestQueue; cleanup

* moved SyncPoint; prevented possible semaphore leak

* adjusting feedback

* regen refs

* Final changes!
This commit is contained in:
Dylan Dmitri Gray 2019-05-27 14:46:14 -07:00 committed by GitHub
parent 01d20c134c
commit 9969e99ef4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 455 additions and 72 deletions

View File

@ -5,6 +5,8 @@
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.0'">
<Compile Include="Microsoft.AspNetCore.RequestThrottling.netcoreapp3.0.cs" />
<Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
<Reference Include="Microsoft.Extensions.Logging.Abstractions" />
<Reference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@ -1,3 +1,24 @@
// 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.
namespace Microsoft.AspNetCore.Builder
{
public static partial class RequestThrottlingExtensions
{
public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseRequestThrottling(this Microsoft.AspNetCore.Builder.IApplicationBuilder app) { throw null; }
}
}
namespace Microsoft.AspNetCore.RequestThrottling
{
public partial class RequestThrottlingMiddleware
{
public RequestThrottlingMiddleware(Microsoft.AspNetCore.Http.RequestDelegate next, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.RequestThrottling.RequestThrottlingOptions> options) { }
[System.Diagnostics.DebuggerStepThroughAttribute]
public System.Threading.Tasks.Task Invoke(Microsoft.AspNetCore.Http.HttpContext context) { throw null; }
}
public partial class RequestThrottlingOptions
{
public RequestThrottlingOptions() { }
public int? MaxConcurrentRequests { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
}

View File

@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
<Reference Include="Microsoft.Extensions.Logging.Console" />
<Reference Include="Microsoft.AspNetCore.RequestThrottling" />
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
</ItemGroup>
</Project>

View File

@ -1,12 +1,12 @@
using System;
using System.Collections.Generic;
// 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.
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestThrottling;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@ -19,13 +19,20 @@ namespace RequestThrottlingSample
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.Configure<RequestThrottlingOptions>(options =>
{
options.MaxConcurrentRequests = 2;
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment environment)
public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory)
{
app.UseRequestThrottling();
app.Run(async context =>
{
await context.Response.WriteAsync("Hello world!");
await context.Response.WriteAsync("Hello Request Throttling! <p></p>");
await Task.Delay(1000);
});
}

View File

@ -0,0 +1,64 @@
// 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.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.RequestThrottling.Internal
{
internal class RequestQueue : IDisposable
{
private SemaphoreSlim _semaphore;
private object _waitingRequestsLock = new object();
public readonly int MaxConcurrentRequests;
public int WaitingRequests { get; private set; }
public RequestQueue(int maxConcurrentRequests)
{
MaxConcurrentRequests = maxConcurrentRequests;
_semaphore = new SemaphoreSlim(maxConcurrentRequests);
}
public async Task EnterQueue()
{
var waitInQueueTask = _semaphore.WaitAsync();
var needsToWaitOnQueue = !waitInQueueTask.IsCompletedSuccessfully;
if (needsToWaitOnQueue)
{
lock (_waitingRequestsLock)
{
WaitingRequests++;
}
await waitInQueueTask;
lock (_waitingRequestsLock)
{
WaitingRequests--;
}
}
}
public void Release()
{
_semaphore.Release();
}
public int Count
{
get => _semaphore.CurrentCount;
}
public int ConcurrentRequests
{
get => MaxConcurrentRequests - _semaphore.CurrentCount;
}
public void Dispose()
{
_semaphore.Dispose();
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>ASP.NET Core middleware for queuing incoming HTTP requests, to avoid threadpool starvation.</Description>
@ -7,4 +7,10 @@
<PackageTags>aspnetcore;queue;queuing</PackageTags>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
<Reference Include="Microsoft.Extensions.Logging.Abstractions" />
<Reference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,29 @@
// 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.
using System;
using Microsoft.AspNetCore.RequestThrottling;
namespace Microsoft.AspNetCore.Builder
{
/// <summary>
/// Extension methods for adding the <see cref="RequestThrottlingMiddleware"/> to an application.
/// </summary>
public static class RequestThrottlingExtensions
{
/// <summary>
/// Adds the <see cref="RequestThrottlingMiddleware"/> to limit the number of concurrently-executing requests.
/// </summary>
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
/// <returns>The <see cref="IApplicationBuilder"/>.</returns>
public static IApplicationBuilder UseRequestThrottling(this IApplicationBuilder app)
{
if (app == null)
{
throw new ArgumentNullException(nameof(app));
}
return app.UseMiddleware<RequestThrottlingMiddleware>();
}
}
}

View File

@ -0,0 +1,116 @@
// 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.
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestThrottling.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.RequestThrottling
{
/// <summary>
/// Limits the number of concurrent requests allowed in the application.
/// </summary>
public class RequestThrottlingMiddleware
{
private readonly RequestQueue _requestQueue;
private readonly RequestThrottlingOptions _options;
private readonly RequestDelegate _next;
private readonly ILogger _logger;
/// <summary>
/// Creates a new <see cref="RequestThrottlingMiddleware"/>.
/// </summary>
/// <param name="next">The <see cref="RequestDelegate"/> representing the next middleware in the pipeline.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/> used for logging.</param>
/// <param name="options">The <see cref="RequestThrottlingOptions"/> containing the initialization parameters.</param>
public RequestThrottlingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IOptions<RequestThrottlingOptions> options)
{
if (options.Value.MaxConcurrentRequests == null)
{
throw new ArgumentException("The value of 'options.MaxConcurrentRequests' must be specified.", nameof(options));
}
_next = next;
_logger = loggerFactory.CreateLogger<RequestThrottlingMiddleware>();
_options = options.Value;
_requestQueue = new RequestQueue(_options.MaxConcurrentRequests.Value);
}
/// <summary>
/// Invokes the logic of the middleware.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/>.</param>
/// <returns>A <see cref="Task"/> that completes when the request leaves.</returns>
public async Task Invoke(HttpContext context)
{
var waitInQueueTask = _requestQueue.EnterQueue();
if (waitInQueueTask.IsCompletedSuccessfully)
{
RequestThrottlingLog.RequestRunImmediately(_logger);
}
else
{
RequestThrottlingLog.RequestEnqueued(_logger, WaitingRequests);
await waitInQueueTask;
RequestThrottlingLog.RequestDequeued(_logger, WaitingRequests);
}
try
{
await _next(context);
}
finally
{
_requestQueue.Release();
}
}
/// <summary>
/// The number of live requests that are downstream from this middleware.
/// Cannot exceeed <see cref="RequestThrottlingOptions.MaxConcurrentRequests"/>.
/// </summary>
internal int ConcurrentRequests
{
get => _requestQueue.ConcurrentRequests;
}
/// <summary>
/// Number of requests currently enqueued and waiting to be processed.
/// </summary>
internal int WaitingRequests
{
get => _requestQueue.WaitingRequests;
}
private static class RequestThrottlingLog
{
private static readonly Action<ILogger, int, Exception> _requestEnqueued =
LoggerMessage.Define<int>(LogLevel.Debug, new EventId(1, "RequestEnqueued"), "Concurrent request limit reached, queuing request. Current queue length: {QueuedRequests}.");
private static readonly Action<ILogger, int, Exception> _requestDequeued =
LoggerMessage.Define<int>(LogLevel.Debug, new EventId(2, "RequestDequeued"), "Request dequeued. Current queue length: {QueuedRequests}.");
private static readonly Action<ILogger, Exception> _requestRunImmediately =
LoggerMessage.Define(LogLevel.Debug, new EventId(3, "RequestRunImmediately"), "Concurrent request limit has not been reached, running request immediately.");
internal static void RequestEnqueued(ILogger logger, int queuedRequests)
{
_requestEnqueued(logger, queuedRequests, null);
}
internal static void RequestDequeued(ILogger logger, int queuedRequests)
{
_requestDequeued(logger, queuedRequests, null);
}
internal static void RequestRunImmediately(ILogger logger)
{
_requestRunImmediately(logger, null);
}
}
}
}

View File

@ -0,0 +1,19 @@
// 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.
using Microsoft.AspNetCore.RequestThrottling;
namespace Microsoft.AspNetCore.RequestThrottling
{
/// <summary>
/// Specifies options for the <see cref="RequestThrottlingMiddleware"/>.
/// </summary>
public class RequestThrottlingOptions
{
/// <summary>
/// Maximum number of concurrent requests. Any extras will be queued on the server.
/// This is null by default because the correct value is application specific. This option must be configured by the application.
/// </summary>
public int? MaxConcurrentRequests { get; set; }
}
}

View File

@ -1,38 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.RequestThrottling
{
internal class SemaphoreWrapper : IDisposable
{
private SemaphoreSlim _semaphore;
public SemaphoreWrapper(int queueLength)
{
_semaphore = new SemaphoreSlim(queueLength);
}
public Task EnterQueue()
{
return _semaphore.WaitAsync();
}
public void LeaveQueue()
{
_semaphore.Release();
}
public int Count
{
get => _semaphore.CurrentCount;
}
public void Dispose()
{
_semaphore.Dispose();
}
}
}

View File

@ -1,10 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(SharedSourceRoot)SyncPoint\SyncPoint.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Http" />
<Reference Include="Microsoft.AspNetCore.Hosting" />
<Reference Include="Microsoft.AspNetCore.RequestThrottling" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,86 @@
// 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
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Internal;
using Xunit;
namespace Microsoft.AspNetCore.RequestThrottling.Tests
{
public class MiddlewareTests
{
[Fact]
public async Task RequestsCanEnterIfSpaceAvailible()
{
var middleware = TestUtils.CreateTestMiddleWare(maxConcurrentRequests: 1);
var context = new DefaultHttpContext();
// a request should go through with no problems
await middleware.Invoke(context).OrTimeout();
}
[Fact]
public async Task SemaphoreStatePreservedIfRequestsError()
{
var middleware = TestUtils.CreateTestMiddleWare(
maxConcurrentRequests: 1,
next: httpContext =>
{
throw new DivideByZeroException();
});
Assert.Equal(0, middleware.ConcurrentRequests);
await Assert.ThrowsAsync<DivideByZeroException>(() => middleware.Invoke(new DefaultHttpContext()));
Assert.Equal(0, middleware.ConcurrentRequests);
}
[Fact]
public async Task QueuedRequestsContinueWhenSpaceBecomesAvailible()
{
var blocker = new SyncPoint();
var firstRequest = true;
var middleware = TestUtils.CreateTestMiddleWare(
maxConcurrentRequests: 1,
next: httpContext =>
{
if (firstRequest)
{
firstRequest = false;
return blocker.WaitToContinue();
}
return Task.CompletedTask;
});
// t1 (as the first request) is blocked by the tcs blocker
var t1 = middleware.Invoke(new DefaultHttpContext());
Assert.Equal(1, middleware.ConcurrentRequests);
Assert.Equal(0, middleware.WaitingRequests);
// t2 is blocked from entering the server since t1 already exists there
// note: increasing MaxConcurrentRequests would allow t2 through while t1 is blocked
var t2 = middleware.Invoke(new DefaultHttpContext());
Assert.Equal(1, middleware.ConcurrentRequests);
Assert.Equal(1, middleware.WaitingRequests);
// unblock the first task, and the second should follow
blocker.Continue();
await t1.OrTimeout();
await t2.OrTimeout();
}
[Fact]
public void InvalidArgumentIfMaxConcurrentRequestsIsNull()
{
var ex = Assert.Throws<ArgumentException>(() =>
{
TestUtils.CreateTestMiddleWare(maxConcurrentRequests: null);
});
Assert.Equal("options", ex.ParamName);
}
}
}

View File

@ -1,34 +1,48 @@
// 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.
using Xunit;
using System.Threading;
using System.Threading.Tasks;
using System;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Testing;
using Microsoft.AspNetCore.RequestThrottling.Internal;
using Xunit;
namespace Microsoft.AspNetCore.RequestThrottling.Tests
{
public class SemaphoreWrapperTests
public class RequestQueueTests
{
[Fact]
public async Task TracksQueueLength()
public async Task LimitsIncomingRequests()
{
using var s = new SemaphoreWrapper(1);
using var s = new RequestQueue(1);
Assert.Equal(1, s.Count);
await s.EnterQueue().OrTimeout();
Assert.Equal(0, s.Count);
s.LeaveQueue();
s.Release();
Assert.Equal(1, s.Count);
}
[Fact]
public async Task TracksQueueLength()
{
using var s = new RequestQueue(1);
Assert.Equal(0, s.WaitingRequests);
await s.EnterQueue();
Assert.Equal(0, s.WaitingRequests);
var enterQueueTask = s.EnterQueue();
Assert.Equal(1, s.WaitingRequests);
s.Release();
await enterQueueTask;
Assert.Equal(0, s.WaitingRequests);
}
[Fact]
public void DoesNotWaitIfSpaceAvailible()
{
using var s = new SemaphoreWrapper(2);
using var s = new RequestQueue(2);
var t1 = s.EnterQueue();
Assert.True(t1.IsCompleted);
@ -43,21 +57,21 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
[Fact]
public async Task WaitsIfNoSpaceAvailible()
{
using var s = new SemaphoreWrapper(1);
using var s = new RequestQueue(1);
await s.EnterQueue().OrTimeout();
var waitingTask = s.EnterQueue();
Assert.False(waitingTask.IsCompleted);
s.LeaveQueue();
s.Release();
await waitingTask.OrTimeout();
}
[Fact]
public async Task IsEncapsulated()
{
using var s1 = new SemaphoreWrapper(1);
using var s2 = new SemaphoreWrapper(1);
using var s1 = new RequestQueue(1);
using var s2 = new RequestQueue(1);
await s1.EnterQueue().OrTimeout();
await s2.EnterQueue().OrTimeout();

View File

@ -0,0 +1,28 @@
// 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.
using System.Threading.Tasks;
using Microsoft.AspNetCore.RequestThrottling;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.RequestThrottling.Tests
{
public static class TestUtils
{
public static RequestThrottlingMiddleware CreateTestMiddleWare(int? maxConcurrentRequests, RequestDelegate next = null)
{
var options = new RequestThrottlingOptions
{
MaxConcurrentRequests = maxConcurrentRequests
};
return new RequestThrottlingMiddleware(
next: next ?? (context => Task.CompletedTask),
loggerFactory: NullLoggerFactory.Instance,
options: Options.Create(options)
);
}
}
}

View File

@ -1,4 +1,4 @@
// 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.
using System;

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>ASP.NET Core middleware for caching HTTP responses on the server.</Description>

View File

@ -0,0 +1,10 @@
<!-- This file is automatically generated. -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netcoreapp3.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.0'">
<Compile Include="Microsoft.AspNetCore.ANCMSymbols.netcoreapp3.0.cs" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,3 @@
// 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.

View File

@ -4,7 +4,7 @@
using System;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.SignalR.Tests
namespace Microsoft.AspNetCore.Internal
{
public class SyncPoint
{

View File

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Http.Connections.Client;
using Microsoft.AspNetCore.Http.Connections.Client.Internal;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Tests;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
@ -90,8 +91,8 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
var startCounter = 0;
var expected = new Exception("Transport failed to start");
// We have 4 cases here. Falling back once, falling back twice and each of these
// with WebSockets available and not. If Websockets aren't available and
// We have 4 cases here. Falling back once, falling back twice and each of these
// with WebSockets available and not. If Websockets aren't available and
// we can't to test the fallback once scenario we don't decrement the passthreshold
// because we still try to start twice (SSE and LP).
if (!TestHelpers.IsWebSocketsSupported() && passThreshold > 2)

View File

@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Protocol;
using Microsoft.AspNetCore.SignalR.Tests;
using Microsoft.Extensions.DependencyInjection;

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
@ -8,6 +8,7 @@
<Compile Include="$(SignalRSharedSourceRoot)MemoryBufferWriter.cs" Link="MemoryBufferWriter.cs" />
<Compile Include="$(SignalRSharedSourceRoot)TextMessageFormatter.cs" Link="TextMessageFormatter.cs" />
<Compile Include="$(SignalRSharedSourceRoot)TextMessageParser.cs" Link="TextMessageParser.cs" />
<Compile Include="$(SharedSourceRoot)SyncPoint\SyncPoint.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -11,6 +11,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections.Client.Internal;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Tests;
using Microsoft.Extensions.Logging.Testing;
using Moq;

View File

@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Http.Connections.Internal;
using Microsoft.AspNetCore.Http.Connections.Internal.Transports;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Tests;
using Microsoft.AspNetCore.Testing;
using Microsoft.AspNetCore.Testing.xunit;

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
@ -6,6 +6,7 @@
<ItemGroup>
<Compile Include="$(SharedSourceRoot)Buffers.Testing\**\*.cs" />
<Compile Include="$(SharedSourceRoot)SyncPoint\SyncPoint.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -1,9 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(SharedSourceRoot)SyncPoint\SyncPoint.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SignalRTestUtilsProject)" />
@ -23,4 +26,4 @@
<Reference Include="System.Reactive.Linq" />
</ItemGroup>
</Project>
</Project>

View File

@ -1,5 +1,6 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Protocol;
using Xunit;