diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/Internal/Http/Frame.cs b/src/Microsoft.AspNetCore.Server.Kestrel/Internal/Http/Frame.cs index 725e284bc6..307ce7bc49 100644 --- a/src/Microsoft.AspNetCore.Server.Kestrel/Internal/Http/Frame.cs +++ b/src/Microsoft.AspNetCore.Server.Kestrel/Internal/Http/Frame.cs @@ -520,6 +520,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http public void Write(ArraySegment data) { + // For the first write, ensure headers are flushed if Write(Chunked)isn't called. + var firstWrite = !HasResponseStarted; + VerifyAndUpdateWrite(data.Count); ProduceStartAndFireOnStarting().GetAwaiter().GetResult(); @@ -529,6 +532,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http { if (data.Count == 0) { + if (firstWrite) + { + Flush(); + } return; } WriteChunked(data); @@ -541,6 +548,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http else { HandleNonBodyResponseWrite(); + + if (firstWrite) + { + Flush(); + } } } @@ -581,14 +593,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http await ProduceStartAndFireOnStarting(); + // WriteAsyncAwaited is only called for the first write to the body. + // Ensure headers are flushed if Write(Chunked)Async isn't called. if (_canHaveBody) { if (_autoChunk) { if (data.Count == 0) { + await FlushAsync(cancellationToken); return; } + await WriteChunkedAsync(data, cancellationToken); } else @@ -599,7 +615,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http else { HandleNonBodyResponseWrite(); - return; + await FlushAsync(cancellationToken); } } diff --git a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/ResponseTests.cs b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/ResponseTests.cs index 5b4b5b73fe..dbf6f5bdd4 100644 --- a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/ResponseTests.cs +++ b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/ResponseTests.cs @@ -7,6 +7,7 @@ using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -767,11 +768,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests { using (var connection = server.CreateConnection()) { - await connection.SendEnd( + await connection.Send( "HEAD / HTTP/1.1", "", ""); - await connection.ReceiveEnd( + await connection.Receive( "HTTP/1.1 200 OK", $"Date: {server.Context.DateHeaderValue}", "Content-Length: 42", @@ -782,26 +783,95 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests } [Fact] - public async Task HeadResponseCanContainContentLengthHeaderButBodyNotWritten() + public async Task HeadResponseBodyNotWrittenWithAsyncWrite() { + var flushed = new SemaphoreSlim(0, 1); + using (var server = new TestServer(async httpContext => { httpContext.Response.ContentLength = 12; await httpContext.Response.WriteAsync("hello, world"); + await flushed.WaitAsync(); + }, new TestServiceContext())) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "HEAD / HTTP/1.1", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {server.Context.DateHeaderValue}", + "Content-Length: 12", + "", + ""); + + flushed.Release(); + } + } + } + + [Fact] + public async Task HeadResponseBodyNotWrittenWithSyncWrite() + { + var flushed = new SemaphoreSlim(0, 1); + + using (var server = new TestServer(httpContext => + { + httpContext.Response.ContentLength = 12; + httpContext.Response.Body.Write(Encoding.ASCII.GetBytes("hello, world"), 0, 12); + flushed.Wait(); + return TaskCache.CompletedTask; + }, new TestServiceContext())) + { + using (var connection = server.CreateConnection()) + { + await connection.Send( + "HEAD / HTTP/1.1", + "", + ""); + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {server.Context.DateHeaderValue}", + "Content-Length: 12", + "", + ""); + + flushed.Release(); + } + } + } + + [Fact] + public async Task ZeroLengthWritesFlushHeaders() + { + var flushed = new SemaphoreSlim(0, 1); + + using (var server = new TestServer(async httpContext => + { + httpContext.Response.ContentLength = 12; + await httpContext.Response.WriteAsync(""); + flushed.Wait(); + await httpContext.Response.WriteAsync("hello, world"); }, new TestServiceContext())) { using (var connection = server.CreateConnection()) { await connection.SendEnd( - "HEAD / HTTP/1.1", + "GET / HTTP/1.1", "", ""); - await connection.ReceiveEnd( + await connection.Receive( "HTTP/1.1 200 OK", $"Date: {server.Context.DateHeaderValue}", "Content-Length: 12", "", ""); + + flushed.Release(); + + await connection.ReceiveEnd("hello, world"); } } } diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/ChunkedResponseTests.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/ChunkedResponseTests.cs index 469f17051c..7ce2297b00 100644 --- a/test/Microsoft.AspNetCore.Server.KestrelTests/ChunkedResponseTests.cs +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/ChunkedResponseTests.cs @@ -192,6 +192,48 @@ namespace Microsoft.AspNetCore.Server.KestrelTests } } + [Theory] + [MemberData(nameof(ConnectionFilterData))] + public async Task ZeroLengthWritesFlushHeaders(TestServiceContext testContext) + { + var flushed = new SemaphoreSlim(0, 1); + + using (var server = new TestServer(async httpContext => + { + var response = httpContext.Response; + await response.WriteAsync(""); + + await flushed.WaitAsync(); + + await response.WriteAsync("Hello World!"); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.SendEnd( + "GET / HTTP/1.1", + "", + ""); + + await connection.Receive( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Transfer-Encoding: chunked", + "", + ""); + + flushed.Release(); + + await connection.ReceiveEnd( + "c", + "Hello World!", + "0", + "", + ""); + } + } + } + [Theory] [MemberData(nameof(ConnectionFilterData))] public async Task EmptyResponseBodyHandledCorrectlyWithZeroLengthWrite(TestServiceContext testContext)