Skip to content

Commit e3b971a

Browse files
authored
Keep Kestrel's connection PipeReader in a consistent state (#16725)
- When the request body PipeReader.ReadAsync throws, the connection-level pipe should be advanced, so subsequent attempts to read from the connection-level pipe don't fail unnecessarily
1 parent da20a12 commit e3b971a

File tree

6 files changed

+169
-82
lines changed

6 files changed

+169
-82
lines changed

src/Servers/Kestrel/Core/src/Internal/Http/Http1ChunkedEncodingMessageBody.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ private async Task PumpAsync()
163163
}
164164

165165
// Read() will have already have greedily consumed the entire request body if able.
166-
CheckCompletedReadResult(result);
166+
if (result.IsCompleted)
167+
{
168+
ThrowUnexpectedEndOfRequestContent();
169+
}
167170
}
168171
finally
169172
{

src/Servers/Kestrel/Core/src/Internal/Http/Http1ContentLengthMessageBody.cs

Lines changed: 67 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public override async ValueTask<ReadResult> ReadAsyncInternal(CancellationToken
4343
if (_readCompleted)
4444
{
4545
_isReading = true;
46-
return _readResult;
46+
return new ReadResult(_readResult.Buffer, Interlocked.Exchange(ref _userCanceled, 0) == 1, _readResult.IsCompleted);
4747
}
4848

4949
TryStart();
@@ -70,44 +70,47 @@ public override async ValueTask<ReadResult> ReadAsyncInternal(CancellationToken
7070
}
7171
catch (ConnectionAbortedException ex)
7272
{
73+
_isReading = false;
7374
throw new TaskCanceledException("The request was aborted", ex);
7475
}
7576

77+
void ResetReadingState()
78+
{
79+
_isReading = false;
80+
// Reset the timing read here for the next call to read.
81+
StopTimingRead(0);
82+
_context.Input.AdvanceTo(_readResult.Buffer.Start);
83+
}
84+
7685
if (_context.RequestTimedOut)
7786
{
87+
ResetReadingState();
7888
BadHttpRequestException.Throw(RequestRejectionReason.RequestBodyTimeout);
7989
}
8090

81-
// Make sure to handle when this is canceled here.
82-
if (_readResult.IsCanceled)
91+
if (_readResult.IsCompleted)
8392
{
84-
if (Interlocked.Exchange(ref _userCanceled, 0) == 1)
85-
{
86-
// Ignore the readResult if it wasn't by the user.
87-
CreateReadResultFromConnectionReadResult();
88-
89-
break;
90-
}
91-
else
92-
{
93-
// Reset the timing read here for the next call to read.
94-
StopTimingRead(0);
95-
continue;
96-
}
93+
ResetReadingState();
94+
ThrowUnexpectedEndOfRequestContent();
9795
}
9896

99-
var readableBuffer = _readResult.Buffer;
100-
var readableBufferLength = readableBuffer.Length;
101-
StopTimingRead(readableBufferLength);
97+
// Ignore the canceled readResult if it wasn't canceled by the user.
98+
if (!_readResult.IsCanceled || Interlocked.Exchange(ref _userCanceled, 0) == 1)
99+
{
100+
var returnedReadResultLength = CreateReadResultFromConnectionReadResult();
102101

103-
CheckCompletedReadResult(_readResult);
102+
// Don't count bytes belonging to the next request, since read rate timeouts are done on a per-request basis.
103+
StopTimingRead(returnedReadResultLength);
104104

105-
if (readableBufferLength > 0)
106-
{
107-
CreateReadResultFromConnectionReadResult();
105+
if (_readResult.IsCompleted)
106+
{
107+
TryStop();
108+
}
108109

109110
break;
110111
}
112+
113+
ResetReadingState();
111114
}
112115

113116
return _readResult;
@@ -129,66 +132,69 @@ public override bool TryReadInternal(out ReadResult readResult)
129132
if (_readCompleted)
130133
{
131134
_isReading = true;
132-
readResult = _readResult;
135+
readResult = new ReadResult(_readResult.Buffer, Interlocked.Exchange(ref _userCanceled, 0) == 1, _readResult.IsCompleted);
133136
return true;
134137
}
135138

136139
TryStart();
137140

138-
if (!_context.Input.TryRead(out _readResult))
139-
{
140-
readResult = default;
141-
return false;
142-
}
143-
144-
if (_readResult.IsCanceled)
141+
// The while(true) because we don't want to return a canceled ReadResult if the user themselves didn't cancel it.
142+
while (true)
145143
{
146-
if (Interlocked.Exchange(ref _userCanceled, 0) == 0)
144+
if (!_context.Input.TryRead(out _readResult))
147145
{
148-
// Cancellation wasn't by the user, return default ReadResult
149146
readResult = default;
150147
return false;
151148
}
152-
}
153149

154-
// Only set _isReading if we are returing true.
155-
_isReading = true;
150+
if (!_readResult.IsCanceled || Interlocked.Exchange(ref _userCanceled, 0) == 1)
151+
{
152+
break;
153+
}
156154

157-
CreateReadResultFromConnectionReadResult();
155+
_context.Input.AdvanceTo(_readResult.Buffer.Start);
156+
}
158157

159-
readResult = _readResult;
160-
CountBytesRead(readResult.Buffer.Length);
158+
if (_readResult.IsCompleted)
159+
{
160+
_context.Input.AdvanceTo(_readResult.Buffer.Start);
161+
ThrowUnexpectedEndOfRequestContent();
162+
}
161163

162-
return true;
163-
}
164+
var returnedReadResultLength = CreateReadResultFromConnectionReadResult();
164165

