Skip to content

Commit fae6720

Browse files
authored
Send connection WINDOW_UPDATE before RTT PING (#97881)
Some servers in GCP have a non-standard ping accounting mechanism, meaning they reset their unsolicited PING counter when they receive DATA, HEADERS or WINDOW_UPDATE. To comply with this behavior, this PR adjusts the RTT logic ensuring that a connection WINDOW_UPDATE is being sent out before we send an RTT PING by applying two changes: - Swap the order of the normal connection WINDOW_UPDATE we attempt after every DATA frame received and the sending of the RTT PING so WINDOW_UPDATE goes first. - In case we need to send an RTT PING, and we didn't send a normal connection WINDOW_UPDATE (threshold not reached), send a WINDOW_UPDATE anyways with current _pendingWindowUpdate credits. Do not send a PING if this is not possible (_pendingWindowUpdate == 0). Just like RTT PINGs, such WINDOW_UPDATEs are relatively rare and should not hurt performance.
1 parent b8e1296 commit fae6720

File tree

4 files changed

+79
-31
lines changed

4 files changed

+79
-31
lines changed

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

+21-15
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ private async ValueTask ProcessHeadersFrame(FrameHeader frameHeader)
637637
if (http2Stream != null)
638638
{
639639
http2Stream.OnHeadersStart();
640-
_rttEstimator.OnDataOrHeadersReceived(this);
640+
_rttEstimator.OnDataOrHeadersReceived(this, sendWindowUpdateBeforePing: true);
641641
headersHandler = http2Stream;
642642
}
643643
else
@@ -766,21 +766,16 @@ private void ProcessDataFrame(FrameHeader frameHeader)
766766

767767
ReadOnlySpan<byte> frameData = GetFrameData(_incomingBuffer.ActiveSpan.Slice(0, frameHeader.PayloadLength), hasPad: frameHeader.PaddedFlag, hasPriority: false);
768768

769-
if (http2Stream != null)
770-
{
771-
bool endStream = frameHeader.EndStreamFlag;
772-
773-
http2Stream.OnResponseData(frameData, endStream);
774-
775-
if (!endStream && frameData.Length > 0)
776-
{
777-
_rttEstimator.OnDataOrHeadersReceived(this);
778-
}
779-
}
769+
bool endStream = frameHeader.EndStreamFlag;
770+
http2Stream?.OnResponseData(frameData, endStream);
780771

781772
if (frameData.Length > 0)
782773
{
783-
ExtendWindow(frameData.Length);
774+
bool windowUpdateSent = ExtendWindow(frameData.Length);
775+
if (http2Stream is not null && !endStream)
776+
{
777+
_rttEstimator.OnDataOrHeadersReceived(this, sendWindowUpdateBeforePing: !windowUpdateSent);
778+
}
784779
}
785780

786781
_incomingBuffer.Discard(frameHeader.PayloadLength);
@@ -1772,7 +1767,7 @@ private Task SendWindowUpdateAsync(int streamId, int amount)
17721767
});
17731768
}
17741769

1775-
private void ExtendWindow(int amount)
1770+
private bool ExtendWindow(int amount)
17761771
{
17771772
if (NetEventSource.Log.IsEnabled()) Trace($"{nameof(amount)}={amount}");
17781773
Debug.Assert(amount > 0);
@@ -1786,14 +1781,25 @@ private void ExtendWindow(int amount)
17861781
if (_pendingWindowUpdate < ConnectionWindowThreshold)
17871782
{
17881783
if (NetEventSource.Log.IsEnabled()) Trace($"{nameof(_pendingWindowUpdate)} {_pendingWindowUpdate} < {ConnectionWindowThreshold}.");
1789-
return;
1784+
return false;
17901785
}
17911786

17921787
windowUpdateSize = _pendingWindowUpdate;
17931788
_pendingWindowUpdate = 0;
17941789
}
17951790

