Skip to content

Commit 3b706dd

Browse files
authored
Respect the Keep-Alive response header on HTTP/1.0 (#73020)
* Respect the Keep-Alive response header on HTTP/1.0 * Remove TimeoutOffset * Update Trace message * Update tests * Adjust test timeouts
1 parent 8762a02 commit 3b706dd

File tree

4 files changed

+217
-3
lines changed

4 files changed

+217
-3
lines changed

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ internal sealed partial class HttpConnection : HttpConnectionBase
6767
private int _readLength;
6868

6969
private long _idleSinceTickCount;
70+
private int _keepAliveTimeoutSeconds; // 0 == no timeout
7071
private bool _inUse;
7172
private bool _detachedFromPool;
7273
private bool _canRetry;
@@ -148,9 +149,14 @@ private void Dispose(bool disposing)
148149

149150
/// <summary>Prepare an idle connection to be used for a new request.</summary>
150151
/// <param name="async">Indicates whether the coming request will be sync or async.</param>
151-
/// <returns>True if connection can be used, false if it is invalid due to receiving EOF or unexpected data.</returns>
152+
/// <returns>True if connection can be used, false if it is invalid due to a timeout or receiving EOF or unexpected data.</returns>
152153
public bool PrepareForReuse(bool async)
153154
{
155+
if (CheckKeepAliveTimeoutExceeded())
156+
{
157+
return false;
158+
}
159+
154160
// We may already have a read-ahead task if we did a previous scavenge and haven't used the connection since.
155161
// If the read-ahead task is completed, then we've received either EOF or erroneous data the connection, so it's not usable.
156162
if (_readAheadTask is not null)
@@ -196,9 +202,14 @@ public bool PrepareForReuse(bool async)
196202
}
197203

198204
/// <summary>Check whether a currently idle connection is still usable, or should be scavenged.</summary>
199-
/// <returns>True if connection can be used, false if it is invalid due to receiving EOF or unexpected data.</returns>
205+
/// <returns>True if connection can be used, false if it is invalid due to a timeout or receiving EOF or unexpected data.</returns>
200206
public override bool CheckUsabilityOnScavenge()
201207
{
208+
if (CheckKeepAliveTimeoutExceeded())
209+
{
210+
return false;
211+
}
212+
202213
// We may already have a read-ahead task if we did a previous scavenge and haven't used the connection since.
203214
#pragma warning disable CA2012 // we're very careful to ensure the ValueTask is only consumed once, even though it's stored into a field
204215
_readAheadTask ??= ReadAheadWithZeroByteReadAsync();
@@ -223,6 +234,14 @@ async ValueTask<int> ReadAheadWithZeroByteReadAsync()
223234
}
224235
}
225236

237+
private bool CheckKeepAliveTimeoutExceeded()
238+
{
239+
// We only honor a Keep-Alive timeout on HTTP/1.0 responses.
240+
// If _keepAliveTimeoutSeconds is 0, no timeout has been set.
241+
return _keepAliveTimeoutSeconds != 0 &&
242+
GetIdleTicks(Environment.TickCount64) >= _keepAliveTimeoutSeconds * 1000;
243+
}
244+
226245
private ValueTask<int>? ConsumeReadAheadTask()
227246
{
228247
if (Interlocked.CompareExchange(ref _readAheadTaskLock, 1, 0) == 0)
@@ -646,6 +665,11 @@ public async Task<HttpResponseMessage> SendAsyncCore(HttpRequestMessage request,
646665
ParseHeaderNameValue(this, line.Span, response, isFromTrailer: false);
647666
}
648667

668+
if (response.Version.Minor == 0)
669+
{
670+
ProcessHttp10KeepAliveHeader(response);
671+
}
672+
649673
if (HttpTelemetry.Log.IsEnabled()) HttpTelemetry.Log.ResponseHeadersStop();
650674

651675
if (allowExpect100ToContinue != null)
@@ -1108,6 +1132,45 @@ private static void ParseHeaderNameValue(HttpConnection connection, ReadOnlySpan
11081132
}
11091133
}
11101134