165-
public override Task ConsumeAsync()
166-
{
167-
TryStart();
166+
// Don't count bytes belonging to the next request, since read rate timeouts are done on a per-request basis.
167+
CountBytesRead(returnedReadResultLength);
168168

169-
if (!_readResult.Buffer.IsEmpty && _inputLength == 0)
169+
// Only set _isReading if we are returning true.
170+
_isReading = true;
171+
readResult = _readResult;
172+
173+
if (readResult.IsCompleted)
170174
{
171-
_context.Input.AdvanceTo(_readResult.Buffer.End);
175+
TryStop();
172176
}
173177

174-
return OnConsumeAsync();
178+
return true;
175179
}
176180

177-
private void CreateReadResultFromConnectionReadResult()
181+
private long CreateReadResultFromConnectionReadResult()
178182
{
179-
if (_readResult.Buffer.Length >= _inputLength + _examinedUnconsumedBytes)
180-
{
181-
_readCompleted = true;
182-
_readResult = new ReadResult(
183-
_readResult.Buffer.Slice(0, _inputLength + _examinedUnconsumedBytes),
184-
_readResult.IsCanceled && Interlocked.Exchange(ref _userCanceled, 0) == 1,
185-
_readCompleted);
186-
}
183+
var initialLength = _readResult.Buffer.Length;
184+
var maxLength = _inputLength + _examinedUnconsumedBytes;
187185

188-
if (_readResult.IsCompleted)
186+
if (initialLength < maxLength)
189187
{
190-
TryStop();
188+
return initialLength;
191189
}
190+
191+
_readCompleted = true;
192+
_readResult = new ReadResult(
193+
_readResult.Buffer.Slice(0, maxLength),
194+
_readResult.IsCanceled,
195+
isCompleted: true);
196+
197+
return maxLength;
192198
}
193199

