Skip to content

Commit c3c9732

Browse files
authored
[release/6.0-rc1] HTTP/3: Response drain timeout (#35492)
- backport of #35322 to release/6.0-rc1
1 parent fd69165 commit c3c9732

20 files changed

+302
-97
lines changed

src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -176,38 +176,62 @@ public void Tick(DateTimeOffset now)
176176
return;
177177
}
178178

179-
UpdateStartingStreams(now);
179+
UpdateStreamTimeouts(now);
180180
}
181181

182-
private void UpdateStartingStreams(DateTimeOffset now)
182+
private void UpdateStreamTimeouts(DateTimeOffset now)
183183
{
184+
// This method checks for timeouts:
185+
// 1. When a stream first starts and waits to receive headers.
186+
// Uses RequestHeadersTimeout.
187+
// 2. When a stream finished and is waiting for underlying transport to drain.
188+
// Uses MinResponseDataRate.
189+
184190
var ticks = now.Ticks;
185191

186192
lock (_streams)
187193
{
188194
foreach (var stream in _streams.Values)
189195
{
190-
if (stream.ReceivedHeader)
196+
if (stream.IsReceivingHeader)
191197
{
192-
continue;
193-
}
198+
if (stream.StreamTimeoutTicks == default)
199+
{
200+
// On expiration overflow, use max value.
201+
var expirationTicks = ticks + _context.ServiceContext.ServerOptions.Limits.RequestHeadersTimeout.Ticks;
202+
stream.StreamTimeoutTicks = expirationTicks >= 0 ? expirationTicks : long.MaxValue;
203+
}
194204

195-
if (stream.HeaderTimeoutTicks == default)
196-
{
197-
// On expiration overflow, use max value.
198-
var expirationTicks = ticks + _context.ServiceContext.ServerOptions.Limits.RequestHeadersTimeout.Ticks;
199-
stream.HeaderTimeoutTicks = expirationTicks >= 0 ? expirationTicks : long.MaxValue;
205+
if (stream.StreamTimeoutTicks < ticks)
206+
{
207+
if (stream.IsRequestStream)
208+
{
209+
stream.Abort(new ConnectionAbortedException(CoreStrings.BadRequest_RequestHeadersTimeout), Http3ErrorCode.RequestRejected);
210+
}
211+
else
212+
{
213+
stream.Abort(new ConnectionAbortedException(CoreStrings.Http3ControlStreamHeaderTimeout), Http3ErrorCode.StreamCreationError);
214+
}
215+
}
200216
}
201-
202-
if (stream.HeaderTimeoutTicks < ticks)
217+
else if (stream.IsDraining)
203218
{
204-
if (stream.IsRequestStream)
219+
var minDataRate = _context.ServiceContext.ServerOptions.Limits.MinResponseDataRate;
220+
if (minDataRate == null)
205221
{
206-
stream.Abort(new ConnectionAbortedException(CoreStrings.BadRequest_RequestHeadersTimeout), Http3ErrorCode.RequestRejected);
222+
continue;
207223
}
208-
else
224+
225+
if (stream.StreamTimeoutTicks == default)
226+
{
227+
stream.StreamTimeoutTicks = _context.TimeoutControl.GetResponseDrainDeadline(ticks, minDataRate);
228+
}
229+
230+
if (stream.StreamTimeoutTicks < ticks)
209231
{
210-
stream.Abort(new ConnectionAbortedException(CoreStrings.Http3ControlStreamHeaderTimeout), Http3ErrorCode.StreamCreationError);
232+
// Cancel connection to be consistent with other data rate limits.
233+
Log.ResponseMinimumDataRateNotSatisfied(_context.ConnectionId, stream.TraceIdentifier);
234+
Abort(new ConnectionAbortedException(CoreStrings.ConnectionTimedBecauseResponseMininumDataRateNotSatisfied), Http3ErrorCode.InternalError);
211235
}
212236
}
213237
}
@@ -396,7 +420,6 @@ public async Task ProcessRequestsAsync<TContext>(IHttpApplication<TContext> appl
396420
}
397421

398422
_context.TimeoutControl.CancelTimeout();
399-
_context.TimeoutControl.StartDrainTimeout(Limits.MinResponseDataRate, Limits.MaxResponseBufferSize);
400423
}
401424
catch
402425
{
@@ -424,7 +447,7 @@ private ConnectionAbortedException CreateConnectionAbortError(Exception? error,
424447

425448
if (clientAbort)
426449
{
427-
return new ConnectionAbortedException("The client closed the HTTP/3 connection.", error!);
450+
return new ConnectionAbortedException(CoreStrings.ConnectionAbortedByClient, error!);
428451
}
429452

430453
return new ConnectionAbortedException(CoreStrings.Http3ConnectionFaulted, error!);
@@ -648,7 +671,7 @@ void IHttp3StreamLifetimeHandler.OnInboundControlStreamSetting(Http3SettingType
648671

649672
void IHttp3StreamLifetimeHandler.OnStreamHeaderReceived(IHttp3Stream stream)
650673
{
651-
Debug.Assert(stream.ReceivedHeader);
674+
Debug.Assert(!stream.IsReceivingHeader);
652675
}
653676

654677
public void HandleRequestHeadersTimeout()

src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,11 @@ private void OnStreamClosed()
6767
public PipeReader Input => _context.Transport.Input;
6868
public IKestrelTrace Log => _context.ServiceContext.Log;
6969

70-
public long HeaderTimeoutTicks { get; set; }
71-
public bool ReceivedHeader => _headerType >= 0;
72-
70+
public long StreamTimeoutTicks { get; set; }
71+
public bool IsReceivingHeader => _headerType == -1;
72+
public bool IsDraining => false;
7373
public bool IsRequestStream => false;
74+
public string TraceIdentifier => _context.StreamContext.ConnectionId;
7475

7576
public void Abort(ConnectionAbortedException abortReason, Http3ErrorCode errorCode)
7677
{

src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
54
using System.Buffers;
65
using System.Diagnostics;
76
using System.IO.Pipelines;
87
using System.Net.Http;
98
using System.Net.Http.QPack;
10-
using System.Net.Quic;
119
using System.Runtime.CompilerServices;
12-
using System.Threading;
13-
using System.Threading.Tasks;
1410
using Microsoft.AspNetCore.Connections;
1511
using Microsoft.AspNetCore.Connections.Features;
1612
using Microsoft.AspNetCore.Hosting.Server;
17-
using Microsoft.AspNetCore.Http.Features;
1813
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
1914
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure;
2015
using Microsoft.Extensions.Logging;
@@ -73,8 +68,9 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpH
7368
public KestrelServerLimits Limits => _context.ServiceContext.ServerOptions.Limits;
7469
public long StreamId => _streamIdFeature.StreamId;
7570

76-
public long HeaderTimeoutTicks { get; set; }
77-
public bool ReceivedHeader => _appCompleted != null; // TCS is assigned once headers are received
71+
public long StreamTimeoutTicks { get; set; }
72+
public bool IsReceivingHeader => _appCompleted == null; // TCS is assigned once headers are received
73+
public bool IsDraining => _appCompleted?.Task.IsCompleted ?? false; // Draining starts once app is complete
7874

7975
public bool IsRequestStream => true;
8076

@@ -97,7 +93,7 @@ public void Initialize(Http3StreamContext context)
9793
_totalParsedHeaderSize = 0;
9894
_isMethodConnect = false;
9995
_completionState = default;
100-
HeaderTimeoutTicks = 0;
96+
StreamTimeoutTicks = 0;
10197

10298
if (_frameWriter == null)
10399
{
@@ -409,7 +405,6 @@ private bool TryClose()
409405
return true;
410406
}
411407

412-
// TODO make this actually close the Http3Stream by telling quic to close the stream.
413408
return false;
414409
}
415410

@@ -501,13 +496,26 @@ public async Task ProcessRequestAsync<TContext>(IHttpApplication<TContext> appli
501496
}
502497
finally
503498
{
504-
ApplyCompletionFlag(StreamCompletionFlags.Completed);
499+
// Drain transports and dispose.
500+
await _context.StreamContext.DisposeAsync();
505501

506502
// Tells the connection to remove the stream from its active collection.
503+
ApplyCompletionFlag(StreamCompletionFlags.Completed);
507504
_context.StreamLifetimeHandler.OnStreamCompleted(this);
508505

509-
// Dispose must happen after stream is no longer active.
510-
await _context.StreamContext.DisposeAsync();
506+
// TODO this is a hack for .NET 6 pooling.
507+
//
508+
// Pooling needs to happen after transports have been drained and stream
509+
// has been completed and is no longer active. All of this logic can't
510+
// be placed in ConnectionContext.DisposeAsync. Instead, QuicStreamContext
511+
// has pooling happen in QuicStreamContext.Dispose.
512+
//
513+
// ConnectionContext only implements IDisposableAsync by default. Only
514+
// QuicStreamContext should pass this check.
515+
if (_context.StreamContext is IDisposable disposableStream)
516+
{
517+
disposableStream.Dispose();
518+
}
511519
}
512520
}
513521
}
@@ -600,8 +608,6 @@ private async Task ProcessHeadersFrameAsync<TContext>(IHttpApplication<TContext>
600608
case RequestHeaderParsingState.Headers:
601609
break;
602610
case RequestHeaderParsingState.Trailers:
603-
// trailers
604-
// TODO figure out if there is anything else to do here.
605611
return;
606612
default:
607613
Debug.Fail("Unexpected header parsing state.");
@@ -627,6 +633,7 @@ private async Task ProcessHeadersFrameAsync<TContext>(IHttpApplication<TContext>
627633
}
628634

629635
_appCompleted = new TaskCompletionSource();
636+
StreamTimeoutTicks = default;
630637
_context.StreamLifetimeHandler.OnStreamHeaderReceived(this);
631638

632639
ThreadPool.UnsafeQueueUserWorkItem(this, preferLocal: false);

src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3Stream.cs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,30 @@ internal interface IHttp3Stream
1414
long StreamId { get; }
1515

1616
/// <summary>
17-
/// Used to track the timeout between when the stream was started by the client, and getting a header.
18-
/// Value is driven by <see cref="KestrelServerLimits.RequestHeadersTimeout"/>.
17+
/// Used to track the timeout in two situations:
18+
/// 1. Between when the stream was started by the client, and getting a header.
19+
/// Value is driven by <see cref="KestrelServerLimits.RequestHeadersTimeout"/>.
20+
/// 2. Between when the request delegate is complete and the transport draining.
21+
/// Value is driven by <see cref="KestrelServerLimits.MinResponseDataRate"/>.
1922
/// </summary>
20-
long HeaderTimeoutTicks { get; set; }
23+
long StreamTimeoutTicks { get; set; }
2124

2225
/// <summary>
23-
/// The stream has received and parsed the header frame.
26+
/// The stream is receiving the header frame.
2427
/// - Request streams = HEADERS frame.
2528
/// - Control streams = unidirectional stream header.
2629
/// </summary>
27-
bool ReceivedHeader { get; }
30+
bool IsReceivingHeader { get; }
31+
32+
/// <summary>
33+
/// The stream request delegate is complete and the transport is draining.
34+
/// </summary>
35+
bool IsDraining { get; }
2836

2937
bool IsRequestStream { get; }
3038

39+
string TraceIdentifier { get; }
40+
3141
void Abort(ConnectionAbortedException abortReason, Http3ErrorCode errorCode);
3242
}
3343
}

src/Servers/Kestrel/Core/src/Internal/Infrastructure/ITimeoutControl.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ internal interface ITimeoutControl
2626
void StartTimingWrite();
2727
void StopTimingWrite();
2828
void BytesWrittenToBuffer(MinDataRate minRate, long count);
29+
long GetResponseDrainDeadline(long ticks, MinDataRate minRate);
2930
}
3031
}