17961791
LogExceptions(SendWindowUpdateAsync(0, windowUpdateSize));
1792+
return true;
1793+
}
1794+
1795+
private bool ForceSendConnectionWindowUpdate()
1796+
{
1797+
if (NetEventSource.Log.IsEnabled()) Trace($"{nameof(_pendingWindowUpdate)}={_pendingWindowUpdate}");
1798+
if (_pendingWindowUpdate == 0) return false;
1799+
1800+
LogExceptions(SendWindowUpdateAsync(0, _pendingWindowUpdate));
1801+
_pendingWindowUpdate = 0;
1802+
return true;
17971803
}
17981804

17991805
public override long GetIdleTicks(long nowTicks)

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

+20-7
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,18 @@ private void AdjustWindowDynamic(int bytesConsumed, Http2Stream stream)
138138
// Assuming that the network characteristics of the connection wouldn't change much within its lifetime, we are maintaining a running minimum value.
139139
// The more PINGs we send, the more accurate is the estimation of MinRtt, however we should be careful not to send too many of them,
140140
// to avoid triggering the server's PING flood protection which may result in an unexpected GOAWAY.
141-
// With most servers we are fine to send PINGs, as long as we are reading their data, this rule is well formalized for gRPC:
141+
//
142+
// Several strategies have been implemented to conform with real life servers.
143+
// 1. With most servers we are fine to send PINGs as long as we are reading their data, a rule formalized by a gRPC spec:
142144
// https://github.com/grpc/proposal/blob/master/A8-client-side-keepalive.md
143-
// As a rule of thumb, we can send send a PING whenever we receive DATA or HEADERS, however, there are some servers which allow receiving only
144-
// a limited amount of PINGs within a given timeframe.
145-
// To deal with the conflicting requirements:
146-
// - We send an initial burst of 'InitialBurstCount' PINGs, to get a relatively good estimation fast
147-
// - Afterwards, we send PINGs with the maximum frequency of 'PingIntervalInSeconds' PINGs per second
145+
// According to this rule, we are OK to send a PING whenever we receive DATA or HEADERS, since the servers conforming to this doc
146+
// will reset their unsolicited ping counter whenever they *send* DATA or HEADERS.
147+
// 2. Some servers allow receiving only a limited amount of PINGs within a given timeframe.
148+
// To deal with this, we send an initial burst of 'InitialBurstCount' (=4) PINGs, to get a relatively good estimation fast. Afterwards,
149+
// we send PINGs each 'PingIntervalInSeconds' second, to maintain our estimation without triggering these servers.
150+
// 3. Some servers in Google's backends reset their unsolicited ping counter when they *receive* DATA, HEADERS, or WINDOW_UPDATE.
151+
// To deal with this, we need to make sure to send a connection WINDOW_UPDATE before sending a PING. The initial burst is an exception
152+
// to this rule, since the mentioned server can tolerate 4 PINGs without receiving a WINDOW_UPDATE.
148153
//
149154
// Threading:
150155
// OnInitialSettingsSent() is called during initialization, all other methods are triggered by HttpConnection.ProcessIncomingFramesAsync(),
@@ -194,7 +199,7 @@ internal void OnInitialSettingsAckReceived(Http2Connection connection)
194199
_state = State.Waiting;
195200
}
196201

197-
internal void OnDataOrHeadersReceived(Http2Connection connection)
202+
internal void OnDataOrHeadersReceived(Http2Connection connection, bool sendWindowUpdateBeforePing)
198203
{
199204
if (_state != State.Waiting) return;
200205

@@ -204,6 +209,14 @@ internal void OnDataOrHeadersReceived(Http2Connection connection)
204209
{
205210
if (initial) _initialBurst--;
206211

212+
// When sendWindowUpdateBeforePing is true, try to send a WINDOW_UPDATE to make Google backends happy.
213+
// Unless we are doing the initial burst, do not send PING if we were not able to send the WINDOW_UPDATE.
214+
// See point 3. in the comments above the class definition for more info.
215+
if (sendWindowUpdateBeforePing && !connection.ForceSendConnectionWindowUpdate() && !initial)
216+
{
217+
return;
218+
}
219+
207220
// Send a PING
208221
_pingCounter--;
209222
if (NetEventSource.Log.IsEnabled()) connection.Trace($"[FlowControl] Sending RTT PING with payload {_pingCounter}");

src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs

+4
Original file line numberDiff line numberDiff line change
@@ -2453,6 +2453,7 @@ public async Task PostAsyncDuplex_ClientSendsEndStream_Success()
24532453
HttpResponseMessage response = await responseTask;
24542454
Stream responseStream = await response.Content.ReadAsStreamAsync();
24552455

2456+
connection.IgnoreWindowUpdates();
24562457
// Send some data back and forth
24572458
await SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId);
24582459
await SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId);
@@ -2513,6 +2514,7 @@ public async Task PostAsyncDuplex_ServerSendsEndStream_Success()
25132514
HttpResponseMessage response = await responseTask;
25142515
Stream responseStream = await response.Content.ReadAsStreamAsync();
25152516

