diff --git a/src/Servers/HttpSys/src/HttpSysOptions.cs b/src/Servers/HttpSys/src/HttpSysOptions.cs
index 44bc0bc5faa8..87fb1ba6d176 100644
--- a/src/Servers/HttpSys/src/HttpSysOptions.cs
+++ b/src/Servers/HttpSys/src/HttpSysOptions.cs
@@ -29,6 +29,11 @@ public class HttpSysOptions
private long? _maxRequestBodySize = DefaultMaxRequestBodySize;
private string? _requestQueueName;
+ private const string RespectHttp10KeepAliveSwitch = "Microsoft.AspNetCore.Server.HttpSys.RespectHttp10KeepAlive";
+
+ // Internal for testing
+ internal bool RespectHttp10KeepAlive = AppContext.TryGetSwitch(RespectHttp10KeepAliveSwitch, out var enabled) && enabled;
+
///
/// Initializes a new .
///
diff --git a/src/Servers/HttpSys/src/RequestProcessing/Response.cs b/src/Servers/HttpSys/src/RequestProcessing/Response.cs
index b10de647511d..e080698ea871 100644
--- a/src/Servers/HttpSys/src/RequestProcessing/Response.cs
+++ b/src/Servers/HttpSys/src/RequestProcessing/Response.cs
@@ -30,6 +30,7 @@ internal sealed class Response
private BoundaryType _boundaryType;
private HttpApiTypes.HTTP_RESPONSE_V2 _nativeResponse;
private HeaderCollection? _trailers;
+ private readonly bool _respectHttp10KeepAlive;
internal Response(RequestContext requestContext)
{
@@ -51,6 +52,7 @@ internal Response(RequestContext requestContext)
_nativeStream = null;
_cacheTtl = null;
_authChallenges = RequestContext.Server.Options.Authentication.Schemes;
+ _respectHttp10KeepAlive = RequestContext.Server.Options.RespectHttp10KeepAlive;
}
private enum ResponseState
@@ -390,6 +392,7 @@ internal HttpApiTypes.HTTP_FLAGS ComputeHeaders(long writeCount, bool endOfReque
var requestConnectionString = Request.Headers[HeaderNames.Connection];
var isHeadRequest = Request.IsHeadMethod;
var requestCloseSet = Matches(Constants.Close, requestConnectionString);
+ var requestConnectionKeepAliveSet = Matches(Constants.KeepAlive, requestConnectionString);
// Gather everything the app may have set on the response:
// Http.Sys does not allow us to specify the response protocol version, assume this is a HTTP/1.1 response when making decisions.
@@ -402,12 +405,25 @@ internal HttpApiTypes.HTTP_FLAGS ComputeHeaders(long writeCount, bool endOfReque
// Determine if the connection will be kept alive or closed.
var keepConnectionAlive = true;
- if (requestVersion <= Constants.V1_0 // Http.Sys does not support "Keep-Alive: true" or "Connection: Keep-Alive"
+
+ if (requestVersion < Constants.V1_0
|| (requestVersion == Constants.V1_1 && requestCloseSet)
|| responseCloseSet)
{
keepConnectionAlive = false;
}
+ else if (requestVersion == Constants.V1_0)
+ {
+ // In .NET 9, we updated the behavior for 1.0 clients here to match
+ // RFC 2068. The new behavior is available down-level behind an
+ // AppContext switch.
+
+ // An HTTP/1.1 server may also establish persistent connections with
+ // HTTP/1.0 clients upon receipt of a Keep-Alive connection token.
+ // However, a persistent connection with an HTTP/1.0 client cannot make
+ // use of the chunked transfer-coding. From: https://www.rfc-editor.org/rfc/rfc2068#section-19.7.1
+ keepConnectionAlive = _respectHttp10KeepAlive && requestConnectionKeepAliveSet && !responseChunkedSet;
+ }
// Determine the body format. If the user asks to do something, let them, otherwise choose a good default for the scenario.
if (responseContentLength.HasValue)
diff --git a/src/Servers/HttpSys/test/FunctionalTests/Listener/ResponseHeaderTests.cs b/src/Servers/HttpSys/test/FunctionalTests/Listener/ResponseHeaderTests.cs
index 4ceb2d4c6bc2..f7213a212879 100644
--- a/src/Servers/HttpSys/test/FunctionalTests/Listener/ResponseHeaderTests.cs
+++ b/src/Servers/HttpSys/test/FunctionalTests/Listener/ResponseHeaderTests.cs
@@ -6,6 +6,7 @@
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Testing;
@@ -207,20 +208,83 @@ public async Task ResponseHeaders_11HeadRequestStatusCodeWithoutBody_NoContentLe
}
[ConditionalFact]
- public async Task ResponseHeaders_HTTP10KeepAliveRequest_Gets11Close()
+ public async Task ResponseHeaders_HTTP10KeepAliveRequest_KeepAliveHeader_RespectsSwitch()
+ {
+ string address;
+ using (var server = Utilities.CreateHttpServer(out address, respectHttp10KeepAlive: true))
+ {
+ // Track the number of times ConnectCallback is invoked to ensure the underlying socket wasn't closed.
+ int connectCallbackInvocations = 0;
+ var handler = new SocketsHttpHandler();
+ handler.ConnectCallback = (context, cancellationToken) =>
+ {
+ Interlocked.Increment(ref connectCallbackInvocations);
+ return ConnectCallback(context, cancellationToken);
+ };
+
+ using (var client = new HttpClient(handler))
+ {
+ // Send the first request
+ Task responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true, httpClient: client);
+ var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
+ context.Dispose();
+
+ HttpResponseMessage response = await responseTask;
+ response.EnsureSuccessStatusCode();
+ Assert.Equal(new Version(1, 1), response.Version);
+ Assert.Null(response.Headers.ConnectionClose);
+
+ // Send the second request
+ responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true, httpClient: client);
+ context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
+ context.Dispose();
+
+ response = await responseTask;
+ response.EnsureSuccessStatusCode();
+ Assert.Equal(new Version(1, 1), response.Version);
+ Assert.Null(response.Headers.ConnectionClose);
+ }
+
+ // Verify that ConnectCallback was only called once
+ Assert.Equal(1, connectCallbackInvocations);
+ }
+
+ using (var server = Utilities.CreateHttpServer(out address, respectHttp10KeepAlive: false))
+ {
+ var handler = new SocketsHttpHandler();
+ using (var client = new HttpClient(handler))
+ {
+ // Send the first request
+ Task responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true, httpClient: client);
+ var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
+ context.Dispose();
+
+ HttpResponseMessage response = await responseTask;
+ response.EnsureSuccessStatusCode();
+ Assert.Equal(new Version(1, 1), response.Version);
+ Assert.True(response.Headers.ConnectionClose.Value);
+ }
+ }
+ }
+
+ [ConditionalFact]
+ public async Task ResponseHeaders_HTTP10KeepAliveRequest_ChunkedTransferEncoding_Gets11Close()
{
string address;
using (var server = Utilities.CreateHttpServer(out address))
{
- // Http.Sys does not support 1.0 keep-alives.
Task responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true);
var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
+ context.Response.Headers["Transfer-Encoding"] = new string[] { "chunked" };
+ var responseBytes = Encoding.ASCII.GetBytes("10\r\nManually Chunked\r\n0\r\n\r\n");
+ await context.Response.Body.WriteAsync(responseBytes, 0, responseBytes.Length);
context.Dispose();
HttpResponseMessage response = await responseTask;
response.EnsureSuccessStatusCode();
Assert.Equal(new Version(1, 1), response.Version);
+ Assert.True(response.Headers.TransferEncodingChunked.HasValue, "Chunked");
Assert.True(response.Headers.ConnectionClose.Value);
}
}
@@ -289,8 +353,9 @@ public async Task AddingControlCharactersToHeadersThrows(string key, string valu
}
}
- private async Task SendRequestAsync(string uri, bool usehttp11 = true, bool sendKeepAlive = false)
+ private async Task SendRequestAsync(string uri, bool usehttp11 = true, bool sendKeepAlive = false, HttpClient httpClient = null)
{
+ httpClient ??= _client;
var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (!usehttp11)
{
@@ -300,7 +365,7 @@ private async Task SendRequestAsync(string uri, bool usehtt
{
request.Headers.Add("Connection", "Keep-Alive");
}
- return await _client.SendAsync(request);
+ return await httpClient.SendAsync(request);
}
private async Task SendHeadRequestAsync(string uri, bool usehttp11 = true)
@@ -312,4 +377,19 @@ private async Task SendHeadRequestAsync(string uri, bool us
}
return await _client.SendAsync(request);
}
+
+ private static async ValueTask ConnectCallback(SocketsHttpConnectionContext connectContext, CancellationToken ct)
+ {
+ var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ try
+ {
+ await s.ConnectAsync(connectContext.DnsEndPoint, ct);
+ return new NetworkStream(s, ownsSocket: true);
+ }
+ catch
+ {
+ s.Dispose();
+ throw;
+ }
+ }
}
diff --git a/src/Servers/HttpSys/test/FunctionalTests/Listener/Utilities.cs b/src/Servers/HttpSys/test/FunctionalTests/Listener/Utilities.cs
index b7ca7e33df85..d8131af17223 100644
--- a/src/Servers/HttpSys/test/FunctionalTests/Listener/Utilities.cs
+++ b/src/Servers/HttpSys/test/FunctionalTests/Listener/Utilities.cs
@@ -22,10 +22,10 @@ internal static class Utilities
internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
- internal static HttpSysListener CreateHttpServer(out string baseAddress)
+ internal static HttpSysListener CreateHttpServer(out string baseAddress, bool respectHttp10KeepAlive = false)
{
string root;
- return CreateDynamicHttpServer(string.Empty, out root, out baseAddress);
+ return CreateDynamicHttpServer(string.Empty, out root, out baseAddress, respectHttp10KeepAlive);
}
internal static HttpSysListener CreateHttpServerReturnRoot(string path, out string root)
@@ -34,7 +34,7 @@ internal static HttpSysListener CreateHttpServerReturnRoot(string path, out stri
return CreateDynamicHttpServer(path, out root, out baseAddress);
}
- internal static HttpSysListener CreateDynamicHttpServer(string basePath, out string root, out string baseAddress)
+ internal static HttpSysListener CreateDynamicHttpServer(string basePath, out string root, out string baseAddress, bool respectHttp10KeepAlive = false)
{
lock (PortLock)
{
@@ -47,6 +47,7 @@ internal static HttpSysListener CreateDynamicHttpServer(string basePath, out str
var options = new HttpSysOptions();
options.UrlPrefixes.Add(prefix);
options.RequestQueueName = prefix.Port; // Convention for use with CreateServerOnExistingQueue
+ options.RespectHttp10KeepAlive = respectHttp10KeepAlive;
var listener = new HttpSysListener(options, new LoggerFactory());
try
{
diff --git a/src/Shared/HttpSys/Constants.cs b/src/Shared/HttpSys/Constants.cs
index 2d5695ef4155..c3c85e26e1f9 100644
--- a/src/Shared/HttpSys/Constants.cs
+++ b/src/Shared/HttpSys/Constants.cs
@@ -11,6 +11,7 @@ internal static class Constants
internal const string HttpsScheme = "https";
internal const string Chunked = "chunked";
internal const string Close = "close";
+ internal const string KeepAlive = "keep-alive";
internal const string Zero = "0";
internal const string SchemeDelimiter = "://";
internal const string DefaultServerAddress = "http://localhost:5000";