Skip to content

Commit f85f009

Browse files
[8.0] Optionally respect HTTP/1.0 keep-Alive for HTTP.sys (#57182)
* Optionally respect HTTP/1.0 Keep-Alive for HTTP.sys. * Update test to account for switch. * Simplify. * Remove extraneous parens. * Move setting to HttpSysOptions for improved testability. * Add comment about internal field
1 parent 16374e2 commit f85f009

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
@@ -29,6 +29,11 @@ public class HttpSysOptions
2929
private long? _maxRequestBodySize = DefaultMaxRequestBodySize;
3030
private string? _requestQueueName;
3131

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

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal sealed class Response
3030
private BoundaryType _boundaryType;
3131
private HttpApiTypes.HTTP_RESPONSE_V2 _nativeResponse;
3232
private HeaderCollection? _trailers;
33+
private readonly bool _respectHttp10KeepAlive;
3334

3435
internal Response(RequestContext requestContext)
3536
{
@@ -51,6 +52,7 @@ internal Response(RequestContext requestContext)
5152
_nativeStream = null;
5253
_cacheTtl = null;
5354
_authChallenges = RequestContext.Server.Options.Authentication.Schemes;
55+
_respectHttp10KeepAlive = RequestContext.Server.Options.RespectHttp10KeepAlive;
5456
}
5557

5658
private enum ResponseState
@@ -390,6 +392,7 @@ internal HttpApiTypes.HTTP_FLAGS ComputeHeaders(long writeCount, bool endOfReque
390392
var requestConnectionString = Request.Headers[HeaderNames.Connection];
391393
var isHeadRequest = Request.IsHeadMethod;
392394
var requestCloseSet = Matches(Constants.Close, requestConnectionString);
395+
var requestConnectionKeepAliveSet = Matches(Constants.KeepAlive, requestConnectionString);
393396

394397
// Gather everything the app may have set on the response:
395398
// 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
402405

403406
// Determine if the connection will be kept alive or closed.
404407
var keepConnectionAlive = true;
405-
if (requestVersion <= Constants.V1_0 // Http.Sys does not support "Keep-Alive: true" or "Connection: Keep-Alive"
408+
409+
if (requestVersion < Constants.V1_0
406410
|| (requestVersion == Constants.V1_1 && requestCloseSet)
407411
|| responseCloseSet)
408412
{
409413
keepConnectionAlive = false;
410414
}
415+
else if (requestVersion == Constants.V1_0)
416+
{
417+
// In .NET 9, we updated the behavior for 1.0 clients here to match
418+
// RFC 2068. The new behavior is available down-level behind an
419+
// AppContext switch.
420+
421+
// An HTTP/1.1 server may also establish persistent connections with
422+
// HTTP/1.0 clients upon receipt of a Keep-Alive connection token.
423+
// However, a persistent connection with an HTTP/1.0 client cannot make
424+
// use of the chunked transfer-coding. From: https://www.rfc-editor.org/rfc/rfc2068#section-19.7.1
425+
keepConnectionAlive = _respectHttp10KeepAlive && requestConnectionKeepAliveSet && !responseChunkedSet;
426+
}
411427

412428
// Determine the body format. If the user asks to do something, let them, otherwise choose a good default for the scenario.
413429
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
}
@@ -289,8 +353,9 @@ public async Task AddingControlCharactersToHeadersThrows(string key, string valu
289353
}
290354
}
291355

292-
private async Task<HttpResponseMessage> SendRequestAsync(string uri, bool usehttp11 = true, bool sendKeepAlive = false)
356+
private async Task<HttpResponseMessage> SendRequestAsync(string uri, bool usehttp11 = true, bool sendKeepAlive = false, HttpClient httpClient = null)
293357
{
358+
httpClient ??= _client;
294359
var request = new HttpRequestMessage(HttpMethod.Get, uri);
295360
if (!usehttp11)
296361
{
@@ -300,7 +365,7 @@ private async Task<HttpResponseMessage> SendRequestAsync(string uri, bool usehtt
300365
{
301366
request.Headers.Add("Connection", "Keep-Alive");
302367
}
303-
return await _client.SendAsync(request);
368+
return await httpClient.SendAsync(request);
304369
}
305370

306371
private async Task<HttpResponseMessage> SendHeadRequestAsync(string uri, bool usehttp11 = true)
@@ -312,4 +377,19 @@ private async Task<HttpResponseMessage> SendHeadRequestAsync(string uri, bool us
312377
}
313378
return await _client.SendAsync(request);
314379
}
380+
381+
private static async ValueTask<Stream> ConnectCallback(SocketsHttpConnectionContext connectContext, CancellationToken ct)
382+
{
383+
var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
384+
try
385+
{
386+
await s.ConnectAsync(connectContext.DnsEndPoint, ct);
387+
return new NetworkStream(s, ownsSocket: true);
388+
}
389+
catch
390+
{
391+
s.Dispose();
392+
throw;
393+
}
394+
}
315395
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ internal static class Utilities
2222

2323
internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
2424

25-
internal static HttpSysListener CreateHttpServer(out string baseAddress)
25+
internal static HttpSysListener CreateHttpServer(out string baseAddress, bool respectHttp10KeepAlive = false)
2626
{
2727
string root;
28-
return CreateDynamicHttpServer(string.Empty, out root, out baseAddress);
28+
return CreateDynamicHttpServer(string.Empty, out root, out baseAddress, respectHttp10KeepAlive);
2929
}
3030

3131
internal static HttpSysListener CreateHttpServerReturnRoot(string path, out string root)
@@ -34,7 +34,7 @@ internal static HttpSysListener CreateHttpServerReturnRoot(string path, out stri
3434
return CreateDynamicHttpServer(path, out root, out baseAddress);
3535
}
3636

37-
internal static HttpSysListener CreateDynamicHttpServer(string basePath, out string root, out string baseAddress)
37+
internal static HttpSysListener CreateDynamicHttpServer(string basePath, out string root, out string baseAddress, bool respectHttp10KeepAlive = false)
3838
{
3939
lock (PortLock)
4040
{
@@ -47,6 +47,7 @@ internal static HttpSysListener CreateDynamicHttpServer(string basePath, out str
4747
var options = new HttpSysOptions();
4848
options.UrlPrefixes.Add(prefix);
4949
options.RequestQueueName = prefix.Port; // Convention for use with CreateServerOnExistingQueue
50+
options.RespectHttp10KeepAlive = respectHttp10KeepAlive;
5051
var listener = new HttpSysListener(options, new LoggerFactory());
5152
try
5253
{

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)