2517+
connection.IgnoreWindowUpdates();
25162518
// Send some data back and forth
25172519
await SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId);
25182520
await SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId);
@@ -2833,6 +2835,7 @@ public async Task PostAsyncDuplex_DisposeResponseBodyBeforeEnd_ResetsStreamAndTh
28332835
// This allows the request processing to complete.
28342836
duplexContent.Fail(e);
28352837

2838+
connection.IgnoreWindowUpdates(); // The RTT algorithm may send a WINDOW_UPDATE before RST_STREAM.
28362839
// Client should set RST_STREAM.
28372840
await connection.ReadRstStreamAsync(streamId);
28382841
}
@@ -2906,6 +2909,7 @@ public async Task PostAsyncDuplex_DisposeResponseBodyAfterEndReceivedButBeforeCo
29062909
// This allows the request processing to complete.
29072910
duplexContent.Fail(e);
29082911

2912+
connection.IgnoreWindowUpdates(); // The RTT algorithm may send a WINDOW_UPDATE before RST_STREAM.
29092913
// Client should set RST_STREAM.
29102914
await connection.ReadRstStreamAsync(streamId);
29112915
}

src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2FlowControl.cs

+34-9
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ static async Task RunTest()
128128
TimeSpan.FromMilliseconds(30),
129129
TimeSpan.Zero,
130130
2 * 1024 * 1024,
131-
null);
131+
maxWindowForPingStopValidation: MaxWindow);
132132

133133
Assert.True(maxCredit <= MaxWindow);
134134
}
@@ -181,19 +181,34 @@ static async Task RunTest()
181181
RemoteExecutor.Invoke(RunTest, options).Dispose();
182182
}
183183

