Skip to content

Commit ab8d30d

Browse files
[6.0] Optionally respect HTTP/1.0 keep-Alive for HTTP.sys. (#57203)
1 parent f541a0e commit ab8d30d

File tree

5 files changed

+111
-8
lines changed

5 files changed

+111
-8
lines changed

src/Servers/HttpSys/src/HttpSysOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ public class HttpSysOptions
3030
private long? _maxRequestBodySize = DefaultMaxRequestBodySize;
3131
private string? _requestQueueName;
3232

33+
private const string RespectHttp10KeepAliveSwitch = "Microsoft.AspNetCore.Server.HttpSys.RespectHttp10KeepAlive";
34+
35+
// Internal for testing
36+
internal bool RespectHttp10KeepAlive = AppContext.TryGetSwitch(RespectHttp10KeepAliveSwitch, out var enabled) && enabled;
37+
3338
/// <summary>
3439
/// Initializes a new <see cref="HttpSysOptions"/>.
3540
/// </summary>

src/Servers/HttpSys/src/RequestProcessing/Response.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ internal sealed class Response
3535
private BoundaryType _boundaryType;
3636
private HttpApiTypes.HTTP_RESPONSE_V2 _nativeResponse;
3737
private HeaderCollection? _trailers;
38+
private readonly bool _respectHttp10KeepAlive;
3839

3940
internal Response(RequestContext requestContext)
4041
{
@@ -56,6 +57,7 @@ internal Response(RequestContext requestContext)
5657
_nativeStream = null;
5758
_cacheTtl = null;
5859
_authChallenges = RequestContext.Server.Options.Authentication.Schemes;
60+
_respectHttp10KeepAlive = RequestContext.Server.Options.RespectHttp10KeepAlive;
5961
}
6062

6163
private enum ResponseState
@@ -403,6 +405,7 @@ internal HttpApiTypes.HTTP_FLAGS ComputeHeaders(long writeCount, bool endOfReque
403405
var requestConnectionString = Request.Headers[HeaderNames.Connection];
404406
var isHeadRequest = Request.IsHeadMethod;
405407
var requestCloseSet = Matches(Constants.Close, requestConnectionString);
408+
var requestConnectionKeepAliveSet = Matches(Constants.KeepAlive, requestConnectionString);
406409

407410
// Gather everything the app may have set on the response:
408411
// Http.Sys does not allow us to specify the response protocol version, assume this is a HTTP/1.1 response when making decisions.
@@ -415,12 +418,25 @@ internal HttpApiTypes.HTTP_FLAGS ComputeHeaders(long writeCount, bool endOfReque
415418

416419
// Determine if the connection will be kept alive or closed.
417420
var keepConnectionAlive = true;
418-
if (requestVersion <= Constants.V1_0 // Http.Sys does not support "Keep-Alive: true" or "Connection: Keep-Alive"
421+
422+
if (requestVersion < Constants.V1_0
419423
|| (requestVersion == Constants.V1_1 && requestCloseSet)
420424
|| responseCloseSet)
421425
{
422426
keepConnectionAlive = false;
423427
}
428+
else if (requestVersion == Constants.V1_0)
429+
{
430+
// In .NET 9, we updated the behavior for 1.0 clients here to match
431+
// RFC 2068. The new behavior is available down-level behind an
432+
// AppContext switch.
433+
434+
// An HTTP/1.1 server may also establish persistent connections with
435+
// HTTP/1.0 clients upon receipt of a Keep-Alive connection token.
436+
// However, a persistent connection with an HTTP/1.0 client cannot make
437+
// use of the chunked transfer-coding. From: https://www.rfc-editor.org/rfc/rfc2068#section-19.7.1
438+
keepConnectionAlive = _respectHttp10KeepAlive && requestConnectionKeepAliveSet && !responseChunkedSet;
439+
}
424440

425441
// Determine the body format. If the user asks to do something, let them, otherwise choose a good default for the scenario.
426442
if (responseContentLength.HasValue)

src/Servers/HttpSys/test/FunctionalTests/Listener/ResponseHeaderTests.cs

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Linq;
77
using System.Net;
88
using System.Net.Http;
9+
using System.Net.Sockets;
910
using System.Text;
1011
using System.Threading.Tasks;
1112
using Microsoft.AspNetCore.Testing;
@@ -207,20 +208,83 @@ public async Task ResponseHeaders_11HeadRequestStatusCodeWithoutBody_NoContentLe
207208
}
208209

209210
[ConditionalFact]
210-
public async Task ResponseHeaders_HTTP10KeepAliveRequest_Gets11Close()
211+
public async Task ResponseHeaders_HTTP10KeepAliveRequest_KeepAliveHeader_RespectsSwitch()
212+
{
213+
string address;
214+
using (var server = Utilities.CreateHttpServer(out address, respectHttp10KeepAlive: true))
215+
{
216+
// Track the number of times ConnectCallback is invoked to ensure the underlying socket wasn't closed.
217+
int connectCallbackInvocations = 0;
218+
var handler = new SocketsHttpHandler();
219+
handler.ConnectCallback = (context, cancellationToken) =>
220+
{
221+
Interlocked.Increment(ref connectCallbackInvocations);
222+
return ConnectCallback(context, cancellationToken);
223+
};
224+
225+
using (var client = new HttpClient(handler))
226+
{
227+
// Send the first request
228+
Task<HttpResponseMessage> responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true, httpClient: client);
229+
var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
230+
context.Dispose();
231+
232+
HttpResponseMessage response = await responseTask;
233+
response.EnsureSuccessStatusCode();
234+
Assert.Equal(new Version(1, 1), response.Version);
235+
Assert.Null(response.Headers.ConnectionClose);
236+
237+
// Send the second request
238+
responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true, httpClient: client);
239+
context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
240+
context.Dispose();
241+
242+
response = await responseTask;
243+
response.EnsureSuccessStatusCode();
244+
Assert.Equal(new Version(1, 1), response.Version);
245+
Assert.Null(response.Headers.ConnectionClose);
246+
}
247+
248+
// Verify that ConnectCallback was only called once
249+
Assert.Equal(1, connectCallbackInvocations);
250+
}
251+
252+
using (var server = Utilities.CreateHttpServer(out address, respectHttp10KeepAlive: false))
253+
{
254+
var handler = new SocketsHttpHandler();
255+
using (var client = new HttpClient(handler))
256+
{
257+
// Send the first request
258+
Task<HttpResponseMessage> responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true, httpClient: client);
259+
var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
260+
context.Dispose();
261+
262+
HttpResponseMessage response = await responseTask;
263+
response.EnsureSuccessStatusCode();
264+
Assert.Equal(new Version(1, 1), response.Version);
265+
Assert.True(response.Headers.ConnectionClose.Value);
266+
}
267+
}
268+
}
269+
270+
[ConditionalFact]
271+
public async Task ResponseHeaders_HTTP10KeepAliveRequest_ChunkedTransferEncoding_Gets11Close()
211272
{
212273
string address;
213274
using (var server = Utilities.CreateHttpServer(out address))
214275
{
215-
// Http.Sys does not support 1.0 keep-alives.
216276
Task<HttpResponseMessage> responseTask = SendRequestAsync(address, usehttp11: false, sendKeepAlive: true);
217277

218278
var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
279+
context.Response.Headers["Transfer-Encoding"] = new string[] { "chunked" };
280+
var responseBytes = Encoding.ASCII.GetBytes("10\r\nManually Chunked\r\n0\r\n\r\n");
281+
await context.Response.Body.WriteAsync(responseBytes, 0, responseBytes.Length);
219282
context.Dispose();
220283

221284
HttpResponseMessage response = await responseTask;
222285
response.EnsureSuccessStatusCode();
223286
Assert.Equal(new Version(1, 1), response.Version);
287+
Assert.True(response.Headers.TransferEncodingChunked.HasValue, "Chunked");
224288
Assert.True(response.Headers.ConnectionClose.Value);
225289
}
226290
}
@@ -284,8 +348,9 @@ public async Task AddingControlCharactersToHeadersThrows(string key, string valu
284348
}
285349
}
286350