194200
public override void AdvanceTo(SequencePosition consumed)
@@ -207,9 +213,10 @@ public override void AdvanceTo(SequencePosition consumed, SequencePosition exami
207213

208214
if (_readCompleted)
209215
{
210-
_readResult = new ReadResult(_readResult.Buffer.Slice(consumed, _readResult.Buffer.End), Interlocked.Exchange(ref _userCanceled, 0) == 1, _readCompleted);
216+
// If the old stored _readResult was canceled, it's already been observed. Do not store a canceled read result permanently.
217+
_readResult = new ReadResult(_readResult.Buffer.Slice(consumed, _readResult.Buffer.End), isCanceled: false, _readCompleted);
211218

212-
if (_readResult.Buffer.Length == 0 && !_finalAdvanceCalled)
219+
if (!_finalAdvanceCalled && _readResult.Buffer.Length == 0)
213220
{
214221
_context.Input.AdvanceTo(consumed);
215222
_finalAdvanceCalled = true;

src/Servers/Kestrel/Core/src/Internal/Http/Http1MessageBody.cs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Diagnostics;
56
using System.IO.Pipelines;
67
using System.Threading;
78
using System.Threading.Tasks;
@@ -21,19 +22,17 @@ protected Http1MessageBody(Http1Connection context)
2122
_context = context;
2223
}
2324

24-
protected void CheckCompletedReadResult(ReadResult result)
25+
[StackTraceHidden]
26+
protected void ThrowUnexpectedEndOfRequestContent()
2527
{
26-
if (result.IsCompleted)
27-
{
28-
// OnInputOrOutputCompleted() is an idempotent method that closes the connection. Sometimes
29-
// input completion is observed here before the Input.OnWriterCompleted() callback is fired,
30-
// so we call OnInputOrOutputCompleted() now to prevent a race in our tests where a 400
31-
// response is written after observing the unexpected end of request content instead of just
32-
// closing the connection without a response as expected.
33-
_context.OnInputOrOutputCompleted();
34-
35-
BadHttpRequestException.Throw(RequestRejectionReason.UnexpectedEndOfRequestContent);
36-
}
28+
// OnInputOrOutputCompleted() is an idempotent method that closes the connection. Sometimes
29+
// input completion is observed here before the Input.OnWriterCompleted() callback is fired,
30+
// so we call OnInputOrOutputCompleted() now to prevent a race in our tests where a 400
31+
// response is written after observing the unexpected end of request content instead of just
32+
// closing the connection without a response as expected.
33+
_context.OnInputOrOutputCompleted();
34+
35+
BadHttpRequestException.Throw(RequestRejectionReason.UnexpectedEndOfRequestContent);
3736
}
3837

3938
public abstract bool TryReadInternal(out ReadResult readResult);

src/Servers/Kestrel/Core/src/Internal/Http/Http1UpgradeMessageBody.cs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,13 @@ public Http1UpgradeMessageBody(Http1Connection context)
2525

2626
public override ValueTask<ReadResult> ReadAsync(CancellationToken cancellationToken = default)
2727
{
28-
if (_completed)
29-
{
30-
throw new InvalidOperationException("Reading is not allowed after the reader was completed.");
31-
}
28+
ThrowIfCompleted();
3229
return _context.Input.ReadAsync(cancellationToken);
3330
}
3431

3532
public override bool TryRead(out ReadResult result)
3633
{
37-
if (_completed)
38-
{
39-
throw new InvalidOperationException("Reading is not allowed after the reader was completed.");
40-
}
34+
ThrowIfCompleted();
4135
return _context.Input.TryRead(out result);
4236
}
4337

src/Servers/Kestrel/Core/test/MessageBodyTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,56 @@ public async Task CompleteForContentLengthDoesNotCompleteConnectionPipeMakesRead
11891189
}
11901190
}
11911191