1135+
private void ProcessHttp10KeepAliveHeader(HttpResponseMessage response)
1136+
{
1137+
if (response.Headers.NonValidated.TryGetValues(KnownHeaders.KeepAlive.Name, out HeaderStringValues keepAliveValues))
1138+
{
1139+
string keepAlive = keepAliveValues.ToString();
1140+
var parsedValues = new UnvalidatedObjectCollection<NameValueHeaderValue>();
1141+
1142+
if (NameValueHeaderValue.GetNameValueListLength(keepAlive, 0, ',', parsedValues) == keepAlive.Length)
1143+
{
1144+
foreach (NameValueHeaderValue nameValue in parsedValues)
1145+
{
1146+
if (string.Equals(nameValue.Name, "timeout", StringComparison.OrdinalIgnoreCase))
1147+
{
1148+
if (!string.IsNullOrEmpty(nameValue.Value) &&
1149+
HeaderUtilities.TryParseInt32(nameValue.Value, out int timeout) &&
1150+
timeout >= 0)
1151+
{
1152+
if (timeout == 0)
1153+
{
1154+
_connectionClose = true;
1155+
}
1156+
else
1157+
{
1158+
_keepAliveTimeoutSeconds = timeout;
1159+
}
1160+
}
1161+
}
1162+
else if (string.Equals(nameValue.Name, "max", StringComparison.OrdinalIgnoreCase))
1163+
{
1164+
if (nameValue.Value == "0")
1165+
{
1166+
_connectionClose = true;
1167+
}
1168+
}
1169+
}
1170+
}
1171+
}
1172+
}
1173+
11111174
private void WriteToBuffer(ReadOnlySpan<byte> source)
11121175
{
11131176
Debug.Assert(source.Length <= _writeBuffer.Length - _writeOffset);

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2324,7 +2324,7 @@ static bool IsUsableConnection(HttpConnectionBase connection, long nowTicks, Tim
23242324

23252325
if (!connection.CheckUsabilityOnScavenge())
23262326
{
2327-
if (NetEventSource.Log.IsEnabled()) connection.Trace($"Scavenging connection. Unexpected data or EOF received.");
2327+
if (NetEventSource.Log.IsEnabled()) connection.Trace($"Scavenging connection. Keep-Alive timeout exceeded, unexpected data or EOF received.");
23282328
return false;
23292329
}
23302330

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net.Test.Common;
5+
using System.Threading.Tasks;
6+
using Xunit;
7+
using Xunit.Abstractions;
8+
9+
namespace System.Net.Http.Functional.Tests
10+
{
11+
[ConditionalClass(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))]
12+
public sealed class SocketsHttpHandler_Http1KeepAlive_Test : HttpClientHandlerTestBase
13+
{
14+
public SocketsHttpHandler_Http1KeepAlive_Test(ITestOutputHelper output) : base(output) { }
15+
16+
[Fact]
17+
public async Task Http10Response_ConnectionIsReusedFor10And11()
18+
{
19+
await LoopbackServer.CreateClientAndServerAsync(async uri =>
20+
{
21+
using HttpClient client = CreateHttpClient();
22+
23+
await client.SendAsync(CreateRequest(HttpMethod.Get, uri, HttpVersion.Version10, exactVersion: true));
24+
await client.SendAsync(CreateRequest(HttpMethod.Get, uri, HttpVersion.Version11, exactVersion: true));
25+
await client.SendAsync(CreateRequest(HttpMethod.Get, uri, HttpVersion.Version10, exactVersion: true));
26+
},
27+
server => server.AcceptConnectionAsync(async connection =>
28+
{
29+
HttpRequestData request = await connection.ReadRequestDataAsync();
30+
Assert.Equal(0, request.Version.Minor);
31+
await connection.WriteStringAsync("HTTP/1.0 200 OK\r\nContent-Length: 1\r\n\r\n1");
32+
connection.CompleteRequestProcessing();
33+
34+
request = await connection.ReadRequestDataAsync();
35+
Assert.Equal(1, request.Version.Minor);
36+
await connection.WriteStringAsync("HTTP/1.0 200 OK\r\nContent-Length: 1\r\n\r\n2");
37+
connection.CompleteRequestProcessing();
38+
39+
request = await connection.ReadRequestDataAsync();
40+
Assert.Equal(0, request.Version.Minor);
41+
await connection.WriteStringAsync("HTTP/1.0 200 OK\r\nContent-Length: 1\r\n\r\n3");
42+
}));
43+
}
44+
45+
[OuterLoop("Uses Task.Delay")]
46+
[Fact]
47+
public async Task Http10ResponseWithKeepAliveTimeout_ConnectionRecycledAfterTimeout()
48+
{
49+
await LoopbackServer.CreateClientAndServerAsync(async uri =>
50+
{
51+
using HttpClient client = CreateHttpClient();
52+
53+
await client.GetAsync(uri);
54+
55+
await Task.Delay(2000);
56+
await client.GetAsync(uri);
57+
},
58+
async server =>
59+
{
60+
await server.AcceptConnectionAsync(async connection =>
61+
{
62+
await connection.ReadRequestDataAsync();
63+
await connection.WriteStringAsync("HTTP/1.0 200 OK\r\nKeep-Alive: timeout=1\r\nContent-Length: 1\r\n\r\n1");
64+
connection.CompleteRequestProcessing();
65+
66+
await Assert.ThrowsAnyAsync<Exception>(() => connection.ReadRequestDataAsync());
67+
});
68+
69+
await server.AcceptConnectionSendResponseAndCloseAsync();
70+
});
71+
}
72+
73+
[Theory]
74+
[InlineData("timeout=1000", true)]
75+
[InlineData("timeout=30", true)]
76+
[InlineData("timeout=0", false)]
77+
[InlineData("foo, bar=baz, timeout=30", true)]
78+
[InlineData("foo, bar=baz, timeout=0", false)]
79+
[InlineData("timeout=-1", true)]
80+
[InlineData("timeout=abc", true)]
81+
[InlineData("max=1", true)]
82+
[InlineData("max=0", false)]
83+
[InlineData("max=-1", true)]
84+
[InlineData("max=abc", true)]
85+
[InlineData("timeout=30, max=1", true)]
86+
[InlineData("timeout=30, max=0", false)]
87+
[InlineData("timeout=0, max=1", false)]
88+
[InlineData("timeout=0, max=0", false)]
89+
public async Task Http10ResponseWithKeepAlive_ConnectionNotReusedForShortTimeoutOrMax0(string keepAlive, bool shouldReuseConnection)
90+
{
91+
await LoopbackServer.CreateClientAndServerAsync(async uri =>
92+
{
93+
using HttpClient client = CreateHttpClient();
94+
95+
await client.GetAsync(uri);
96+
await client.GetAsync(uri);
97+
},
98+
async server =>
99+
{
100+
await server.AcceptConnectionAsync(async connection =>
101+
{
102+
await connection.ReadRequestDataAsync();
103+
await connection.WriteStringAsync($"HTTP/1.0 200 OK\r\nKeep-Alive: {keepAlive}\r\nContent-Length: 1\r\n\r\n1");
104+
connection.CompleteRequestProcessing();
105+
106+
if (shouldReuseConnection)
107+
{
108+
await connection.HandleRequestAsync();
109+
}
110+
else
111+
{
112+
await Assert.ThrowsAnyAsync<Exception>(() => connection.ReadRequestDataAsync());
113+
}
114+
});
115+
116+
if (!shouldReuseConnection)
117+
{
118+
await server.AcceptConnectionSendResponseAndCloseAsync();
119+
}
120+
});
121+
}
122+
123+
[Theory]
124+
[InlineData("timeout=1")]
125+
[InlineData("timeout=0")]
126+
[InlineData("max=1")]
127+
[InlineData("max=0")]
128+
public async Task Http11ResponseWithKeepAlive_KeepAliveIsIgnored(string keepAlive)
129+
{
130+
await LoopbackServer.CreateClientAndServerAsync(async uri =>
131+
{
132+
using HttpClient client = CreateHttpClient();
133+
134+
await client.GetAsync(uri);
135+
await client.GetAsync(uri);
136+
},
137+
async server =>
138+
{
139+
await server.AcceptConnectionAsync(async connection =>
140+
{
141+
await connection.ReadRequestDataAsync();
142+
await connection.WriteStringAsync($"HTTP/1.1 200 OK\r\nKeep-Alive: {keepAlive}\r\nContent-Length: 1\r\n\r\n1");
143+
connection.CompleteRequestProcessing();
144+
145+
await connection.HandleRequestAsync();
146+
});
147+
});
148+
}
149+
}
150+
}

src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
<Compile Include="HttpClientHandlerTest.AltSvc.cs" />
161161
<Compile Include="SocketsHttpHandlerTest.Cancellation.cs" />
162162
<Compile Include="SocketsHttpHandlerTest.Cancellation.NonParallel.cs" />
163+
<Compile Include="SocketsHttpHandlerTest.Http1KeepAlive.cs" />
163164
<Compile Include="SocketsHttpHandlerTest.Http2FlowControl.cs" />
164165
<Compile Include="SocketsHttpHandlerTest.Http2KeepAlivePing.cs" />
165166
<Compile Include="HttpClientHandlerTest.Connect.cs" />

0 commit comments

Comments
 (0)