287-
private async Task<HttpResponseMessage> SendRequestAsync(string uri, bool usehttp11 = true, bool sendKeepAlive = false)
351+
private async Task<HttpResponseMessage> SendRequestAsync(string uri, bool usehttp11 = true, bool sendKeepAlive = false, HttpClient httpClient = null)
288352
{
353+
httpClient ??= _client;
289354
var request = new HttpRequestMessage(HttpMethod.Get, uri);
290355
if (!usehttp11)
291356
{
@@ -295,7 +360,7 @@ private async Task<HttpResponseMessage> SendRequestAsync(string uri, bool usehtt
295360
{
296361
request.Headers.Add("Connection", "Keep-Alive");
297362
}
298-
return await _client.SendAsync(request);
363+
return await httpClient.SendAsync(request);
299364
}
300365

301366
private async Task<HttpResponseMessage> SendHeadRequestAsync(string uri, bool usehttp11 = true)
@@ -307,5 +372,20 @@ private async Task<HttpResponseMessage> SendHeadRequestAsync(string uri, bool us
307372
}
308373
return await _client.SendAsync(request);
309374
}
375+
376+
private static async ValueTask<Stream> ConnectCallback(SocketsHttpConnectionContext connectContext, CancellationToken ct)
377+
{
378+
var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
379+
try
380+
{
381+
await s.ConnectAsync(connectContext.DnsEndPoint, ct);
382+
return new NetworkStream(s, ownsSocket: true);
383+
}
384+
catch
385+
{
386+
s.Dispose();
387+
throw;
388+
}
389+
}
310390
}
311391
}

