From 1222d8de4936d1406db64df3f4160578d80eddf1 Mon Sep 17 00:00:00 2001 From: Justin Kotalik Date: Mon, 28 Jan 2019 17:42:04 -0800 Subject: [PATCH] Add StartAsync() to HttpResponse; ImplementFeature in Kestrel (#6967) --- .../Http.Abstractions/src/HttpResponse.cs | 11 + .../src/IHttpResponseStartFeature.cs | 19 + .../Http/src/Internal/DefaultHttpResponse.cs | 16 + src/Http/Http/test/DefaultHttpContextTests.cs | 18 +- .../test/Internal/DefaultHttpResponseTests.cs | 40 ++ .../Http/HttpProtocol.FeatureCollection.cs | 13 +- .../Internal/Http/HttpProtocol.Generated.cs | 23 ++ .../Core/src/Internal/Http/HttpProtocol.cs | 62 +-- .../Internal/Http/RequestProcessingStatus.cs | 7 +- .../HttpProtocolFeatureCollectionTests.cs | 3 + .../Http2/Http2StreamTests.cs | 383 ++++++++++++++++++ .../InMemory.FunctionalTests/ResponseTests.cs | 349 ++++++++++++++++ .../HttpProtocolFeatureCollection.cs | 2 + 13 files changed, 913 insertions(+), 33 deletions(-) create mode 100644 src/Http/Http.Features/src/IHttpResponseStartFeature.cs diff --git a/src/Http/Http.Abstractions/src/HttpResponse.cs b/src/Http/Http.Abstractions/src/HttpResponse.cs index e5af10fbfa..799b8b5cec 100644 --- a/src/Http/Http.Abstractions/src/HttpResponse.cs +++ b/src/Http/Http.Abstractions/src/HttpResponse.cs @@ -4,7 +4,9 @@ using System; using System.IO; using System.IO.Pipelines; +using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Features; namespace Microsoft.AspNetCore.Http { @@ -111,5 +113,14 @@ namespace Microsoft.AspNetCore.Http /// where only ASCII characters are allowed. /// True if the redirect is permanent (301), otherwise false (302). public abstract void Redirect(string location, bool permanent); + + /// + /// Starts the response by calling OnStarting() and making headers unmodifiable. + /// + /// + /// + /// If the isn't set, StartAsync will default to calling HttpResponse.Body.FlushAsync(). + /// + public abstract Task StartAsync(CancellationToken cancellationToken = default); } } diff --git a/src/Http/Http.Features/src/IHttpResponseStartFeature.cs b/src/Http/Http.Features/src/IHttpResponseStartFeature.cs new file mode 100644 index 0000000000..45202a0871 --- /dev/null +++ b/src/Http/Http.Features/src/IHttpResponseStartFeature.cs @@ -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 System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Http.Features +{ + /// + /// Feature to start response writing. + /// + public interface IHttpResponseStartFeature + { + /// + /// Starts the response by calling OnStarting() and making headers unmodifiable. + /// + Task StartAsync(CancellationToken token = default); + } +} diff --git a/src/Http/Http/src/Internal/DefaultHttpResponse.cs b/src/Http/Http/src/Internal/DefaultHttpResponse.cs index 0922da92f8..6fea6198bf 100644 --- a/src/Http/Http/src/Internal/DefaultHttpResponse.cs +++ b/src/Http/Http/src/Internal/DefaultHttpResponse.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.IO.Pipelines; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http.Features; using Microsoft.Net.Http.Headers; @@ -14,6 +15,7 @@ namespace Microsoft.AspNetCore.Http.Internal { // Lambdas hoisted to static readonly fields to improve inlining https://github.com/dotnet/roslyn/issues/13624 private readonly static Func _nullResponseFeature = f => null; + private readonly static Func _nullResponseStartFeature = f => null; private readonly static Func _newResponseCookiesFeature = f => new ResponseCookiesFeature(f); private readonly static Func _newResponseBodyPipeFeature = context => new ResponseBodyPipeFeature(context); @@ -39,6 +41,9 @@ namespace Microsoft.AspNetCore.Http.Internal private IHttpResponseFeature HttpResponseFeature => _features.Fetch(ref _features.Cache.Response, _nullResponseFeature); + private IHttpResponseStartFeature HttpResponseStartFeature => + _features.Fetch(ref _features.Cache.ResponseStart, _nullResponseStartFeature); + private IResponseCookiesFeature ResponseCookiesFeature => _features.Fetch(ref _features.Cache.Cookies, _newResponseCookiesFeature); @@ -139,11 +144,22 @@ namespace Microsoft.AspNetCore.Http.Internal Headers[HeaderNames.Location] = location; } + public override Task StartAsync(CancellationToken cancellationToken = default) + { + if (HttpResponseStartFeature == null) + { + return HttpResponseFeature.Body.FlushAsync(cancellationToken); + } + + return HttpResponseStartFeature.StartAsync(); + } + struct FeatureInterfaces { public IHttpResponseFeature Response; public IResponseCookiesFeature Cookies; public IResponseBodyPipeFeature BodyPipe; + public IHttpResponseStartFeature ResponseStart; } } } diff --git a/src/Http/Http/test/DefaultHttpContextTests.cs b/src/Http/Http/test/DefaultHttpContextTests.cs index 5a54bc2726..2a7d7168a7 100644 --- a/src/Http/Http/test/DefaultHttpContextTests.cs +++ b/src/Http/Http/test/DefaultHttpContextTests.cs @@ -159,15 +159,16 @@ namespace Microsoft.AspNetCore.Http features.Set(new HttpRequestFeature()); features.Set(new HttpResponseFeature()); features.Set(new TestHttpWebSocketFeature()); + features.Set(new MockHttpResponseStartFeature()); // FeatureCollection is set. all cached interfaces are null. var context = new DefaultHttpContext(features); TestAllCachedFeaturesAreNull(context, features); - Assert.Equal(3, features.Count()); + Assert.Equal(4, features.Count()); // getting feature properties populates feature collection with defaults TestAllCachedFeaturesAreSet(context, features); - Assert.NotEqual(3, features.Count()); + Assert.NotEqual(4, features.Count()); // FeatureCollection is null. and all cached interfaces are null. // only top level is tested because child objects are inaccessible. @@ -179,15 +180,16 @@ namespace Microsoft.AspNetCore.Http newFeatures.Set(new HttpRequestFeature()); newFeatures.Set(new HttpResponseFeature()); newFeatures.Set(new TestHttpWebSocketFeature()); + newFeatures.Set(new MockHttpResponseStartFeature()); // FeatureCollection is set to newFeatures. all cached interfaces are null. context.Initialize(newFeatures); TestAllCachedFeaturesAreNull(context, newFeatures); - Assert.Equal(3, newFeatures.Count()); + Assert.Equal(4, newFeatures.Count()); // getting feature properties populates new feature collection with defaults TestAllCachedFeaturesAreSet(context, newFeatures); - Assert.NotEqual(3, newFeatures.Count()); + Assert.NotEqual(4, newFeatures.Count()); } [Fact] @@ -417,5 +419,13 @@ namespace Microsoft.AspNetCore.Http throw new NotImplementedException(); } } + + private class MockHttpResponseStartFeature : IHttpResponseStartFeature + { + public Task StartAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } } } diff --git a/src/Http/Http/test/Internal/DefaultHttpResponseTests.cs b/src/Http/Http/test/Internal/DefaultHttpResponseTests.cs index 18c85701ec..3dfffb7ede 100644 --- a/src/Http/Http/test/Internal/DefaultHttpResponseTests.cs +++ b/src/Http/Http/test/Internal/DefaultHttpResponseTests.cs @@ -6,8 +6,11 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Http.Internal @@ -99,6 +102,32 @@ namespace Microsoft.AspNetCore.Http.Internal Assert.Throws(() => context.Response.BodyPipe = null); } + [Fact] + public async Task ResponseStart_CallsFeatureIfSet() + { + var features = new FeatureCollection(); + var mock = new Mock(); + mock.Setup(o => o.StartAsync(It.IsAny())).Returns(Task.CompletedTask); + features.Set(mock.Object); + + var context = new DefaultHttpContext(features); + await context.Response.StartAsync(); + + mock.Verify(m => m.StartAsync(default), Times.Once()); + } + + [Fact] + public async Task ResponseStart_CallsResponseBodyFlushIfNotSet() + { + var context = new DefaultHttpContext(); + var mock = new FlushAsyncCheckStream(); + context.Response.Body = mock; + + await context.Response.StartAsync(default); + + Assert.True(mock.IsCalled); + } + private static HttpResponse CreateResponse(IHeaderDictionary headers) { var context = new DefaultHttpContext(); @@ -126,5 +155,16 @@ namespace Microsoft.AspNetCore.Http.Internal return CreateResponse(headers); } + + private class FlushAsyncCheckStream : MemoryStream + { + public bool IsCalled { get; private set; } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + IsCalled = true; + return base.FlushAsync(cancellationToken); + } + } } } 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 cf40e3a9c0..7a25100788 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs @@ -20,7 +20,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http IHttpRequestLifetimeFeature, IHttpRequestIdentifierFeature, IHttpBodyControlFeature, - IHttpMaxRequestBodySizeFeature + IHttpMaxRequestBodySizeFeature, + IHttpResponseStartFeature { // NOTE: When feature interfaces are added to or removed from this HttpProtocol class implementation, // then the list of `implementedFeatures` in the generated code project MUST also be updated. @@ -250,5 +251,15 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http } protected abstract void ApplicationAbort(); + + Task IHttpResponseStartFeature.StartAsync(CancellationToken cancellationToken) + { + if (HasResponseStarted) + { + return Task.CompletedTask; + } + + return InitializeResponseAsync(0); + } } } 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 4b67300ba3..14abaab13c 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs @@ -34,6 +34,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http private static readonly Type IHttpMinRequestBodyDataRateFeatureType = typeof(IHttpMinRequestBodyDataRateFeature); 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 IHttpSendFileFeatureType = typeof(IHttpSendFileFeature); private object _currentIHttpRequestFeature; @@ -57,6 +58,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http private object _currentIHttpMinRequestBodyDataRateFeature; private object _currentIHttpMinResponseDataRateFeature; private object _currentIHttpBodyControlFeature; + private object _currentIHttpResponseStartFeature; private object _currentIHttpSendFileFeature; private int _featureRevision; @@ -73,6 +75,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http _currentIHttpConnectionFeature = this; _currentIHttpMaxRequestBodySizeFeature = this; _currentIHttpBodyControlFeature = this; + _currentIHttpResponseStartFeature = this; _currentIServiceProvidersFeature = null; _currentIHttpAuthenticationFeature = null; @@ -226,6 +229,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { feature = _currentIHttpBodyControlFeature; } + else if (key == IHttpResponseStartFeatureType) + { + feature = _currentIHttpResponseStartFeature; + } else if (key == IHttpSendFileFeatureType) { feature = _currentIHttpSendFileFeature; @@ -326,6 +333,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { _currentIHttpBodyControlFeature = value; } + else if (key == IHttpResponseStartFeatureType) + { + _currentIHttpResponseStartFeature = value; + } else if (key == IHttpSendFileFeatureType) { _currentIHttpSendFileFeature = value; @@ -424,6 +435,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { feature = (TFeature)_currentIHttpBodyControlFeature; } + else if (typeof(TFeature) == typeof(IHttpResponseStartFeature)) + { + feature = (TFeature)_currentIHttpResponseStartFeature; + } else if (typeof(TFeature) == typeof(IHttpSendFileFeature)) { feature = (TFeature)_currentIHttpSendFileFeature; @@ -528,6 +543,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { _currentIHttpBodyControlFeature = feature; } + else if (typeof(TFeature) == typeof(IHttpResponseStartFeature)) + { + _currentIHttpResponseStartFeature = feature; + } else if (typeof(TFeature) == typeof(IHttpSendFileFeature)) { _currentIHttpSendFileFeature = feature; @@ -624,6 +643,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { yield return new KeyValuePair(IHttpBodyControlFeatureType, _currentIHttpBodyControlFeature); } + if (_currentIHttpResponseStartFeature != null) + { + yield return new KeyValuePair(IHttpResponseStartFeatureType, _currentIHttpResponseStartFeature); + } if (_currentIHttpSendFileFeature != null) { yield return new KeyValuePair(IHttpSendFileFeatureType, _currentIHttpSendFileFeature); diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs index 8a8926b176..4f0f456da8 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs @@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http protected volatile bool _keepAlive = true; private bool _canHaveBody; private bool _autoChunk; - private Exception _applicationException; + protected Exception _applicationException; private BadHttpRequestException _requestRejectedException; protected HttpVersion _httpVersion; @@ -266,7 +266,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http } } - public bool HasResponseStarted => _requestProcessingStatus == RequestProcessingStatus.ResponseStarted; + public bool HasResponseStarted => _requestProcessingStatus >= RequestProcessingStatus.HeadersCommitted; + + public bool HasFlushedHeaders => _requestProcessingStatus == RequestProcessingStatus.HeadersFlushed; protected HttpRequestHeaders HttpRequestHeaders { get; } = new HttpRequestHeaders(); @@ -786,18 +788,25 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http // If return is Task.CompletedTask no awaiting is required if (!ReferenceEquals(initializeTask, Task.CompletedTask)) { - return FlushAsyncAwaited(initializeTask, cancellationToken); + return InitializeAndFlushAsyncAwaited(initializeTask, cancellationToken); } } - return Output.FlushAsync(cancellationToken); + return FlushAsyncInternal(cancellationToken); } [MethodImpl(MethodImplOptions.NoInlining)] - private async Task FlushAsyncAwaited(Task initializeTask, CancellationToken cancellationToken) + private async Task InitializeAndFlushAsyncAwaited(Task initializeTask, CancellationToken cancellationToken) { await initializeTask; - await Output.FlushAsync(cancellationToken); + await FlushAsyncInternal(cancellationToken); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Task FlushAsyncInternal(CancellationToken cancellationToken) + { + _requestProcessingStatus = RequestProcessingStatus.HeadersFlushed; + return Output.FlushAsync(cancellationToken); } public Task WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default(CancellationToken)) @@ -825,20 +834,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { if (data.Length == 0) { - return !firstWrite ? Task.CompletedTask : FlushAsync(cancellationToken); + return !firstWrite ? Task.CompletedTask : FlushAsyncInternal(cancellationToken); } return WriteChunkedAsync(data.Span, cancellationToken); } else { CheckLastWrite(); - return Output.WriteDataAsync(data.Span, cancellationToken: cancellationToken); + return WriteDataAsync(data, cancellationToken: cancellationToken); } } else { HandleNonBodyResponseWrite(); - return !firstWrite ? Task.CompletedTask : FlushAsync(cancellationToken); + return !firstWrite ? Task.CompletedTask : FlushAsyncInternal(cancellationToken); } } @@ -854,7 +863,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { if (data.Length == 0) { - await FlushAsync(cancellationToken); + await FlushAsyncInternal(cancellationToken); return; } @@ -863,16 +872,22 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http else { CheckLastWrite(); - await Output.WriteDataAsync(data.Span, cancellationToken: cancellationToken); + await WriteDataAsync(data, cancellationToken); } } else { HandleNonBodyResponseWrite(); - await FlushAsync(cancellationToken); + await FlushAsyncInternal(cancellationToken); } } + private Task WriteDataAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + _requestProcessingStatus = RequestProcessingStatus.HeadersFlushed; + return Output.WriteDataAsync(data.Span, cancellationToken: cancellationToken); + } + private void VerifyAndUpdateWrite(int count) { var responseHeaders = HttpResponseHeaders; @@ -945,6 +960,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http private Task WriteChunkedAsync(ReadOnlySpan data, CancellationToken cancellationToken) { + _requestProcessingStatus = RequestProcessingStatus.HeadersFlushed; return Output.WriteChunkAsync(data, cancellationToken); } @@ -1004,7 +1020,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http return; } - _requestProcessingStatus = RequestProcessingStatus.ResponseStarted; + _requestProcessingStatus = RequestProcessingStatus.HeadersCommitted; CreateResponseHeader(appCompleted); } @@ -1047,23 +1063,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http if (!HasResponseStarted) { - return ProduceEndAwaited(); + ProduceStart(appCompleted: true); } return WriteSuffix(); } - [MethodImpl(MethodImplOptions.NoInlining)] - private async Task ProduceEndAwaited() - { - ProduceStart(appCompleted: true); - - // Force flush - await Output.FlushAsync(default(CancellationToken)); - - await WriteSuffix(); - } - private Task WriteSuffix() { // _autoChunk should be checked after we are sure ProduceStart() has been called @@ -1083,6 +1088,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http Log.ConnectionHeadResponseBodyWrite(ConnectionId, _responseBytesWritten); } + if (!HasFlushedHeaders) + { + return FlushAsyncInternal(default); + } + return Task.CompletedTask; } @@ -1091,6 +1101,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http // For the same reason we call CheckLastWrite() in Content-Length responses. PreventRequestAbortedCancellation(); + _requestProcessingStatus = RequestProcessingStatus.HeadersFlushed; + await Output.WriteStreamSuffixAsync(); if (_keepAlive) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/RequestProcessingStatus.cs b/src/Servers/Kestrel/Core/src/Internal/Http/RequestProcessingStatus.cs index f6e4248047..d32e4e3f9b 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/RequestProcessingStatus.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/RequestProcessingStatus.cs @@ -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. namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http @@ -9,6 +9,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http ParsingRequestLine, ParsingHeaders, AppStarted, - ResponseStarted + HeadersCommitted, + HeadersFlushed } -} \ No newline at end of file +} diff --git a/src/Servers/Kestrel/Core/test/HttpProtocolFeatureCollectionTests.cs b/src/Servers/Kestrel/Core/test/HttpProtocolFeatureCollectionTests.cs index 011fa59fe1..24cce16454 100644 --- a/src/Servers/Kestrel/Core/test/HttpProtocolFeatureCollectionTests.cs +++ b/src/Servers/Kestrel/Core/test/HttpProtocolFeatureCollectionTests.cs @@ -124,6 +124,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _collection[typeof(IHttpMinRequestBodyDataRateFeature)] = CreateHttp1Connection(); _collection[typeof(IHttpMinResponseDataRateFeature)] = CreateHttp1Connection(); _collection[typeof(IHttpBodyControlFeature)] = CreateHttp1Connection(); + _collection[typeof(IHttpResponseStartFeature)] = CreateHttp1Connection(); CompareGenericGetterToIndexer(); @@ -142,6 +143,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _collection.Set(CreateHttp1Connection()); _collection.Set(CreateHttp1Connection()); _collection.Set(CreateHttp1Connection()); + _collection.Set(CreateHttp1Connection()); CompareGenericGetterToIndexer(); @@ -178,6 +180,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests Assert.Same(_collection.Get(), _collection[typeof(IHttpMinRequestBodyDataRateFeature)]); Assert.Same(_collection.Get(), _collection[typeof(IHttpMinResponseDataRateFeature)]); Assert.Same(_collection.Get(), _collection[typeof(IHttpBodyControlFeature)]); + Assert.Same(_collection.Get(), _collection[typeof(IHttpResponseStartFeature)]); } private int EachHttpProtocolFeatureSetAndUnique() diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs index 46ce78701c..bc898995c0 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; @@ -1024,6 +1025,388 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); } + [Fact] + public async Task StartAsync_Response_NoBytesWritten_Sends200() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + await context.Response.StartAsync(); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + 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]); + } + + [Fact] + public async Task StartAsync_ContentLength_Response_NoBytesWritten_Sends200() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + context.Response.ContentLength = 0; + await context.Response.StartAsync(); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + } + + [Fact] + public async Task StartAsync_OnStartingThrowsAfterStartAsyncIsCalled() + { + InvalidOperationException ex = null; + + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + await context.Response.StartAsync(); + ex = Assert.Throws(() => context.Response.OnStarting(_ => Task.CompletedTask, null)); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + Assert.NotNull(ex); + + _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]); + } + + [Fact] + public async Task StartAsync_StartsResponse() + { + + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + await context.Response.StartAsync(); + Assert.True(context.Response.HasStarted); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + 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]); + } + + [Fact] + public async Task StartAsync_WithoutFinalFlushDoesNotFlushUntilResponseEnd() + { + var tcs = 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 => + { + await context.Response.StartAsync(); + + // Verify that the response isn't flushed by verifying the TCS isn't set + var res = await Task.WhenAny(tcs.Task, Task.Delay(1000)) == tcs.Task; + await context.Response.WriteAsync("hello, world"); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + + var dataFrame = await ExpectAsync(Http2FrameType.DATA, + withLength: 12, + withFlags: (byte)Http2DataFrameFlags.NONE, + withStreamId: 1); + + tcs.SetResult(null); + + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + 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.True(_helloWorldBytes.AsSpan().SequenceEqual(dataFrame.PayloadSequence.ToArray())); + } + + [Fact] + public async Task StartAsync_FlushStillFlushesBody() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + await context.Response.StartAsync(); + + // Verify that the response isn't flushed by verifying the TCS isn't set + await context.Response.Body.FlushAsync(); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + 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]); + } + + [Fact] + public async Task StartAsync_WithContentLengthAndEmptyWriteCallsFinalFlush() + { + + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + context.Response.ContentLength = 0; + await context.Response.StartAsync(); + await context.Response.WriteAsync(""); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + } + + [Fact] + public async Task StartAsync_SingleWriteCallsFinalFlush() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + await context.Response.StartAsync(); + await context.Response.WriteAsync("hello, world"); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + + var dataFrame = await ExpectAsync(Http2FrameType.DATA, + withLength: 12, + withFlags: (byte)Http2DataFrameFlags.NONE, + withStreamId: 1); + + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + 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.True(_helloWorldBytes.AsSpan().SequenceEqual(dataFrame.PayloadSequence.ToArray())); + } + + [Fact] + public async Task StartAsync_ContentLength_ThrowsException_DataIsFlushed_ConnectionReset() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + context.Response.ContentLength = 11; + await context.Response.StartAsync(); + throw new Exception(); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 56, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + + await WaitForStreamErrorAsync(1, Http2ErrorCode.INTERNAL_ERROR, ""); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("11", _decodedHeaders[HeaderNames.ContentLength]); + } + + [Fact] + public async Task StartAsync_ThrowsException_DataIsFlushed() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(async context => + { + await context.Response.StartAsync(); + throw new Exception(); + }); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + + await WaitForStreamErrorAsync(1, Http2ErrorCode.INTERNAL_ERROR, ""); + + 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]); + } + [Fact] public async Task ContentLength_Response_TooFewBytesWritten_Resets() { diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs index 7b8ae6b955..884f4194ef 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/ResponseTests.cs @@ -9,6 +9,7 @@ using System.Net; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -102,6 +103,39 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests } } + [Fact] + public async Task OnStartingThrowsWhenSetAfterStartAsyncIsCalled() + { + InvalidOperationException ex = null; + + using (var server = new TestServer(async context => + { + await context.Response.StartAsync(); + ex = Assert.Throws(() => context.Response.OnStarting(_ => Task.CompletedTask, null)); + }, new TestServiceContext(LoggerFactory))) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + + await connection.Receive($"HTTP/1.1 200 OK", + $"Date: {server.Context.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "0", + "", + ""); + + Assert.NotNull(ex); + } + await server.StopAsync(); + } + } + [Fact] public async Task ResponseBodyWriteAsyncCanBeCancelled() { @@ -2336,6 +2370,321 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests } } + [Fact] + public async Task StartAsyncDefaultToChunkedResponse() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "0", + "", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncWithContentLengthAndEmptyWriteStillCallsFinalFlush() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + httpContext.Response.ContentLength = 0; + await httpContext.Response.StartAsync(); + await httpContext.Response.WriteAsync(""); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Content-Length: 0", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncAndEmptyWriteStillCallsFinalFlush() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + await httpContext.Response.WriteAsync(""); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "0", + "", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncWithSingleChunkedWriteStillWritesSuffix() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + await httpContext.Response.WriteAsync("Hello World!"); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "c", + "Hello World!", + "0", + "", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncWithoutFlushStartsResponse() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + Assert.True(httpContext.Response.HasStarted); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "0", + "", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncThrowExceptionThrowsConnectionAbortedException() + { + var testContext = new TestServiceContext(LoggerFactory); + var expectedException = new Exception(); + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + throw expectedException; + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncWithContentLengthThrowExceptionThrowsConnectionAbortedException() + { + var testContext = new TestServiceContext(LoggerFactory); + var expectedException = new Exception(); + using (var server = new TestServer(async httpContext => + { + httpContext.Response.ContentLength = 11; + await httpContext.Response.StartAsync(); + throw expectedException; + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Content-Length: 11", + "", + ""); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncWithoutFlushingDoesNotFlush() + { + var testContext = new TestServiceContext(LoggerFactory); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + Assert.True(httpContext.Response.HasStarted); + + // Verify that the response isn't flushed by verifying the TCS isn't set + var res = await Task.WhenAny(tcs.Task, Task.Delay(1000)) == tcs.Task; + Assert.False(res); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "0", + "", + ""); + // If we reach this point before the app exits, this means the flush finished early. + tcs.SetResult(null); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncWithContentLengthWritingWorks() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + httpContext.Response.Headers["Content-Length"] = new[] { "11" }; + await httpContext.Response.StartAsync(); + await httpContext.Response.WriteAsync("Hello World"); + Assert.True(httpContext.Response.HasStarted); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Content-Length: 11", + "", + "Hello World"); + } + await server.StopAsync(); + } + } + + [Fact] + public async Task StartAsyncAndFlushWorks() + { + var testContext = new TestServiceContext(LoggerFactory); + + using (var server = new TestServer(async httpContext => + { + await httpContext.Response.StartAsync(); + await httpContext.Response.Body.FlushAsync(); + Assert.True(httpContext.Response.HasStarted); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "GET / HTTP/1.1", + "Host:", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + "0", + "", + ""); + } + await server.StopAsync(); + } + } + [Fact] public async Task OnStartingCallbacksAreCalledInLastInFirstOutOrder() { diff --git a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs index 98be2c3fea..d6828450d0 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs @@ -40,6 +40,7 @@ namespace CodeGenerator "IHttpMinRequestBodyDataRateFeature", "IHttpMinResponseDataRateFeature", "IHttpBodyControlFeature", + "IHttpResponseStartFeature" }; var rareFeatures = new[] @@ -65,6 +66,7 @@ namespace CodeGenerator "IHttpConnectionFeature", "IHttpMaxRequestBodySizeFeature", "IHttpBodyControlFeature", + "IHttpResponseStartFeature" }; var usings = $@"