184+
[OuterLoop("Runs long")]
185+
[Fact]
186+
public async Task LongRunningSlowServerStream_NoInvalidPingsAreSent()
187+
{
188+
// A scenario similar to https://github.com/grpc/grpc-dotnet/issues/2361.
189+
// We need to send a small amount of data so the connection window is not consumed and no "standard" WINDOW_UPDATEs are sent and
190+
// we also need to do it very slowly to cover some RTT PINGs after the initial burst.
191+
// This scenario should trigger the "forced WINDOW_UPDATE" logic in the implementation, ensuring that no more than 4 PINGs are sent without a WINDOW_UPDATE.
192+
await TestClientWindowScalingAsync(
193+
TimeSpan.FromMilliseconds(500),
194+
TimeSpan.FromMilliseconds(500),
195+
1024,
196+
_output,
197+
dataPerFrame: 32);
198+
}
199+
184200
private static async Task<int> TestClientWindowScalingAsync(
185201
TimeSpan networkDelay,
186202
TimeSpan slowBandwidthSimDelay,
187203
int bytesToDownload,
188204
ITestOutputHelper output = null,
189-
int maxWindowForPingStopValidation = int.MaxValue, // set to actual maximum to test if we stop sending PING when window reached maximum
190-
Action<SocketsHttpHandler> configureHandler = null)
205+
int dataPerFrame = 16384,
206+
int maxWindowForPingStopValidation = 16 * 1024 * 1024) // set to actual maximum to test if we stop sending PING when window reached maximum
191207
{
192208
TimeSpan timeout = TimeSpan.FromSeconds(30);
193209
CancellationTokenSource timeoutCts = new CancellationTokenSource(timeout);
194210

195211
HttpClientHandler handler = CreateHttpClientHandler(HttpVersion20.Value);
196-
configureHandler?.Invoke(GetUnderlyingSocketsHttpHandler(handler));
197212

198213
using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(NoAutoPingResponseHttp2Options);
199214
using HttpClient client = new HttpClient(handler, true);
@@ -225,13 +240,13 @@ private static async Task<int> TestClientWindowScalingAsync(
225240
using SemaphoreSlim writeSemaphore = new SemaphoreSlim(1);
226241
int remainingBytes = bytesToDownload;
227242

228-
bool pingReceivedAfterReachingMaxWindow = false;
243+
string unexpectedPingReason = null;
229244
bool unexpectedFrameReceived = false;
230245
CancellationTokenSource stopFrameProcessingCts = new CancellationTokenSource();
231246

232247
CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stopFrameProcessingCts.Token, timeoutCts.Token);
233248
Task processFramesTask = ProcessIncomingFramesAsync(linkedCts.Token);
234-
byte[] buffer = new byte[16384];
249+
byte[] buffer = new byte[dataPerFrame];
235250

236251
while (remainingBytes > 0)
237252
{
@@ -259,7 +274,7 @@ private static async Task<int> TestClientWindowScalingAsync(
259274

260275
int dataReceived = (await response.Content.ReadAsByteArrayAsync()).Length;
261276
Assert.Equal(bytesToDownload, dataReceived);
262-
Assert.False(pingReceivedAfterReachingMaxWindow, "Server received a PING after reaching max window");
277+
Assert.Null(unexpectedPingReason);
263278
Assert.False(unexpectedFrameReceived, "Server received an unexpected frame, see test output for more details.");
264279

265280
return maxCredit;
@@ -270,6 +285,7 @@ async Task ProcessIncomingFramesAsync(CancellationToken cancellationToken)
270285
// We should not receive any more RTT PING's after this point
271286
int maxWindowCreditThreshold = (int) (0.9 * maxWindowForPingStopValidation);
272287
output?.WriteLine($"maxWindowCreditThreshold: {maxWindowCreditThreshold} maxWindowForPingStopValidation: {maxWindowForPingStopValidation}");
288+
int pingsWithoutWindowUpdate = 0;
273289

274290
try
275291
{
@@ -284,10 +300,18 @@ async Task ProcessIncomingFramesAsync(CancellationToken cancellationToken)
284300

285301
output?.WriteLine($"Received PING ({pingFrame.Data})");
286302

303+
pingsWithoutWindowUpdate++;
287304
if (maxCredit > maxWindowCreditThreshold)
288305
{
289-
output?.WriteLine("PING was unexpected");
290-
Volatile.Write(ref pingReceivedAfterReachingMaxWindow, true);
306+
Volatile.Write(ref unexpectedPingReason, "The server received a PING after reaching max window");
307+
output?.WriteLine($"PING was unexpected: {unexpectedPingReason}");
308+
}
309+
310+
// Exceeding this limit may trigger a GOAWAY on some servers. See implementation comments for more details.
311+
if (pingsWithoutWindowUpdate > 4)
312+
{
313+
Volatile.Write(ref unexpectedPingReason, $"The server received {pingsWithoutWindowUpdate} PINGs without receiving a WINDOW_UPDATE");
314+
output?.WriteLine($"PING was unexpected: {unexpectedPingReason}");
291315
}
292316

293317
await writeSemaphore.WaitAsync(cancellationToken);
@@ -296,6 +320,7 @@ async Task ProcessIncomingFramesAsync(CancellationToken cancellationToken)
296320
}
297321
else if (frame is WindowUpdateFrame windowUpdateFrame)
298322
{
323+
pingsWithoutWindowUpdate = 0;
299324
// Ignore connection window:
300325
if (windowUpdateFrame.StreamId != streamId) continue;
301326

0 commit comments

Comments
 (0)