src/Servers/HttpSys/test/FunctionalTests/Listener/Utilities.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ internal static HttpSysListener CreateHttpAuthServer(AuthenticationSchemes authT
3030
return listener;
3131
}
3232

33-
internal static HttpSysListener CreateHttpServer(out string baseAddress)
33+
internal static HttpSysListener CreateHttpServer(out string baseAddress, bool respectHttp10KeepAlive = false)
3434
{
3535
string root;
36-
return CreateDynamicHttpServer(string.Empty, out root, out baseAddress);
36+
return CreateDynamicHttpServer(string.Empty, out root, out baseAddress, respectHttp10KeepAlive);
3737
}
3838

3939
internal static HttpSysListener CreateHttpServerReturnRoot(string path, out string root)
@@ -42,7 +42,7 @@ internal static HttpSysListener CreateHttpServerReturnRoot(string path, out stri
4242
return CreateDynamicHttpServer(path, out root, out baseAddress);
4343
}
4444

45-
internal static HttpSysListener CreateDynamicHttpServer(string basePath, out string root, out string baseAddress)
45+
internal static HttpSysListener CreateDynamicHttpServer(string basePath, out string root, out string baseAddress, bool respectHttp10KeepAlive = false)
4646
{
4747
lock (PortLock)
4848
{
@@ -55,6 +55,7 @@ internal static HttpSysListener CreateDynamicHttpServer(string basePath, out str
5555
var options = new HttpSysOptions();
5656
options.UrlPrefixes.Add(prefix);
5757
options.RequestQueueName = prefix.Port; // Convention for use with CreateServerOnExistingQueue
58+
options.RespectHttp10KeepAlive = respectHttp10KeepAlive;
5859
var listener = new HttpSysListener(options, new LoggerFactory());
5960
try
6061
{

src/Shared/HttpSys/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal static class Constants
1111
internal const string HttpsScheme = "https";
1212
internal const string Chunked = "chunked";
1313
internal const string Close = "close";
14+
internal const string KeepAlive = "keep-alive";
1415
internal const string Zero = "0";
1516
internal const string SchemeDelimiter = "://";
1617
internal const string DefaultServerAddress = "http://localhost:5000";

0 commit comments

Comments
 (0)