src/Servers/Kestrel/Core/src/Internal/Infrastructure/TimeoutControl.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,5 +330,14 @@ void IConnectionTimeoutFeature.ResetTimeout(TimeSpan timeSpan)
330330

331331
ResetTimeout(timeSpan.Ticks, TimeoutReason.TimeoutFeature);
332332
}
333+
334+
public long GetResponseDrainDeadline(long ticks, MinDataRate minRate)
335+
{
336+
// On grace period overflow, use max value.
337+
var gracePeriod = ticks + minRate.GracePeriod.Ticks;
338+
gracePeriod = gracePeriod >= 0 ? gracePeriod : long.MaxValue;
339+
340+
return Math.Max(_writeTimingTimeoutTimestamp, gracePeriod);
341+
}
333342
}
334343
}

src/Servers/Kestrel/Transport.Quic/src/Internal/QuicStreamContext.FeatureCollection.cs

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Net.Sockets;
54
using Microsoft.AspNetCore.Connections;
65
using Microsoft.AspNetCore.Connections.Features;
76

@@ -31,15 +30,18 @@ public void AbortRead(long errorCode, ConnectionAbortedException abortReason)
3130
{
3231
lock (_shutdownLock)
3332
{
34-
if (_stream.CanRead)
33+
if (_stream != null)
3534
{
36-
_shutdownReadReason = abortReason;
37-
_log.StreamAbortRead(this, errorCode, abortReason.Message);
38-
_stream.AbortRead(errorCode);
39-
}
40-
else
41-
{
42-
throw new InvalidOperationException("Unable to abort reading from a stream that doesn't support reading.");
35+
if (_stream.CanRead)
36+
{
37+
_shutdownReadReason = abortReason;
38+
_log.StreamAbortRead(this, errorCode, abortReason.Message);
39+
_stream.AbortRead(errorCode);
40+
}
41+
else
42+
{
43+
throw new InvalidOperationException("Unable to abort reading from a stream that doesn't support reading.");
44+
}
4345
}
4446
}
4547
}
@@ -48,15 +50,18 @@ public void AbortWrite(long errorCode, ConnectionAbortedException abortReason)
4850
{
4951
lock (_shutdownLock)
5052
{
51-
if (_stream.CanWrite)
52-
{
53-
_shutdownWriteReason = abortReason;
54-
_log.StreamAbortWrite(this, errorCode, abortReason.Message);
55-
_stream.AbortWrite(errorCode);
56-
}
57-
else
53+
if (_stream != null)
5854
{
59-
throw new InvalidOperationException("Unable to abort writing to a stream that doesn't support writing.");
55+
if (_stream.CanWrite)
56+
{
57+
_shutdownWriteReason = abortReason;
58+
_log.StreamAbortWrite(this, errorCode, abortReason.Message);
59+
_stream.AbortWrite(errorCode);
60+
}
61+
else
62+
{
63+
throw new InvalidOperationException("Unable to abort writing to a stream that doesn't support writing.");
64+
}
6065
}
6166
}
6267
}

0 commit comments

Comments
 (0)