From 6a188e11d62dc2da16cbe08c618c34914d969853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Tue, 3 Nov 2026 18:12:27 +0100 Subject: [PATCH 1/2] perf(engine): Write the HTTP/1.1 response head in a single pass ResponseSerializer issued 15-25 small writes per response, each with its own GetSpan/Advance pair on the PipeWriter. The head size is now computed up front and everything is copied into one span with a single Advance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016Z4d2PxGuaT75fzUxMqgyN --- Engine/Shared/Types/ResponseSerializer.cs | 165 ++++++++++++++++------ Engine/Shared/Types/WriterExtensions.cs | 24 ---- Testing/Acceptance/Engine/HeaderTests.cs | 31 +++- 3 files changed, 148 insertions(+), 72 deletions(-) delete mode 100644 Engine/Shared/Types/WriterExtensions.cs diff --git a/Engine/Shared/Types/ResponseSerializer.cs b/Engine/Shared/Types/ResponseSerializer.cs index b1c267f40..507a01686 100644 --- a/Engine/Shared/Types/ResponseSerializer.cs +++ b/Engine/Shared/Types/ResponseSerializer.cs @@ -1,5 +1,5 @@ -using System.Buffers; using System.IO.Pipelines; +using System.Runtime.CompilerServices; using GenHTTP.Api.Protocol; @@ -7,6 +7,21 @@ namespace GenHTTP.Engine.Shared.Types; public static class ResponseSerializer { + private const int MaxNumberLength = 20; + + private static ReadOnlySpan Newline => "\r\n"u8; + + private static ReadOnlySpan HeaderSeparator => ": "u8; + + private static ReadOnlySpan ContentTypePrefix => "Content-Type: "u8; + + private static ReadOnlySpan ContentLengthPrefix => "Content-Length: "u8; + + private static ReadOnlySpan ContentEncodingPrefix => "Content-Encoding: "u8; + + private static ReadOnlySpan ChunkedHeader => "Transfer-Encoding: chunked\r\n"u8; + + private static ReadOnlySpan EmptyContentLength => "Content-Length: 0\r\n"u8; public static bool ShouldSendBody(IRequest? request, IResponse response, bool headRequest) { @@ -33,97 +48,153 @@ public static bool ShouldSendBody(IRequest? request, IResponse response, bool he /// /// Writes the status line and the headers of the final response to the given request. /// + /// + /// Calculates the size of the head first, so that it can be written into a single span + /// instead of issuing a separate write for every fragment. + /// /// The request the response is written for, if any public static void WriteHead(PipeWriter writer, Request? request, IResponse response, bool keepAlive, ReadOnlyMemory serverHeader, ReadOnlyMemory dateHeader, bool isHttp10) { request?.ResponseStarting(); - writer.Write(StatusLine.Get(response.Status)); + var headers = response.Headers; - WriteHeader(writer, response, keepAlive, serverHeader, dateHeader, isHttp10); + var statusLine = StatusLine.Get(response.Status); - writer.Write("\r\n"u8); - } + var server = headers.ContainsKey(KnownHeaders.Server) ? ReadOnlySpan.Empty : serverHeader.Span; + var date = headers.ContainsKey(KnownHeaders.Date) ? ReadOnlySpan.Empty : dateHeader.Span; - private static void WriteHeader(PipeWriter writer, IResponse response, bool keepAlive, ReadOnlyMemory serverHeader, ReadOnlyMemory dateHeader, bool isHttp10) - { var isUpgrade = response.Mode == Connection.Upgrade; - if (!response.Headers.ContainsKey(KnownHeaders.Server)) - { - writer.Write(serverHeader.Span); - } + var connection = GetConnectionHeader(isUpgrade, keepAlive, isHttp10); - if (!response.Headers.ContainsKey(KnownHeaders.Date)) - { - writer.Write(dateHeader.Span); - } + var content = response.Content; - if (isUpgrade) + var type = content?.Type; + var length = content?.Length; + var encoding = content?.Encoding; + + var size = statusLine.Length + server.Length + date.Length + connection.Length + Newline.Length; + + if (content != null) { - writer.Write("Connection: Upgrade\r\n"u8); + if (type != null) + { + size += ContentTypePrefix.Length + type.Value.Bytes.Length + Newline.Length; + } + + if (length != null) + { + size += ContentLengthPrefix.Length + MaxNumberLength + Newline.Length; + } + else if (!isUpgrade) + { + size += ChunkedHeader.Length; + } + + if (encoding != null) + { + size += ContentEncodingPrefix.Length + encoding.Value.Length + Newline.Length; + } } - else if (isHttp10) + else { - writer.Write(keepAlive ? "Connection: Keep-Alive\r\n"u8 : "Connection: Close\r\n"u8); + size += EmptyContentLength.Length; } - else if (!keepAlive) + + var count = headers.Count; + + for (var i = 0; i < count; i++) { - // HTTP/1.1 connections are persistent by default so we do not need to send a Keep-Alive header - writer.Write("Connection: Close\r\n"u8); + var header = headers.GetMemoryEntry(i); + + size += header.Key.Length + HeaderSeparator.Length + header.Value.Length + Newline.Length; } - var content = response.Content; + var span = writer.GetSpan(size); + + var offset = 0; + + Append(span, ref offset, statusLine); + Append(span, ref offset, server); + Append(span, ref offset, date); + Append(span, ref offset, connection); if (content != null) { - var type = content.Type; - if (type != null) { - writer.Write("Content-Type: "u8); - writer.Write(type.Value.Bytes.Span); - writer.Write("\r\n"u8); + Append(span, ref offset, ContentTypePrefix); + Append(span, ref offset, type.Value.Bytes.Span); + Append(span, ref offset, Newline); } - var length = content.Length; - if (length != null) { - writer.Write("Content-Length: "u8); - writer.Write(length.Value); - writer.Write("\r\n"u8); + Append(span, ref offset, ContentLengthPrefix); + + if (!length.Value.TryFormat(span[offset..], out var written)) + { + throw new InvalidOperationException("Unable to write number to buffer"); + } + + offset += written; + + Append(span, ref offset, Newline); } else if (!isUpgrade) { - writer.Write("Transfer-Encoding: chunked\r\n"u8); + Append(span, ref offset, ChunkedHeader); } - var encoding = content.Encoding; - if (encoding != null) { - writer.Write("Content-Encoding: "u8); - writer.Write(encoding.Value.Span); - writer.Write("\r\n"u8); + Append(span, ref offset, ContentEncodingPrefix); + Append(span, ref offset, encoding.Value.Span); + Append(span, ref offset, Newline); } } else { - writer.Write("Content-Length: 0\r\n"u8); + Append(span, ref offset, EmptyContentLength); } - var headers = response.Headers; - - for (var i = 0; i < headers.Count; i++) + for (var i = 0; i < count; i++) { var header = headers.GetMemoryEntry(i); - writer.Write(header.Key.Span); - writer.Write(": "u8); - writer.Write(header.Value.Span); - writer.Write("\r\n"u8); + Append(span, ref offset, header.Key.Span); + Append(span, ref offset, HeaderSeparator); + Append(span, ref offset, header.Value.Span); + Append(span, ref offset, Newline); + } + + Append(span, ref offset, Newline); + + writer.Advance(offset); + } + + private static ReadOnlySpan GetConnectionHeader(bool isUpgrade, bool keepAlive, bool isHttp10) + { + if (isUpgrade) + { + return "Connection: Upgrade\r\n"u8; + } + + if (isHttp10) + { + return keepAlive ? "Connection: Keep-Alive\r\n"u8 : "Connection: Close\r\n"u8; } + + // HTTP/1.1 connections are persistent by default so we do not need to send a Keep-Alive header + return keepAlive ? ReadOnlySpan.Empty : "Connection: Close\r\n"u8; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Append(Span target, ref int offset, ReadOnlySpan value) + { + value.CopyTo(target[offset..]); + offset += value.Length; } } diff --git a/Engine/Shared/Types/WriterExtensions.cs b/Engine/Shared/Types/WriterExtensions.cs deleted file mode 100644 index 72362070d..000000000 --- a/Engine/Shared/Types/WriterExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Buffers; -using System.Runtime.CompilerServices; - -namespace GenHTTP.Engine.Shared.Types; - -internal static class BufferWriterExtensions -{ - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static void Write(this IBufferWriter writer, ulong number) - { - Span buffer = writer.GetSpan(20); - - if (number.TryFormat(buffer, out var written)) - { - writer.Advance(written); - } - else - { - throw new InvalidOperationException("Unable to write number to buffer"); - } - } - -} diff --git a/Testing/Acceptance/Engine/HeaderTests.cs b/Testing/Acceptance/Engine/HeaderTests.cs index add734c75..c57acb778 100644 --- a/Testing/Acceptance/Engine/HeaderTests.cs +++ b/Testing/Acceptance/Engine/HeaderTests.cs @@ -1,4 +1,7 @@ -using GenHTTP.Api.Infrastructure; +using System.Net; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Modules.IO; using GenHTTP.Testing.Acceptance.Utilities; namespace GenHTTP.Testing.Acceptance.Engine; @@ -25,6 +28,32 @@ public async Task TestServerHeaderCanBeSet(ServerEngine engine) Assert.AreEqual("TFB", response.GetHeader("Server")); } + [TestMethod] + [MultiEngineTest] + public async Task TestLargeHeadersAreWritten(ServerEngine engine) + { + var value = new string('x', 20_000); + + var handler = new FunctionalHandler(responseProvider: r => + { + return r.Respond() + .Header("X-Large", value) + .Header("X-Small", "small") + .Content("Hello World") + .Build(); + }); + + await using var runner = await TestHost.RunAsync(handler.Wrap(), engine: engine); + + using var response = await runner.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual(value, response.GetHeader("X-Large")); + Assert.AreEqual("small", response.GetHeader("X-Small")); + Assert.AreEqual("Hello World", await response.GetContentAsync()); + } + [TestMethod] [MultiEngineTest] public async Task TestDateHeaderCanBeSet(ServerEngine engine) From 199979986ee846cfbd2b9d3d2616dd1ce037f30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 4 Nov 2026 13:58:17 +0100 Subject: [PATCH 2/2] perf(engine): Detect Server and Date while sizing the response head The custom headers are now scanned once to compute their size and to find Server or Date, instead of two extra ContainsKey passes. The span requested for the head uses the exact Content-Length digit count. Also documents ShouldSendBody. Co-Authored-By: Claude Opus 5.5 --- Engine/Shared/Types/ResponseSerializer.cs | 59 +++++++++++++++++------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/Engine/Shared/Types/ResponseSerializer.cs b/Engine/Shared/Types/ResponseSerializer.cs index 507a01686..7e141c4f4 100644 --- a/Engine/Shared/Types/ResponseSerializer.cs +++ b/Engine/Shared/Types/ResponseSerializer.cs @@ -7,8 +7,6 @@ namespace GenHTTP.Engine.Shared.Types; public static class ResponseSerializer { - private const int MaxNumberLength = 20; - private static ReadOnlySpan Newline => "\r\n"u8; private static ReadOnlySpan HeaderSeparator => ": "u8; @@ -23,6 +21,16 @@ public static class ResponseSerializer private static ReadOnlySpan EmptyContentLength => "Content-Length: 0\r\n"u8; + /// + /// Checks, whether the body of the given response needs to be written to the client. + /// + /// + /// The headers still describe the content if no body is sent, e.g. for a HEAD request. + /// + /// The request the response is written for, or null if the request could not be parsed + /// The response to be written + /// Whether the client sent a HEAD request + /// false, if there is no content, the content is known to be empty or this is a HEAD request public static bool ShouldSendBody(IRequest? request, IResponse response, bool headRequest) { if (request == null) @@ -59,10 +67,27 @@ public static void WriteHead(PipeWriter writer, Request? request, IResponse resp var headers = response.Headers; + var count = headers.Count; + + var size = 0; + + var hasServer = false; + var hasDate = false; + + for (var i = 0; i < count; i++) + { + var header = headers.GetMemoryEntry(i); + + hasServer |= header.Key == KnownHeaders.Server; + hasDate |= header.Key == KnownHeaders.Date; + + size += header.Key.Length + HeaderSeparator.Length + header.Value.Length + Newline.Length; + } + var statusLine = StatusLine.Get(response.Status); - var server = headers.ContainsKey(KnownHeaders.Server) ? ReadOnlySpan.Empty : serverHeader.Span; - var date = headers.ContainsKey(KnownHeaders.Date) ? ReadOnlySpan.Empty : dateHeader.Span; + var server = hasServer ? ReadOnlySpan.Empty : serverHeader.Span; + var date = hasDate ? ReadOnlySpan.Empty : dateHeader.Span; var isUpgrade = response.Mode == Connection.Upgrade; @@ -74,7 +99,7 @@ public static void WriteHead(PipeWriter writer, Request? request, IResponse resp var length = content?.Length; var encoding = content?.Encoding; - var size = statusLine.Length + server.Length + date.Length + connection.Length + Newline.Length; + size += statusLine.Length + server.Length + date.Length + connection.Length + Newline.Length; if (content != null) { @@ -85,7 +110,7 @@ public static void WriteHead(PipeWriter writer, Request? request, IResponse resp if (length != null) { - size += ContentLengthPrefix.Length + MaxNumberLength + Newline.Length; + size += ContentLengthPrefix.Length + CountDigits(length.Value) + Newline.Length; } else if (!isUpgrade) { @@ -102,15 +127,6 @@ public static void WriteHead(PipeWriter writer, Request? request, IResponse resp size += EmptyContentLength.Length; } - var count = headers.Count; - - for (var i = 0; i < count; i++) - { - var header = headers.GetMemoryEntry(i); - - size += header.Key.Length + HeaderSeparator.Length + header.Value.Length + Newline.Length; - } - var span = writer.GetSpan(size); var offset = 0; @@ -190,6 +206,19 @@ private static ReadOnlySpan GetConnectionHeader(bool isUpgrade, bool keepA return keepAlive ? ReadOnlySpan.Empty : "Connection: Close\r\n"u8; } + private static int CountDigits(ulong value) + { + var digits = 1; + + while (value >= 10) + { + value /= 10; + digits++; + } + + return digits; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void Append(Span target, ref int offset, ReadOnlySpan value) {