1192+
[Fact]
1193+
public async Task UnexpectedEndOfRequestContentIsRepeatedlyThrownForContentLengthBody()
1194+
{
1195+
using (var input = new TestInput())
1196+
{
1197+
var body = Http1MessageBody.For(HttpVersion.Http11, new HttpRequestHeaders { HeaderContentLength = "5" }, input.Http1Connection);
1198+
var reader = new HttpRequestPipeReader();
1199+
reader.StartAcceptingReads(body);
1200+
1201+
input.Application.Output.Complete();
1202+
1203+
var ex0 = Assert.Throws<BadHttpRequestException>(() => reader.TryRead(out var readResult));
1204+
var ex1 = Assert.Throws<BadHttpRequestException>(() => reader.TryRead(out var readResult));
1205+
var ex2 = await Assert.ThrowsAsync<BadHttpRequestException>(() => reader.ReadAsync().AsTask());
1206+
var ex3 = await Assert.ThrowsAsync<BadHttpRequestException>(() => reader.ReadAsync().AsTask());
1207+
1208+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex0.Reason);
1209+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex1.Reason);
1210+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex2.Reason);
1211+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex3.Reason);
1212+
1213+
await body.StopAsync();
1214+
}
1215+
}
1216+
1217+
[Fact]
1218+
public async Task UnexpectedEndOfRequestContentIsRepeatedlyThrownForChunkedBody()
1219+
{
1220+
using (var input = new TestInput())
1221+
{
1222+
var body = Http1MessageBody.For(HttpVersion.Http11, new HttpRequestHeaders { HeaderTransferEncoding = "chunked" }, input.Http1Connection);
1223+
var reader = new HttpRequestPipeReader();
1224+
reader.StartAcceptingReads(body);
1225+
1226+
input.Application.Output.Complete();
1227+
1228+
var ex0 = Assert.Throws<BadHttpRequestException>(() => reader.TryRead(out var readResult));
1229+
var ex1 = Assert.Throws<BadHttpRequestException>(() => reader.TryRead(out var readResult));
1230+
var ex2 = await Assert.ThrowsAsync<BadHttpRequestException>(() => reader.ReadAsync().AsTask());
1231+
var ex3 = await Assert.ThrowsAsync<BadHttpRequestException>(() => reader.ReadAsync().AsTask());
1232+
1233+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex0.Reason);
1234+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex1.Reason);
1235+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex2.Reason);
1236+
Assert.Equal(RequestRejectionReason.UnexpectedEndOfRequestContent, ex3.Reason);
1237+
1238+
await body.StopAsync();
1239+
}
1240+
}
1241+
11921242
[Fact]
11931243
public async Task CompleteForChunkedAllowsConsumeToWork()
11941244
{

src/Servers/Kestrel/test/InMemory.FunctionalTests/RequestTests.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919
using Microsoft.AspNetCore.Testing;
2020
using Microsoft.Extensions.Logging;
2121
using Microsoft.Extensions.Logging.Testing;
22+
using Serilog;
2223
using Xunit;
2324

2425
namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests
2526
{
26-
public class RequestTests : LoggedTest
27+
public class RequestTests : TestApplicationErrorLoggerLoggedTest
2728
{
2829
[Fact]
2930
public async Task StreamsAreNotPersistedAcrossRequests()
@@ -1440,6 +1441,39 @@ await connection.Receive(
14401441
}
14411442
}
14421443

1444+
[Fact]
1445+
public async Task ContentLengthSwallowedUnexpectedEndOfRequestContentDoesNotResultInWarnings()
1446+
{
1447+
var testContext = new TestServiceContext(LoggerFactory);
1448+
1449+
await using (var server = new TestServer(async httpContext =>
1450+
{
1451+
try
1452+
{
1453+
await httpContext.Request.Body.ReadAsync(new byte[1], 0, 1);
1454+
}
1455+
catch
1456+
{
1457+
}
1458+
}, testContext))
1459+
{
1460+
using (var connection = server.CreateConnection())
1461+
{
1462+
await connection.Send(
1463+
"POST / HTTP/1.1",
1464+
"Host:",
1465+
"Content-Length: 5",
1466+
"",
1467+
"");
1468+
connection.ShutdownSend();
1469+
1470+
await connection.ReceiveEnd();
1471+
}
1472+
}
1473+
1474+
Assert.Empty(TestApplicationErrorLogger.Messages.Where(m => m.LogLevel >= LogLevel.Warning));
1475+
}
1476+
14431477
[Fact]
14441478
public async Task ContentLengthRequestCallCancelPendingReadWorks()
14451479
{

0 commit comments

Comments
 (0)