From 9077b388057bbdd3348a56b33d815039f8aeb1ae Mon Sep 17 00:00:00 2001 From: Chris Ross Date: Tue, 18 Jun 2019 11:14:40 -0700 Subject: [PATCH] Implement IHttpResetFeature in Kestrel #10886 (#11300) --- ...AspNetCore.Http.Features.netstandard2.0.cs | 4 + .../Http.Features/src/IHttpResetFeature.cs | 17 ++ src/Servers/Kestrel/Core/src/CoreStrings.resx | 3 + .../Http/HttpProtocol.FeatureCollection.cs | 1 + .../Internal/Http/HttpProtocol.Generated.cs | 23 +++ .../Http2/Http2Stream.FeatureCollection.cs | 8 + .../Http2/Http2ConnectionTests.cs | 44 +++++ .../Http2/Http2StreamTests.cs | 173 ++++++++++++++++++ .../Http2/Http2TestBase.cs | 9 + .../HttpProtocolFeatureCollection.cs | 3 +- 10 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/Http/Http.Features/src/IHttpResetFeature.cs diff --git a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs index 6f53a07297..e115f9e9d1 100644 --- a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs +++ b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs @@ -200,6 +200,10 @@ namespace Microsoft.AspNetCore.Http.Features bool Available { get; } Microsoft.AspNetCore.Http.IHeaderDictionary Trailers { get; } } + public partial interface IHttpResetFeature + { + void Reset(int errorCode); + } public partial interface IHttpResponseCompletionFeature { System.Threading.Tasks.Task CompleteAsync(); diff --git a/src/Http/Http.Features/src/IHttpResetFeature.cs b/src/Http/Http.Features/src/IHttpResetFeature.cs new file mode 100644 index 0000000000..8a08ebe32a --- /dev/null +++ b/src/Http/Http.Features/src/IHttpResetFeature.cs @@ -0,0 +1,17 @@ +// 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.Http.Features +{ + /// + /// Used to send reset messages for protocols that support them such as HTTP/2 or HTTP/3. + /// + public interface IHttpResetFeature + { + /// + /// Send a reset message with the given error code. The request will be considered aborted. + /// + /// The error code to send in the reset message. + void Reset(int errorCode); + } +} diff --git a/src/Servers/Kestrel/Core/src/CoreStrings.resx b/src/Servers/Kestrel/Core/src/CoreStrings.resx index e176d8c451..4bcd314323 100644 --- a/src/Servers/Kestrel/Core/src/CoreStrings.resx +++ b/src/Servers/Kestrel/Core/src/CoreStrings.resx @@ -608,4 +608,7 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l HTTP/2 over TLS is not supported on OSX due to missing ALPN support. + + The HTTP/2 stream was reset by the application with error code {errorCode}. + \ No newline at end of file diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs index f922de8a99..77435ab084 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs @@ -279,6 +279,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http _currentIHttp2StreamIdFeature = this; _currentIHttpResponseCompletionFeature = this; _currentIHttpResponseTrailersFeature = this; + _currentIHttpResetFeature = this; } void IHttpResponseFeature.OnStarting(Func callback, object state) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs index f594feed0f..d07806e85a 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs @@ -41,6 +41,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http private static readonly Type IHttpMinResponseDataRateFeatureType = typeof(IHttpMinResponseDataRateFeature); private static readonly Type IHttpBodyControlFeatureType = typeof(IHttpBodyControlFeature); private static readonly Type IHttpResponseStartFeatureType = typeof(IHttpResponseStartFeature); + private static readonly Type IHttpResetFeatureType = typeof(IHttpResetFeature); private static readonly Type IHttpSendFileFeatureType = typeof(IHttpSendFileFeature); private object _currentIHttpRequestFeature; @@ -71,6 +72,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http private object _currentIHttpMinResponseDataRateFeature; private object _currentIHttpBodyControlFeature; private object _currentIHttpResponseStartFeature; + private object _currentIHttpResetFeature; private object _currentIHttpSendFileFeature; private int _featureRevision; @@ -108,6 +110,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http _currentIHttpWebSocketFeature = null; _currentISessionFeature = null; _currentIHttpMinResponseDataRateFeature = null; + _currentIHttpResetFeature = null; _currentIHttpSendFileFeature = null; } @@ -275,6 +278,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { feature = _currentIHttpResponseStartFeature; } + else if (key == IHttpResetFeatureType) + { + feature = _currentIHttpResetFeature; + } else if (key == IHttpSendFileFeatureType) { feature = _currentIHttpSendFileFeature; @@ -403,6 +410,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { _currentIHttpResponseStartFeature = value; } + else if (key == IHttpResetFeatureType) + { + _currentIHttpResetFeature = value; + } else if (key == IHttpSendFileFeatureType) { _currentIHttpSendFileFeature = value; @@ -529,6 +540,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { feature = (TFeature)_currentIHttpResponseStartFeature; } + else if (typeof(TFeature) == typeof(IHttpResetFeature)) + { + feature = (TFeature)_currentIHttpResetFeature; + } else if (typeof(TFeature) == typeof(IHttpSendFileFeature)) { feature = (TFeature)_currentIHttpSendFileFeature; @@ -661,6 +676,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { _currentIHttpResponseStartFeature = feature; } + else if (typeof(TFeature) == typeof(IHttpResetFeature)) + { + _currentIHttpResetFeature = feature; + } else if (typeof(TFeature) == typeof(IHttpSendFileFeature)) { _currentIHttpSendFileFeature = feature; @@ -785,6 +804,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { yield return new KeyValuePair(IHttpResponseStartFeatureType, _currentIHttpResponseStartFeature); } + if (_currentIHttpResetFeature != null) + { + yield return new KeyValuePair(IHttpResetFeatureType, _currentIHttpResetFeature); + } if (_currentIHttpSendFileFeature != null) { yield return new KeyValuePair(IHttpSendFileFeatureType, _currentIHttpSendFileFeature); diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.FeatureCollection.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.FeatureCollection.cs index fb27e387d3..44e27fb8ce 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.FeatureCollection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.FeatureCollection.cs @@ -3,6 +3,7 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Features; @@ -12,6 +13,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 { internal partial class Http2Stream : IHttp2StreamIdFeature, IHttpMinRequestBodyDataRateFeature, + IHttpResetFeature, IHttpResponseCompletionFeature, IHttpResponseTrailersFeature @@ -73,5 +75,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 await ProduceEnd(); } } + + void IHttpResetFeature.Reset(int errorCode) + { + var abortReason = new ConnectionAbortedException(CoreStrings.FormatHttp2StreamResetByApplication((Http2ErrorCode)errorCode)); + ResetAndAbort(abortReason, (Http2ErrorCode)errorCode); + } } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs index 593f3bc644..0b3d5c13fe 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs @@ -3859,6 +3859,50 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); } + [Theory] + [InlineData((int)(Http2FrameType.DATA))] + [InlineData((int)(Http2FrameType.WINDOW_UPDATE))] + [InlineData((int)(Http2FrameType.HEADERS))] + [InlineData((int)(Http2FrameType.CONTINUATION))] + public async Task ResetStream_ResetsAndDrainsRequest(int intFinalFrameType) + { + var finalFrameType = (Http2FrameType)intFinalFrameType; + + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "POST"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(_appReset); + + await StartStreamAsync(1, headers, endStream: false); + + await WaitForStreamErrorAsync(1, Http2ErrorCode.CANCEL, "The HTTP/2 stream was reset by the application with error code CANCEL."); + + // These would be refused if the cool-down period had expired + switch (finalFrameType) + { + case Http2FrameType.DATA: + await SendDataAsync(1, new byte[100], endStream: true); + break; + case Http2FrameType.WINDOW_UPDATE: + await SendWindowUpdateAsync(1, 1024); + break; + case Http2FrameType.HEADERS: + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM | Http2HeadersFrameFlags.END_HEADERS, _requestTrailers); + break; + case Http2FrameType.CONTINUATION: + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM, _requestTrailers); + await SendContinuationAsync(1, Http2ContinuationFrameFlags.END_HEADERS, _requestTrailers); + break; + default: + throw new NotImplementedException(finalFrameType.ToString()); + } + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + [Theory] [InlineData((int)(Http2FrameType.DATA))] [InlineData((int)(Http2FrameType.HEADERS))] diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs index 7bdaa893a9..2c65152682 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs @@ -4107,5 +4107,178 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests Assert.Single(_decodedHeaders); Assert.Equal("Custom Value", _decodedHeaders["CustomName"]); } + + [Fact] + public async Task ResetAfterCompleteAsync_GETWithResponseBodyAndTrailers_ResetsAfterResponse() + { + var startingTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var appTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var clientTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + try + { + context.Response.OnStarting(() => { startingTcs.SetResult(0); return Task.CompletedTask; }); + var completionFeature = context.Features.Get(); + Assert.NotNull(completionFeature); + + await context.Response.WriteAsync("Hello World"); + Assert.True(startingTcs.Task.IsCompletedSuccessfully); // OnStarting got called. + Assert.True(context.Response.Headers.IsReadOnly); + + context.Response.AppendTrailer("CustomName", "Custom Value"); + + await completionFeature.CompleteAsync().DefaultTimeout(); + + Assert.True(context.Features.Get().Trailers.IsReadOnly); + + // RequestAborted will no longer fire after CompleteAsync. + Assert.False(context.RequestAborted.CanBeCanceled); + var resetFeature = context.Features.Get(); + Assert.NotNull(resetFeature); + resetFeature.Reset((int)Http2ErrorCode.NO_ERROR); + + // Make sure the client gets our results from CompleteAsync instead of from the request delegate exiting. + await clientTcs.Task.DefaultTimeout(); + appTcs.SetResult(0); + } + catch (Exception ex) + { + appTcs.SetException(ex); + } + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS), + withStreamId: 1); + var bodyFrame = await ExpectAsync(Http2FrameType.DATA, + withLength: 11, + withFlags: (byte)(Http2HeadersFrameFlags.NONE), + withStreamId: 1); + var trailersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 25, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1); + await WaitForStreamErrorAsync(1, Http2ErrorCode.NO_ERROR, expectedErrorMessage: + "The HTTP/2 stream was reset by the application with error code NO_ERROR."); + + clientTcs.SetResult(0); + await appTcs.Task; + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this); + + Assert.Equal(2, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + + Assert.Equal("Hello World", Encoding.UTF8.GetString(bodyFrame.Payload.Span)); + + _decodedHeaders.Clear(); + + _hpackDecoder.Decode(trailersFrame.PayloadSequence, endHeaders: true, handler: this); + + Assert.Single(_decodedHeaders); + Assert.Equal("Custom Value", _decodedHeaders["CustomName"]); + } + + [Fact] + public async Task ResetAfterCompleteAsync_POSTWithResponseBodyAndTrailers_RequestBodyThrows() + { + var startingTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var appTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var clientTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "POST"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + try + { + var requestBodyTask = context.Request.BodyReader.ReadAsync(); + + context.Response.OnStarting(() => { startingTcs.SetResult(0); return Task.CompletedTask; }); + var completionFeature = context.Features.Get(); + Assert.NotNull(completionFeature); + + await context.Response.WriteAsync("Hello World"); + Assert.True(startingTcs.Task.IsCompletedSuccessfully); // OnStarting got called. + Assert.True(context.Response.Headers.IsReadOnly); + + context.Response.AppendTrailer("CustomName", "Custom Value"); + + await completionFeature.CompleteAsync().DefaultTimeout(); + + Assert.True(context.Features.Get().Trailers.IsReadOnly); + + // RequestAborted will no longer fire after CompleteAsync. + Assert.False(context.RequestAborted.CanBeCanceled); + var resetFeature = context.Features.Get(); + Assert.NotNull(resetFeature); + resetFeature.Reset((int)Http2ErrorCode.NO_ERROR); + + await Assert.ThrowsAsync(async () => await requestBodyTask); + await Assert.ThrowsAsync(async () => await context.Request.BodyReader.ReadAsync()); + + // Make sure the client gets our results from CompleteAsync instead of from the request delegate exiting. + await clientTcs.Task.DefaultTimeout(); + appTcs.SetResult(0); + } + catch (Exception ex) + { + appTcs.SetException(ex); + } + }); + + await StartStreamAsync(1, headers, endStream: false); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS), + withStreamId: 1); + var bodyFrame = await ExpectAsync(Http2FrameType.DATA, + withLength: 11, + withFlags: (byte)(Http2HeadersFrameFlags.NONE), + withStreamId: 1); + var trailersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 25, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1); + await WaitForStreamErrorAsync(1, Http2ErrorCode.NO_ERROR, expectedErrorMessage: + "The HTTP/2 stream was reset by the application with error code NO_ERROR."); + + clientTcs.SetResult(0); + await appTcs.Task; + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this); + + Assert.Equal(2, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + + Assert.Equal("Hello World", Encoding.UTF8.GetString(bodyFrame.Payload.Span)); + + _decodedHeaders.Clear(); + + _hpackDecoder.Decode(trailersFrame.PayloadSequence, endHeaders: true, handler: this); + + Assert.Single(_decodedHeaders); + Assert.Equal("Custom Value", _decodedHeaders["CustomName"]); + } } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs index 7d09560508..543a9dc289 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs @@ -151,6 +151,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests protected readonly RequestDelegate _echoHost; protected readonly RequestDelegate _echoPath; protected readonly RequestDelegate _appAbort; + protected readonly RequestDelegate _appReset; internal TestServiceContext _serviceContext; private Timer _timer; @@ -390,6 +391,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests context.Abort(); return Task.CompletedTask; }; + + _appReset = context => + { + var resetFeature = context.Features.Get(); + Assert.NotNull(resetFeature); + resetFeature.Reset((int)Http2ErrorCode.CANCEL); + return Task.CompletedTask; + }; } public override void Initialize(MethodInfo methodInfo, object[] testMethodArguments, ITestOutputHelper testOutputHelper) diff --git a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs index d30a30a9eb..f6e671fb9b 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs @@ -46,7 +46,8 @@ namespace CodeGenerator "IHttpMinRequestBodyDataRateFeature", "IHttpMinResponseDataRateFeature", "IHttpBodyControlFeature", - "IHttpResponseStartFeature" + "IHttpResponseStartFeature", + "IHttpResetFeature" }; var rareFeatures = new[]