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[]