diff --git a/src/libraries/Common/src/Interop/Windows/WinSock/Interop.WSAIoctl.TcpInfo.cs b/src/libraries/Common/src/Interop/Windows/WinSock/Interop.WSAIoctl.TcpInfo.cs new file mode 100644 index 00000000000000..a921a4f0ce204e --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/WinSock/Interop.WSAIoctl.TcpInfo.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + internal static partial class Winsock + { + private const int SioTcpInfo = unchecked((int)3623878695L); + + [DllImport(Interop.Libraries.Ws2_32, SetLastError = true, EntryPoint = "WSAIoctl")] + private static extern SocketError WSAIoctl_Blocking( + SafeSocketHandle socketHandle, + [In] int ioControlCode, + [In] ref int inBuffer, + [In] int inBufferSize, + [Out] out _TCP_INFO_v0 outBuffer, + [In] int outBufferSize, + [Out] out int bytesTransferred, + [In] IntPtr overlapped, + [In] IntPtr completionRoutine); + + internal static unsafe SocketError GetTcpInfoV0(SafeSocketHandle socketHandle, out _TCP_INFO_v0 tcpInfo) + { + int input = 0; + return WSAIoctl_Blocking(socketHandle, SioTcpInfo, ref input, sizeof(int), out tcpInfo, sizeof(_TCP_INFO_v0), out _, IntPtr.Zero, IntPtr.Zero); + } + + internal struct _TCP_INFO_v0 + { + internal System.Net.NetworkInformation.TcpState State; + internal uint Mss; + internal ulong ConnectionTimeMs; + internal byte TimestampsEnabled; + internal uint RttUs; + internal uint MinRttUs; + internal uint BytesInFlight; + internal uint Cwnd; + internal uint SndWnd; + internal uint RcvWnd; + internal uint RcvBuf; + internal ulong BytesOut; + internal ulong BytesIn; + internal uint BytesReordered; + internal uint BytesRetrans; + internal uint FastRetrans; + internal uint DupAcksIn; + internal uint TimeoutEpisodes; + internal byte SynRetrans; + } + } +} diff --git a/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs b/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs index 3bf335ec33cea4..c3af562389eab8 100644 --- a/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs +++ b/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs @@ -28,5 +28,7 @@ internal static partial class HttpHandlerDefaults public static readonly TimeSpan DefaultPooledConnectionIdleTimeout = TimeSpan.FromMinutes(1); public static readonly TimeSpan DefaultExpect100ContinueTimeout = TimeSpan.FromSeconds(1); public static readonly TimeSpan DefaultConnectTimeout = Timeout.InfiniteTimeSpan; + public const int DefaultHttp2MaxStreamWindowSize = 16 * 1024 * 1024; + public const double DefaultHttp2StreamWindowScaleThresholdMultiplier = 1.0; } } diff --git a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs index 01fa9d4e697b5f..c0a63d8ad5a009 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs @@ -25,6 +25,8 @@ public class Http2LoopbackConnection : GenericLoopbackConnection private TaskCompletionSource _ignoredSettingsAckPromise; private bool _ignoreWindowUpdates; private TaskCompletionSource _expectPingFrame; + private bool _autoProcessPingFrames; + private bool _respondToPing; private readonly TimeSpan _timeout; private int _lastStreamId; @@ -200,9 +202,18 @@ private async Task ReadFrameAsync(CancellationToken cancellationToken) if (_expectPingFrame != null && header.Type == FrameType.Ping) { - _expectPingFrame.SetResult(PingFrame.ReadFrom(header, data)); - _expectPingFrame = null; - return await ReadFrameAsync(cancellationToken).ConfigureAwait(false); + PingFrame pingFrame = PingFrame.ReadFrom(header, data); + + // _expectPingFrame is not intended to work with PING ACK: + if (!pingFrame.AckFlag) + { + await ProcessExpectedPingFrame(pingFrame); + return await ReadFrameAsync(cancellationToken).ConfigureAwait(false); + } + else + { + return pingFrame; + } } // Construct the correct frame type and return it. @@ -224,11 +235,30 @@ private async Task ReadFrameAsync(CancellationToken cancellationToken) return GoAwayFrame.ReadFrom(header, data); case FrameType.Continuation: return ContinuationFrame.ReadFrom(header, data); + case FrameType.WindowUpdate: + return WindowUpdateFrame.ReadFrom(header, data); default: return header; } } + private async Task ProcessExpectedPingFrame(PingFrame pingFrame) + { + _expectPingFrame.SetResult(pingFrame); + if (_respondToPing && !pingFrame.AckFlag) + { + await SendPingAckAsync(pingFrame.Data); + } + + _expectPingFrame = null; + _respondToPing = false; + + if (_autoProcessPingFrames) + { + _ = ExpectPingFrameAsync(true); + } + } + // Reset and return underlying networking objects. public (Socket, Stream) ResetNetwork() { @@ -263,15 +293,32 @@ public void IgnoreWindowUpdates() _ignoreWindowUpdates = true; } - // Set up loopback server to expect PING frames among other frames. + // Set up loopback server to expect a (non-ACK) PING frame among other frames. // Once PING frame is read in ReadFrameAsync, the returned task is completed. // The returned task is canceled in ReadPingAsync if no PING frame has been read so far. - public Task ExpectPingFrameAsync() + public Task ExpectPingFrameAsync(bool respond = false) { _expectPingFrame ??= new TaskCompletionSource(); + _respondToPing = respond; + return _expectPingFrame.Task; } + // Recurring variant of ExpectPingFrame(). + // Starting from the time of the call, respond to all (non-ACK) PING frames which are received among other frames. + public void SetupAutomaticPingResponse() + { + _autoProcessPingFrames = true; + _ = ExpectPingFrameAsync(true); + } + + // Tear down automatic PING responses, but still expect (at most one) PING in flight + public void TearDownAutomaticPingResponse() + { + _respondToPing = false; + _autoProcessPingFrames = false; + } + public async Task ReadRstStreamAsync(int streamId) { Frame frame = await ReadFrameAsync(_timeout); @@ -292,6 +339,14 @@ public async Task ReadRstStreamAsync(int streamId) } } + // Receive a single PING frame and respond with an ACK + public async Task RespondToPingFrameAsync() + { + PingFrame pingFrame = (PingFrame)await ReadFrameAsync(_timeout); + Assert.False(pingFrame.AckFlag, "Unexpected PING ACK"); + await SendPingAckAsync(pingFrame.Data); + } + // Wait for the client to close the connection, e.g. after the HttpClient is disposed. public async Task WaitForClientDisconnectAsync(bool ignoreUnexpectedFrames = false) { @@ -720,9 +775,11 @@ public async Task PingPong() PingFrame ping = new PingFrame(pingData, FrameFlags.None, 0); await WriteFrameAsync(ping).ConfigureAwait(false); PingFrame pingAck = (PingFrame)await ReadFrameAsync(_timeout).ConfigureAwait(false); + if (pingAck == null || pingAck.Type != FrameType.Ping || !pingAck.AckFlag) { - throw new Exception("Expected PING ACK"); + string faultDetails = pingAck == null ? "" : $" frame.Type:{pingAck.Type} frame.AckFlag: {pingAck.AckFlag}"; + throw new Exception("Expected PING ACK" + faultDetails); } Assert.Equal(pingData, pingAck.Data); @@ -732,6 +789,7 @@ public async Task ReadPingAsync(TimeSpan timeout) { _expectPingFrame?.TrySetCanceled(); _expectPingFrame = null; + _respondToPing = false; Frame frame = await ReadFrameAsync(timeout).ConfigureAwait(false); Assert.NotNull(frame); diff --git a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.cs b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.cs index 06e80b66b00be5..840d5a1f8d21c0 100644 --- a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.cs +++ b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.cs @@ -1414,6 +1414,11 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri => { await server.AcceptConnectionAsync(async connection => { + if (connection is Http2LoopbackConnection http2Connection) + { + http2Connection.SetupAutomaticPingResponse(); // Handle RTT PING + } + // Send unexpected 1xx responses. HttpRequestData requestData = await connection.ReadRequestDataAsync(readBody: false); await connection.SendResponseAsync(responseStatusCode, isFinal: false); @@ -1463,6 +1468,10 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri => await server.AcceptConnectionAsync(async connection => { await connection.ReadRequestDataAsync(readBody: false); + if (connection is Http2LoopbackConnection http2Connection) + { + http2Connection.SetupAutomaticPingResponse(); // Respond to RTT PING + } // Send multiple 100-Continue responses. for (int count = 0 ; count < 4; count++) { @@ -1565,6 +1574,11 @@ await server.AcceptConnectionAsync(async connection => await connection.SendResponseAsync(HttpStatusCode.OK, headers: new HttpHeaderData[] {new HttpHeaderData("Content-Length", $"{ResponseString.Length}")}, isFinal : false); + if (connection is Http2LoopbackConnection http2Connection) + { + http2Connection.SetupAutomaticPingResponse(); // Respond to RTT PING + } + byte[] body = await connection.ReadRequestBodyAsync(); Assert.Equal(RequestString, Encoding.ASCII.GetString(body)); diff --git a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTestBase.cs b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTestBase.cs index 26a084b969f200..7d56c4ce48838b 100644 --- a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTestBase.cs +++ b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTestBase.cs @@ -50,6 +50,8 @@ protected static HttpClient CreateHttpClient(HttpMessageHandler handler, string #endif }; + public const int DefaultInitialWindowSize = 65535; + public static readonly bool[] BoolValues = new[] { true, false }; // For use by remote server tests diff --git a/src/libraries/System.Net.Http/ref/System.Net.Http.cs b/src/libraries/System.Net.Http/ref/System.Net.Http.cs index 02486ab68b11b8..1087699920f169 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -353,7 +353,7 @@ protected override void SerializeToStream(System.IO.Stream stream, System.Net.Tr public sealed partial class SocketsHttpHandler : System.Net.Http.HttpMessageHandler { public SocketsHttpHandler() { } - [System.Runtime.Versioning.UnsupportedOSPlatformGuardAttribute("browser")] + public int InitialHttp2StreamWindowSize { get { throw null; } set { } } public static bool IsSupported { get { throw null; } } public bool AllowAutoRedirect { get { throw null; } set { } } public System.Net.DecompressionMethods AutomaticDecompression { get { throw null; } set { } } diff --git a/src/libraries/System.Net.Http/src/Resources/Strings.resx b/src/libraries/System.Net.Http/src/Resources/Strings.resx index 0f725d661b20db..35ce6f88feb339 100644 --- a/src/libraries/System.Net.Http/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Http/src/Resources/Strings.resx @@ -429,6 +429,9 @@ An HTTP/2 connection could not be established because the server did not complete the HTTP/2 handshake. + + The initial HTTP/2 stream window size must be between {0} and {1}. + This method is not implemented by this class. diff --git a/src/libraries/System.Net.Http/src/System.Net.Http.csproj b/src/libraries/System.Net.Http/src/System.Net.Http.csproj index 0ce5ec1aa171ef..80bbce5df23f40 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -1,4 +1,4 @@ - + win true @@ -62,6 +62,7 @@ + @@ -160,6 +161,7 @@ + @@ -416,6 +418,8 @@ Link="Common\Interop\Windows\WinHttp\Interop.winhttp_types.cs" /> + throw new PlatformNotSupportedException(); } + public int InitialHttp2StreamWindowSize + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } + [AllowNull] public CookieContainer CookieContainer { diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs index 4e4d730c4c188a..2119da9d724978 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs @@ -276,25 +276,7 @@ private static class Settings public static readonly bool s_activityPropagationEnabled = GetEnableActivityPropagationValue(); - private static bool GetEnableActivityPropagationValue() - { - // First check for the AppContext switch, giving it priority over the environment variable. - if (AppContext.TryGetSwitch(EnableActivityPropagationAppCtxSettingName, out bool enableActivityPropagation)) - { - return enableActivityPropagation; - } - - // AppContext switch wasn't used. Check the environment variable to determine which handler should be used. - string? envVar = Environment.GetEnvironmentVariable(EnableActivityPropagationEnvironmentVariableSettingName); - if (envVar != null && (envVar.Equals("false", StringComparison.OrdinalIgnoreCase) || envVar.Equals("0"))) - { - // Suppress Activity propagation. - return false; - } - - // Defaults to enabling Activity propagation. - return true; - } + private static bool GetEnableActivityPropagationValue() => RuntimeSettingParser.QueryRuntimeSettingSwitch(EnableActivityPropagationAppCtxSettingName, EnableActivityPropagationEnvironmentVariableSettingName, true); public static readonly DiagnosticListener s_diagnosticListener = new DiagnosticListener(DiagnosticsHandlerLoggingStrings.DiagnosticListenerName); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs index c60370d6d8052d..da6b3be86ef1fc 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs @@ -38,10 +38,11 @@ internal sealed partial class Http2Connection : HttpConnectionBase, IDisposable private readonly CreditManager _connectionWindow; private readonly CreditManager _concurrentStreams; + private RttEstimator _rttEstimator; private int _nextStream; private bool _expectingSettingsAck; - private int _initialWindowSize; + private int _initialServerStreamWindowSize; private int _maxConcurrentStreams; private int _pendingWindowUpdate; private long _idleSinceTickCount; @@ -79,13 +80,15 @@ internal sealed partial class Http2Connection : HttpConnectionBase, IDisposable #else private const int InitialConnectionBufferSize = 4096; #endif - - private const int DefaultInitialWindowSize = 65535; + // The default initial window size for streams and connections according to the RFC: + // https://datatracker.ietf.org/doc/html/rfc7540#section-5.2.1 + internal const int DefaultInitialWindowSize = 65535; // We don't really care about limiting control flow at the connection level. // We limit it per stream, and the user controls how many streams are created. // So set the connection window size to a large value. private const int ConnectionWindowSize = 64 * 1024 * 1024; + private const int ConnectionWindowUpdateRatio = 8; // We hold off on sending WINDOW_UPDATE until we hit thi minimum threshold. // This value is somewhat arbitrary; the intent is to ensure it is much smaller than @@ -93,7 +96,7 @@ internal sealed partial class Http2Connection : HttpConnectionBase, IDisposable // If we want to further reduce the frequency of WINDOW_UPDATEs, it's probably better to // increase the window size (and thus increase the threshold proportionally) // rather than just increase the threshold. - private const int ConnectionWindowThreshold = ConnectionWindowSize / 8; + private const int ConnectionWindowThreshold = ConnectionWindowSize / ConnectionWindowUpdateRatio; // When buffering outgoing writes, we will automatically buffer up to this number of bytes. // Single writes that are larger than the buffer can cause the buffer to expand beyond @@ -131,10 +134,12 @@ public Http2Connection(HttpConnectionPool pool, Stream stream) _connectionWindow = new CreditManager(this, nameof(_connectionWindow), DefaultInitialWindowSize); _concurrentStreams = new CreditManager(this, nameof(_concurrentStreams), InitialMaxConcurrentStreams); + _rttEstimator = new RttEstimator(this); + _writeChannel = Channel.CreateUnbounded(s_channelOptions); _nextStream = 1; - _initialWindowSize = DefaultInitialWindowSize; + _initialServerStreamWindowSize = DefaultInitialWindowSize; _maxConcurrentStreams = InitialMaxConcurrentStreams; _pendingWindowUpdate = 0; @@ -178,21 +183,30 @@ public async ValueTask SetupAsync() s_http2ConnectionPreface.AsSpan().CopyTo(_outgoingBuffer.AvailableSpan); _outgoingBuffer.Commit(s_http2ConnectionPreface.Length); - // Send SETTINGS frame. Disable push promise. - FrameHeader.WriteTo(_outgoingBuffer.AvailableSpan, FrameHeader.SettingLength, FrameType.Settings, FrameFlags.None, streamId: 0); + // Send SETTINGS frame. Disable push promise & set initial window size. + FrameHeader.WriteTo(_outgoingBuffer.AvailableSpan, 2*FrameHeader.SettingLength, FrameType.Settings, FrameFlags.None, streamId: 0); _outgoingBuffer.Commit(FrameHeader.Size); BinaryPrimitives.WriteUInt16BigEndian(_outgoingBuffer.AvailableSpan, (ushort)SettingId.EnablePush); _outgoingBuffer.Commit(2); BinaryPrimitives.WriteUInt32BigEndian(_outgoingBuffer.AvailableSpan, 0); _outgoingBuffer.Commit(4); + BinaryPrimitives.WriteUInt16BigEndian(_outgoingBuffer.AvailableSpan, (ushort)SettingId.InitialWindowSize); + _outgoingBuffer.Commit(2); + BinaryPrimitives.WriteUInt32BigEndian(_outgoingBuffer.AvailableSpan, (uint)_pool.Settings._initialHttp2StreamWindowSize); + _outgoingBuffer.Commit(4); - // Send initial connection-level WINDOW_UPDATE + // The connection-level window size can not be initialized by SETTINGS frames: + // https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2 + // Send an initial connection-level WINDOW_UPDATE to setup the desired ConnectionWindowSize: + uint windowUpdateAmount = (ConnectionWindowSize - DefaultInitialWindowSize); + if (NetEventSource.Log.IsEnabled()) Trace($"Initial connection-level WINDOW_UPDATE, windowUpdateAmount={windowUpdateAmount}"); FrameHeader.WriteTo(_outgoingBuffer.AvailableSpan, FrameHeader.WindowUpdateLength, FrameType.WindowUpdate, FrameFlags.None, streamId: 0); _outgoingBuffer.Commit(FrameHeader.Size); - BinaryPrimitives.WriteUInt32BigEndian(_outgoingBuffer.AvailableSpan, ConnectionWindowSize - DefaultInitialWindowSize); + BinaryPrimitives.WriteUInt32BigEndian(_outgoingBuffer.AvailableSpan, windowUpdateAmount); _outgoingBuffer.Commit(4); await _stream.WriteAsync(_outgoingBuffer.ActiveMemory).ConfigureAwait(false); + _rttEstimator.OnInitialSettingsSent(); _outgoingBuffer.Discard(_outgoingBuffer.ActiveLength); _expectingSettingsAck = true; @@ -439,6 +453,7 @@ private async ValueTask ProcessHeadersFrame(FrameHeader frameHeader) if (http2Stream != null) { http2Stream.OnHeadersStart(); + _rttEstimator.OnDataOrHeadersReceived(); headersHandler = http2Stream; } else @@ -473,6 +488,7 @@ private async ValueTask ProcessHeadersFrame(FrameHeader frameHeader) _hpackDecoder.CompleteDecode(); http2Stream?.OnHeadersComplete(endStream); + //_rttEstimator.Update(); } /// Nop implementation of used by . @@ -570,6 +586,11 @@ private void ProcessDataFrame(FrameHeader frameHeader) bool endStream = frameHeader.EndStreamFlag; http2Stream.OnResponseData(frameData, endStream); + + if (!endStream && frameData.Length > 0) + { + _rttEstimator.OnDataOrHeadersReceived(); + } } if (frameData.Length > 0) @@ -604,6 +625,7 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f // We only send SETTINGS once initially, so we don't need to do anything in response to the ACK. // Just remember that we received one and we won't be expecting any more. _expectingSettingsAck = false; + _rttEstimator.OnInitialSettingsAckReceived(); } else { @@ -691,8 +713,8 @@ private void ChangeInitialWindowSize(int newSize) lock (SyncObject) { - int delta = newSize - _initialWindowSize; - _initialWindowSize = newSize; + int delta = newSize - _initialServerStreamWindowSize; + _initialServerStreamWindowSize = newSize; // Adjust existing streams foreach (KeyValuePair kvp in _httpStreams) @@ -1395,7 +1417,7 @@ await PerformWriteAsync(totalSize, (thisRef: this, http2Stream, headerBytes, end // assigning the stream ID to ensure only one stream gets an ID, and it must be held // across setting the initial window size (available credit) and storing the stream into // collection such that window size updates are able to atomically affect all known streams. - s.http2Stream.Initialize(s.thisRef._nextStream, s.thisRef._initialWindowSize); + s.http2Stream.Initialize(s.thisRef._nextStream, s.thisRef._initialServerStreamWindowSize); // Client-initiated streams are always odd-numbered, so increase by 2. s.thisRef._nextStream += 2; @@ -1666,6 +1688,9 @@ private void StartTerminatingConnection(int lastValidStream, Exception abortExce // we could hold pool lock while trying to grab connection lock in Dispose(). _pool.InvalidateHttp2Connection(this); + // There is no point sending more PING frames for RTT estimation: + _rttEstimator.OnGoAwayReceived(); + List streamsToAbort = new List(); lock (SyncObject) @@ -1975,11 +2000,19 @@ private void RefreshPingTimestamp() private void ProcessPingAck(long payload) { - if (_keepAliveState != KeepAliveState.PingSent) - ThrowProtocolError(); - if (Interlocked.Read(ref _keepAlivePingPayload) != payload) - ThrowProtocolError(); - _keepAliveState = KeepAliveState.None; + if (payload < 0) // RTT ping + { + _rttEstimator.OnPingAckReceived(payload); + return; + } + else // Keepalive ping + { + if (_keepAliveState != KeepAliveState.PingSent) + ThrowProtocolError(); + if (Interlocked.Read(ref _keepAlivePingPayload) != payload) + ThrowProtocolError(); + _keepAliveState = KeepAliveState.None; + } } private void VerifyKeepAlive() diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs index d7d7a88970375e..0c5d8e9e275336 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs @@ -36,7 +36,7 @@ private sealed class Http2Stream : IValueTaskSource, IHttpHeadersHandler, IHttpT private HttpResponseHeaders? _trailers; private MultiArrayBuffer _responseBuffer; // mutable struct, do not make this readonly - private int _pendingWindowUpdate; + private Http2StreamWindowManager _windowManager; private CreditWaiter? _creditWaiter; private int _availableCredit; private readonly object _creditSyncObject = new object(); // split from SyncObject to avoid lock ordering problems with Http2Connection.SyncObject @@ -85,11 +85,6 @@ private sealed class Http2Stream : IValueTaskSource, IHttpHeadersHandler, IHttpT private int _headerBudgetRemaining; - private const int StreamWindowSize = DefaultInitialWindowSize; - - // See comment on ConnectionWindowThreshold. - private const int StreamWindowThreshold = StreamWindowSize / 8; - public Http2Stream(HttpRequestMessage request, Http2Connection connection) { _request = request; @@ -102,7 +97,10 @@ public Http2Stream(HttpRequestMessage request, Http2Connection connection) _responseBuffer = new MultiArrayBuffer(InitialStreamBufferSize); - _pendingWindowUpdate = 0; + _windowManager = new Http2StreamWindowManager(connection, this); + + Trace($"_windowManager: {_windowManager.GetType().Name}"); + _headerBudgetRemaining = connection._pool.Settings._maxResponseHeadersLength * 1024; if (_request.Content == null) @@ -149,6 +147,8 @@ public void Initialize(int streamId, int initialWindowSize) public bool SendRequestFinished => _requestCompletionState != StreamCompletionState.InProgress; + public bool ExpectResponseData => _responseProtocolState == ResponseProtocolState.ExpectingData; + public HttpResponseMessage GetAndClearResponse() { // Once SendAsync completes, the Http2Stream should no longer hold onto the response message. @@ -805,7 +805,7 @@ public void OnResponseData(ReadOnlySpan buffer, bool endStream) break; } - if (_responseBuffer.ActiveMemory.Length + buffer.Length > StreamWindowSize) + if (_responseBuffer.ActiveMemory.Length + buffer.Length > _windowManager.StreamWindowSize) { // Window size exceeded. ThrowProtocolError(Http2ProtocolErrorCode.FlowControlError); @@ -1013,30 +1013,6 @@ public async Task ReadResponseHeadersAsync(CancellationToken cancellationToken) } } - private void ExtendWindow(int amount) - { - Debug.Assert(amount > 0); - Debug.Assert(_pendingWindowUpdate < StreamWindowThreshold); - - if (_responseProtocolState != ResponseProtocolState.ExpectingData) - { - // We are not expecting any more data (because we've either completed or aborted). - // So no need to send any more WINDOW_UPDATEs. - return; - } - - _pendingWindowUpdate += amount; - if (_pendingWindowUpdate < StreamWindowThreshold) - { - return; - } - - int windowUpdateSize = _pendingWindowUpdate; - _pendingWindowUpdate = 0; - - _connection.LogExceptions(_connection.SendWindowUpdateAsync(StreamId, windowUpdateSize)); - } - private (bool wait, int bytesRead) TryReadFromBuffer(Span buffer, bool partOfSyncRead = false) { Debug.Assert(buffer.Length > 0); @@ -1089,7 +1065,7 @@ public int ReadData(Span buffer, HttpResponseMessage responseMessage) if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead); } else { @@ -1118,7 +1094,7 @@ public async ValueTask ReadDataAsync(Memory buffer, HttpResponseMessa if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead); } else { @@ -1148,7 +1124,7 @@ public void CopyTo(HttpResponseMessage responseMessage, Stream destination, int if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead); destination.Write(new ReadOnlySpan(buffer, 0, bytesRead)); } else @@ -1184,7 +1160,7 @@ public async Task CopyToAsync(HttpResponseMessage responseMessage, Stream destin if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead); await destination.WriteAsync(new ReadOnlyMemory(buffer, 0, bytesRead), cancellationToken).ConfigureAwait(false); } else diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2StreamWindowManager.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2StreamWindowManager.cs new file mode 100644 index 00000000000000..18c6437ae3e3b0 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2StreamWindowManager.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + internal sealed partial class Http2Connection + { + private struct Http2StreamWindowManager + { + public const int StreamWindowUpdateRatio = 8; + private static readonly double StopWatchToTimesSpan = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; + + private readonly Http2Connection _connection; + private readonly Http2Stream _stream; + + private readonly double _windowScaleThresholdMultiplier; + private readonly int _maxStreamWindowSize; + private bool _windowScalingEnabled; + + private int _deliveredBytes; + private int _streamWindowSize; + private long _lastWindowUpdate; + + public Http2StreamWindowManager(Http2Connection connection, Http2Stream stream) + { + _connection = connection; + _stream = stream; + HttpConnectionSettings settings = connection._pool.Settings; + _streamWindowSize = settings._initialHttp2StreamWindowSize; + _windowScalingEnabled = !settings._disableDynamicHttp2WindowSizing; + _maxStreamWindowSize = settings._maxHttp2StreamWindowSize; + _windowScaleThresholdMultiplier = settings._http2StreamWindowScaleThresholdMultiplier; + _lastWindowUpdate = Stopwatch.GetTimestamp(); + _deliveredBytes = 0; + + if (NetEventSource.Log.IsEnabled()) _stream.Trace($"[FlowControl] InitialClientStreamWindowSize: {StreamWindowSize}, StreamWindowThreshold: {StreamWindowThreshold}, WindowScaleThresholdMultiplier: {_windowScaleThresholdMultiplier}"); + } + + internal int StreamWindowSize => _streamWindowSize; + + internal int StreamWindowThreshold => _streamWindowSize / StreamWindowUpdateRatio; + + public void AdjustWindow(int bytesConsumed) + { + Debug.Assert(bytesConsumed > 0); + Debug.Assert(_deliveredBytes < StreamWindowThreshold); + + if (!_stream.ExpectResponseData) + { + // We are not expecting any more data (because we've either completed or aborted). + // So no need to send any more WINDOW_UPDATEs. + return; + } + + if (_windowScalingEnabled) + { + AdjustWindowDynamic(bytesConsumed); + } + else + { + AjdustWindowStatic(bytesConsumed); + } + } + + private void AjdustWindowStatic(int bytesConsumed) + { + _deliveredBytes += bytesConsumed; + if (_deliveredBytes < StreamWindowThreshold) + { + return; + } + + int windowUpdateIncrement = _deliveredBytes; + _deliveredBytes = 0; + + Task sendWindowUpdateTask = _connection.SendWindowUpdateAsync(_stream.StreamId, windowUpdateIncrement); + _connection.LogExceptions(sendWindowUpdateTask); + } + + private void AdjustWindowDynamic(int bytesConsumed) + { + _deliveredBytes += bytesConsumed; + + if (_deliveredBytes < StreamWindowThreshold) + { + return; + } + + int windowUpdateIncrement = _deliveredBytes; + long currentTime = Stopwatch.GetTimestamp(); + + if (_connection._rttEstimator.MinRtt > TimeSpan.Zero) + { + TimeSpan rtt = _connection._rttEstimator.MinRtt; + TimeSpan dt = StopwatchTicksToTimeSpan(currentTime - _lastWindowUpdate); + + if (_deliveredBytes * rtt.Ticks > _streamWindowSize * dt.Ticks * _windowScaleThresholdMultiplier) + { + int extendedWindowSize = Math.Min(_maxStreamWindowSize, _streamWindowSize * 2); + windowUpdateIncrement += extendedWindowSize - _streamWindowSize; + _streamWindowSize = extendedWindowSize; + + if (NetEventSource.Log.IsEnabled()) _stream.Trace($"[FlowControl] Updated Stream Window. StreamWindowSize: {StreamWindowSize}, StreamWindowThreshold: {StreamWindowThreshold}"); + + Debug.Assert(_streamWindowSize <= _maxStreamWindowSize); + if (_streamWindowSize == _maxStreamWindowSize) + { + if (NetEventSource.Log.IsEnabled()) _stream.Trace($"[FlowControl] StreamWindowSize reached the configured maximum of {_maxStreamWindowSize}."); + _windowScalingEnabled = false; + } + } + } + + _deliveredBytes = 0; + + Task sendWindowUpdateTask = _connection.SendWindowUpdateAsync(_stream.StreamId, windowUpdateIncrement); + _connection.LogExceptions(sendWindowUpdateTask); + + _lastWindowUpdate = currentTime; + } + + private static TimeSpan StopwatchTicksToTimeSpan(long stopwatchTicks) + { + long ticks = (long)(StopWatchToTimesSpan * stopwatchTicks); + return new TimeSpan(ticks); + } + } + + private struct RttEstimator + { + private enum State + { + Disabled, + Init, + Waiting, + PingSent, + Terminating + } + + private const double PingIntervalInSeconds = 1; + private static readonly long PingIntervalInTicks =(long)(PingIntervalInSeconds * Stopwatch.Frequency); + + private Http2Connection _connection; + + private State _state; + private long _pingSentTimestamp; + private long _pingCounter; + private int _initialBurst; + + public TimeSpan MinRtt; + + public RttEstimator(Http2Connection connection) + { + _connection = connection; + _state = connection._pool.Settings._disableDynamicHttp2WindowSizing ? State.Disabled : State.Init; + _pingCounter = -1; + _initialBurst = 4; + _pingSentTimestamp = default; + MinRtt = default; + } + + internal void OnInitialSettingsSent() + { + if (_state == State.Disabled) return; + _pingSentTimestamp = Stopwatch.GetTimestamp(); + } + + internal void OnInitialSettingsAckReceived() + { + if (_state == State.Disabled) return; + RefreshRtt(); + _state = State.Waiting; + } + + internal void OnDataOrHeadersReceived() + { + if (_state != State.Waiting) return; + + long now = Stopwatch.GetTimestamp(); + bool initial = Interlocked.Decrement(ref _initialBurst) >= 0; + if (initial || now - _pingSentTimestamp > PingIntervalInTicks) + { + if (_initialBurst > 0) Interlocked.Decrement(ref _initialBurst); + + // Send a PING + long payload = Interlocked.Decrement(ref _pingCounter); + _connection.LogExceptions(_connection.SendPingAsync(payload, isAck: false)); + _pingSentTimestamp = now; + _state = State.PingSent; + } + } + + internal void OnPingAckReceived(long payload) + { + if (_state != State.PingSent) return; + Debug.Assert(payload < 0); + + if (Interlocked.Read(ref _pingCounter) != payload) + ThrowProtocolError(); + RefreshRtt(); + _state = State.Waiting; + } + + internal void OnGoAwayReceived() + { + if (_state == State.Disabled) return; + _state = State.Terminating; + } + + private void RefreshRtt() + { + long elapsedTicks = Stopwatch.GetTimestamp() - _pingSentTimestamp; + TimeSpan prevRtt = MinRtt == TimeSpan.Zero ? TimeSpan.MaxValue : MinRtt; + TimeSpan currentRtt = TimeSpan.FromSeconds(elapsedTicks / (double)Stopwatch.Frequency); + MinRtt = new TimeSpan(Math.Min(prevRtt.Ticks, currentRtt.Ticks)); + if (NetEventSource.Log.IsEnabled()) _connection.Trace($"[FlowControl] Updated MinRtt: {MinRtt.TotalMilliseconds} ms"); + } + } + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs index 00a1a376799e2c..1cbf71ac72b8bf 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs @@ -1434,6 +1434,8 @@ private async ValueTask ConstructHttp11ConnectionAsync(bool asyn private async ValueTask ConstructHttp2ConnectionAsync(Stream stream, HttpRequestMessage request, CancellationToken cancellationToken) { + Socket? socket = (stream as NetworkStream)?.Socket; + stream = await ApplyPlaintextFilterAsync(async: true, stream, HttpVersion.Version20, request, cancellationToken).ConfigureAwait(false); Http2Connection http2Connection = new Http2Connection(this, stream); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs index 3c19737ae40032..30cb7478b3dd9a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs @@ -15,11 +15,6 @@ namespace System.Net.Http /// Provides a state bag of settings for configuring HTTP connections. internal sealed class HttpConnectionSettings { - private const string Http2SupportEnvironmentVariableSettingName = "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2SUPPORT"; - private const string Http2SupportAppCtxSettingName = "System.Net.Http.SocketsHttpHandler.Http2Support"; - private const string Http3DraftSupportEnvironmentVariableSettingName = "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP3DRAFTSUPPORT"; - private const string Http3DraftSupportAppCtxSettingName = "System.Net.SocketsHttpHandler.Http3DraftSupport"; - internal DecompressionMethods _automaticDecompression = HttpHandlerDefaults.DefaultAutomaticDecompression; internal bool _useCookies = HttpHandlerDefaults.DefaultUseCookies; @@ -67,6 +62,12 @@ internal sealed class HttpConnectionSettings internal IDictionary? _properties; + // Http2 flow control settings: + internal bool _disableDynamicHttp2WindowSizing = DisableDynamicHttp2WindowSizing; + internal int _maxHttp2StreamWindowSize = MaxHttp2StreamWindowSize; + internal double _http2StreamWindowScaleThresholdMultiplier = Http2StreamWindowScaleThresholdMultiplier; + internal int _initialHttp2StreamWindowSize = Http2Connection.DefaultInitialWindowSize; + public HttpConnectionSettings() { bool allowHttp2 = AllowHttp2; @@ -119,7 +120,11 @@ public HttpConnectionSettings CloneAndNormalize() _responseHeaderEncodingSelector = _responseHeaderEncodingSelector, _enableMultipleHttp2Connections = _enableMultipleHttp2Connections, _connectCallback = _connectCallback, - _plaintextStreamFilter = _plaintextStreamFilter + _plaintextStreamFilter = _plaintextStreamFilter, + _disableDynamicHttp2WindowSizing = _disableDynamicHttp2WindowSizing, + _maxHttp2StreamWindowSize = _maxHttp2StreamWindowSize, + _http2StreamWindowScaleThresholdMultiplier = _http2StreamWindowScaleThresholdMultiplier, + _initialHttp2StreamWindowSize = _initialHttp2StreamWindowSize, }; // TODO: Remove if/when QuicImplementationProvider is removed from System.Net.Quic. @@ -131,55 +136,59 @@ public HttpConnectionSettings CloneAndNormalize() return settings; } - private static bool AllowHttp2 + // Default to allowing HTTP/2, but enable that to be overridden by an + // AppContext switch, or by an environment variable being set to false/0. + private static bool AllowHttp2 => RuntimeSettingParser.QueryRuntimeSettingSwitch( + "System.Net.Http.SocketsHttpHandler.Http2Support", + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2SUPPORT", + true); + + // Default to allowing draft HTTP/3, but enable that to be overridden + // by an AppContext switch, or by an environment variable being set to false/0. + private static bool AllowDraftHttp3 => RuntimeSettingParser.QueryRuntimeSettingSwitch( + "System.Net.SocketsHttpHandler.Http3DraftSupport", + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP3DRAFTSUPPORT", + true); + + // Switch to disable the HTTP/2 dynamic window scaling algorithm. Enabled by default. + private static bool DisableDynamicHttp2WindowSizing => RuntimeSettingParser.QueryRuntimeSettingSwitch( + "System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamic2WindowSizing", + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2FLOWCONTROL_DISABLEDYNAMICWINDOWSIZING", + false); + + // The maximum size of the HTTP/2 stream receive window. Defaults to 16 MB. + private static int MaxHttp2StreamWindowSize { get { - // Default to allowing HTTP/2, but enable that to be overridden by an - // AppContext switch, or by an environment variable being set to false/0. - - // First check for the AppContext switch, giving it priority over the environment variable. - if (AppContext.TryGetSwitch(Http2SupportAppCtxSettingName, out bool allowHttp2)) - { - return allowHttp2; - } + int value = RuntimeSettingParser.ParseInt32EnvironmentVariableValue( + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_MAXSTREAMWINDOWSIZE", + HttpHandlerDefaults.DefaultHttp2MaxStreamWindowSize); - // AppContext switch wasn't used. Check the environment variable. - string? envVar = Environment.GetEnvironmentVariable(Http2SupportEnvironmentVariableSettingName); - if (envVar != null && (envVar.Equals("false", StringComparison.OrdinalIgnoreCase) || envVar.Equals("0"))) + // Disallow small values: + if (value < Http2Connection.DefaultInitialWindowSize) { - // Disallow HTTP/2 protocol. - return false; + value = Http2Connection.DefaultInitialWindowSize; } - - // Default to a maximum of HTTP/2. - return true; + return value; } } - private static bool AllowDraftHttp3 + // Defaults to 1.0. Higher values result in shorter window, but slower downloads. + private static double Http2StreamWindowScaleThresholdMultiplier { get { - // Default to allowing draft HTTP/3, but enable that to be overridden - // by an AppContext switch, or by an environment variable being set to false/0. + double value = RuntimeSettingParser.ParseDoubleEnvironmentVariableValue( + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_STREAMWINDOWSCALETHRESHOLDMULTIPLIER", + HttpHandlerDefaults.DefaultHttp2StreamWindowScaleThresholdMultiplier); - // First check for the AppContext switch, giving it priority over the environment variable. - if (AppContext.TryGetSwitch(Http3DraftSupportAppCtxSettingName, out bool allowHttp3)) + // Disallow negative values: + if (value < 0) { - return allowHttp3; + value = HttpHandlerDefaults.DefaultHttp2StreamWindowScaleThresholdMultiplier; } - - // AppContext switch wasn't used. Check the environment variable. - string? envVar = Environment.GetEnvironmentVariable(Http3DraftSupportEnvironmentVariableSettingName); - if (envVar != null && (envVar.Equals("false", StringComparison.OrdinalIgnoreCase) || envVar.Equals("0"))) - { - // Disallow HTTP/3 protocol for HTTP endpoints. - return false; - } - - // Default to allow. - return true; + return value; } } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RuntimeSettingParser.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RuntimeSettingParser.cs new file mode 100644 index 00000000000000..2fc23028f9b58c --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RuntimeSettingParser.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace System.Net.Http +{ + internal static class RuntimeSettingParser + { + /// + /// Parse a value from an AppContext switch or an environment variable. + /// + public static bool QueryRuntimeSettingSwitch(string appCtxSettingName, string environmentVariableSettingName, bool defaultValue) + { + bool value; + + // First check for the AppContext switch, giving it priority over the environment variable. + if (AppContext.TryGetSwitch(appCtxSettingName, out value)) + { + return value; + } + + // AppContext switch wasn't used. Check the environment variable. + string? envVar = Environment.GetEnvironmentVariable(environmentVariableSettingName); + + if (bool.TryParse(envVar, out value)) + { + return value; + } + else if (uint.TryParse(envVar, out uint intVal)) + { + return intVal != 0; + } + + return defaultValue; + } + + /// + /// Parse an environment variable for an value. + /// + public static int ParseInt32EnvironmentVariableValue(string environmentVariableSettingName, int defaultValue) + { + string? envVar = Environment.GetEnvironmentVariable(environmentVariableSettingName); + + if (int.TryParse(envVar, NumberStyles.Any, CultureInfo.InvariantCulture, out int value)) + { + return value; + } + return defaultValue; + } + + /// + /// Parse an environment variable for a value. + /// + public static double ParseDoubleEnvironmentVariableValue(string environmentVariableSettingName, double defaultValue) + { + string? envVar = Environment.GetEnvironmentVariable(environmentVariableSettingName); + if (double.TryParse(envVar, NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) + { + return value; + } + return defaultValue; + } + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs index 6f392af09ac4df..34f349fb79589e 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs @@ -55,6 +55,22 @@ public bool UseCookies } } + public int InitialHttp2StreamWindowSize + { + get => _settings._initialHttp2StreamWindowSize; + set + { + if (value < Http2Connection.DefaultInitialWindowSize || value > _settings._maxHttp2StreamWindowSize) + { + throw new ArgumentOutOfRangeException( + nameof(InitialHttp2StreamWindowSize), + SR.Format(SR.net_http_http2_invalidinitialstreamwindowsize, Http2Connection.DefaultInitialWindowSize, _settings._maxHttp2StreamWindowSize)); + } + CheckDisposedOrStarted(); + _settings._initialHttp2StreamWindowSize = value; + } + } + [AllowNull] public CookieContainer CookieContainer { diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Finalization.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Finalization.cs index ac1ba7ebee4ed7..82a789e36e3433 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Finalization.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Finalization.cs @@ -53,6 +53,11 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async url => { HttpRequestData data = await connection.ReadRequestDataAsync(readBody: false); await connection.SendResponseHeadersAsync(headers: new HttpHeaderData[] { new HttpHeaderData("SomeHeaderName", "AndValue") }); + if (connection is Http2LoopbackConnection http2Connection) + { + // We may receive an RTT PING in response to HEADERS + _ = http2Connection.ExpectPingFrameAsync(true); + } await connection.WaitForCancellationAsync(); } finally diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs index 668d194ebef1c9..602922a86fe204 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs @@ -334,6 +334,7 @@ public async Task GetAsync_StreamRefused_RequestIsRetried() using (HttpClient client = CreateHttpClient()) { (_, Http2LoopbackConnection connection) = await EstablishConnectionAndProcessOneRequestAsync(client, server); + connection.SetupAutomaticPingResponse(); Task sendTask = client.GetAsync(server.Address); (int streamId1, HttpRequestData requestData1) = await connection.ReadAndParseRequestHeaderAsync(readBody: true); @@ -371,6 +372,7 @@ public async Task PostAsync_StreamRefused_RequestIsRetried() const string Content = "Hello world"; (_, Http2LoopbackConnection connection) = await EstablishConnectionAndProcessOneRequestAsync(client, server); + connection.SetupAutomaticPingResponse(); Task sendTask = client.PostAsync(server.Address, new StringContent(Content)); (int streamId1, HttpRequestData requestData1) = await connection.ReadAndParseRequestHeaderAsync(readBody: true); @@ -742,6 +744,7 @@ public async Task ResponseStreamFrames_HeadersAfterHeadersWithoutEndHeaders_Conn Task sendTask = client.GetAsync(server.Address); Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); int streamId = await connection.ReadRequestHeaderAsync(); + connection.SetupAutomaticPingResponse(); await connection.WriteFrameAsync(MakeSimpleHeadersFrame(streamId, endHeaders: false)); await connection.WriteFrameAsync(MakeSimpleHeadersFrame(streamId, endHeaders: false)); @@ -765,6 +768,7 @@ public async Task ResponseStreamFrames_HeadersAfterHeadersAndContinuationWithout int streamId = await connection.ReadRequestHeaderAsync(); await connection.WriteFrameAsync(MakeSimpleHeadersFrame(streamId, endHeaders: false)); + await connection.RespondToPingFrameAsync(); // Respond to 1 RTT PING await connection.WriteFrameAsync(MakeSimpleContinuationFrame(streamId, endHeaders: false)); await connection.WriteFrameAsync(MakeSimpleHeadersFrame(streamId, endHeaders: false)); @@ -787,6 +791,7 @@ public async Task ResponseStreamFrames_DataAfterHeadersWithoutEndHeaders_Connect int streamId = await connection.ReadRequestHeaderAsync(); await connection.WriteFrameAsync(MakeSimpleHeadersFrame(streamId, endHeaders: false)); + await connection.RespondToPingFrameAsync(); // Receive 1 RTT PING await connection.WriteFrameAsync(MakeSimpleDataFrame(streamId)); // As this is a connection level error, the client should see the request fail. @@ -808,6 +813,7 @@ public async Task ResponseStreamFrames_DataAfterHeadersAndContinuationWithoutEnd int streamId = await connection.ReadRequestHeaderAsync(); await connection.WriteFrameAsync(MakeSimpleHeadersFrame(streamId, endHeaders: false)); + await connection.RespondToPingFrameAsync(); // Respond to 1 RTT PING await connection.WriteFrameAsync(MakeSimpleContinuationFrame(streamId, endHeaders: false)); await connection.WriteFrameAsync(MakeSimpleDataFrame(streamId)); @@ -868,6 +874,7 @@ public async Task GoAwayFrame_NewRequest_NewConnection() Http2LoopbackConnection connection2 = await server.EstablishConnectionAsync(); int streamId2 = await connection2.ReadRequestHeaderAsync(); await connection2.SendDefaultResponseAsync(streamId2); + await connection2.RespondToPingFrameAsync(); // Receive 1 RTT PING in response to HEADERS HttpResponseMessage response2 = await sendTask2; Assert.Equal(HttpStatusCode.OK, response2.StatusCode); @@ -942,6 +949,7 @@ public async Task GoAwayFrame_UnprocessedStreamFirstRequestFinishedFirst_Request // Complete second request int streamId3 = await connection2.ReadRequestHeaderAsync(); await connection2.SendDefaultResponseAsync(streamId3); + await connection2.RespondToPingFrameAsync(); // Receive 1 RTT PING in response to HEADERS HttpResponseMessage response2 = await sendTask2; Assert.Equal(HttpStatusCode.OK, response2.StatusCode); await connection2.WaitForConnectionShutdownAsync(); @@ -978,6 +986,7 @@ public async Task GoAwayFrame_UnprocessedStreamFirstRequestWaitsUntilSecondFinis // Complete second request int streamId3 = await connection2.ReadRequestHeaderAsync(); await connection2.SendDefaultResponseAsync(streamId3); + await connection2.RespondToPingFrameAsync(); // Expect 1 RTT PING in response to HEADERS HttpResponseMessage response2 = await sendTask2; Assert.Equal(HttpStatusCode.OK, response2.StatusCode); await connection2.WaitForConnectionShutdownAsync(); @@ -1026,6 +1035,7 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async uri => await connection.SendGoAway(streamId); // Make sure client received GOAWAY + _ = connection.ExpectPingFrameAsync(); await connection.PingPong(); await connection.SendResponseBodyAsync(streamId, new byte[4] { 15, 14, 13, 12 }, isFinal: false); @@ -1074,6 +1084,9 @@ public async Task CompletedResponse_FrameReceived_Ignored(bool sendDataFrame) DataFrame dataFrame = new DataFrame(new byte[10], FrameFlags.EndStream, 0, streamId); await connection.WriteFrameAsync(dataFrame); + // We may receive an RTT PING in response to the DATA: + _ = connection.ExpectPingFrameAsync(respond: true); + HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -1103,6 +1116,7 @@ public async Task EmptyResponse_FrameReceived_Ignored(bool sendDataFrame) // Send empty response. await connection.SendDefaultResponseAsync(streamId); + connection.SetupAutomaticPingResponse(); // Respond to RTT PINGs HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -1132,6 +1146,9 @@ public async Task CompletedResponse_WindowUpdateFrameReceived_Success() // Send empty response. await connection.SendDefaultResponseAsync(streamId); + // We expect an RTT PING in response to HEADERS: + await connection.RespondToPingFrameAsync(); + HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -1200,6 +1217,9 @@ public async Task ResetResponseStream_FrameReceived_Ignored(ProtocolErrors error int streamId = await connection.ReadRequestHeaderAsync(); await connection.SendDefaultResponseHeadersAsync(streamId); + // Auto-respond to all incoming RTT PINGs: + connection.SetupAutomaticPingResponse(); + // Send a reset stream frame so that stream 1 moves to a terminal state. RstStreamFrame resetStream = new RstStreamFrame(FrameFlags.None, (int)error, streamId); await connection.WriteFrameAsync(resetStream); @@ -1254,6 +1274,9 @@ public async Task GoAwayFrame_NoPendingStreams_ConnectionClosed() { (int streamId, Http2LoopbackConnection connection) = await EstablishConnectionAndProcessOneRequestAsync(client, server); + // We expect one RTT PING as a response to HEADERS + await connection.RespondToPingFrameAsync(); + // Send GOAWAY. GoAwayFrame goAwayFrame = new GoAwayFrame(streamId, 0, new byte[0], 0); await connection.WriteFrameAsync(goAwayFrame); @@ -1274,6 +1297,9 @@ public async Task GoAwayFrame_AllPendingStreamsValid_RequestsSucceedAndConnectio { (_, Http2LoopbackConnection connection) = await EstablishConnectionAndProcessOneRequestAsync(client, server); + // Handle RTT PINGs: + connection.SetupAutomaticPingResponse(); + // Issue three requests Task sendTask1 = client.GetAsync(server.Address); Task sendTask2 = client.GetAsync(server.Address); @@ -1308,6 +1334,8 @@ public async Task GoAwayFrame_AllPendingStreamsValid_RequestsSucceedAndConnectio await connection.SendResponseDataAsync(streamId2, new byte[10], endStream: true); await connection.SendResponseDataAsync(streamId3, new byte[5], endStream: true); + // Send no more PING ACK: + connection.TearDownAutomaticPingResponse(); // We will not send any more frames, so send EOF now, and ensure the client handles this properly. connection.ShutdownSend(); @@ -1341,6 +1369,7 @@ public async Task GoAwayFrame_AbortAllPendingStreams_StreamFailWithExpectedExcep server.AllowMultipleConnections = true; (_, Http2LoopbackConnection connection) = await EstablishConnectionAndProcessOneRequestAsync(client, server); + connection.SetupAutomaticPingResponse(); // Respond to RTT PINGs // Issue three requests, we want to make sure the specific task is related with specific stream Task sendTask1 = client.GetAsync("request1"); @@ -1381,6 +1410,7 @@ public async Task GoAwayFrame_AbortAllPendingStreams_StreamFailWithExpectedExcep string headerData = Encoding.UTF8.GetString(retriedFrame.Data.Span); await newConnection.SendDefaultResponseHeadersAsync(retriedStreamId); + newConnection.SetupAutomaticPingResponse(); await newConnection.SendResponseDataAsync(retriedStreamId, new byte[3], endStream: true); Assert.Contains("request1", headerData); @@ -1437,7 +1467,125 @@ private static async Task ReadToEndOfStream(Http2LoopbackConnection connect return bytesReceived; } - const int DefaultInitialWindowSize = 65535; + [ConditionalFact(nameof(SupportsAlpn))] + public async Task Http2_FlowControl_HighBandwidthDelayProduct_ClientStreamReceiveWindowWindowScalesUp() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.FromMilliseconds(30), + TimeSpan.Zero, + 2 * 1024 * 1024); + + // Expect the client receive window to grow over 1MB: + Assert.True(maxCredit > 1024 * 1024); + } + + [ConditionalFact(nameof(SupportsAlpn))] + public async Task Http2_FlowControl_LowBandwidthDelayProduct_ClientStreamReceiveWindowStopsScaling() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.Zero, + TimeSpan.FromMilliseconds(15), + 2 * 1024 * 1024); + + // Expect the client receive window to stay below 1MB: + Assert.True(maxCredit < 1024 * 1024); + } + + private async Task TestClientWindowScalingAsync(TimeSpan networkDelay, TimeSpan slowBandwidthSimDelay, int bytesToDownload) + { + TimeSpan timeout = TimeSpan.FromSeconds(30); + + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + using HttpClient client = CreateHttpClient(); + + Task clientTask = client.GetAsync(server.Address); + Http2LoopbackConnection connection = await server.AcceptConnectionAsync().ConfigureAwait(false); + SettingsFrame clientSettingsFrame = await connection.ReadSettingsAsync().ConfigureAwait(false); + + // send server SETTINGS: + await connection.WriteFrameAsync(new SettingsFrame()).ConfigureAwait(false); + + // Initial client SETTINGS also works as a PING. Do not send ACK immediately to avoid low RTT estimation + await Task.Delay(networkDelay); + await connection.WriteFrameAsync(new SettingsFrame(FrameFlags.Ack, new SettingsEntry[0])); + + // Expect SETTINGS ACK from client: + await connection.ExpectSettingsAckAsync(); + + int maxCredit = (int)clientSettingsFrame.Entries.SingleOrDefault(e => e.SettingId == SettingId.InitialWindowSize).Value; + if (maxCredit == default) maxCredit = DefaultInitialWindowSize; + int credit = maxCredit; + + int streamId = await connection.ReadRequestHeaderAsync(); + // Write the response. + await connection.SendDefaultResponseHeadersAsync(streamId); + + using SemaphoreSlim creditReceivedSemaphore = new SemaphoreSlim(0); + using SemaphoreSlim writeSemaphore = new SemaphoreSlim(1); + int remainingBytes = bytesToDownload; + _ = Task.Run(ProcessIncomingFramesAsync); + byte[] buffer = new byte[16384]; + + while (remainingBytes > 0) + { + Wait(slowBandwidthSimDelay); + while (credit == 0) await creditReceivedSemaphore.WaitAsync(timeout); + int bytesToSend = Math.Min(Math.Min(buffer.Length, credit), remainingBytes); + + Memory responseData = buffer.AsMemory(0, bytesToSend); + + int nextRemainingBytes = remainingBytes - bytesToSend; + bool endStream = nextRemainingBytes == 0; + + await writeSemaphore.WaitAsync(); + await connection.SendResponseDataAsync(streamId, responseData, endStream); + writeSemaphore.Release(); + + credit -= bytesToSend; + + remainingBytes = nextRemainingBytes; + } + + using HttpResponseMessage response = await clientTask; + int dataReceived = (await response.Content.ReadAsByteArrayAsync()).Length; + Assert.Equal(bytesToDownload, dataReceived); + + return maxCredit; + + async Task ProcessIncomingFramesAsync() + { + while (remainingBytes > 0) + { + Frame frame = await connection.ReadFrameAsync(timeout); + + if (frame is PingFrame pingFrame) + { + // Simulate network delay for RTT PING + Wait(networkDelay); + + await writeSemaphore.WaitAsync(); + await connection.SendPingAckAsync(pingFrame.Data); + writeSemaphore.Release(); + } + else if (frame is WindowUpdateFrame windowUpdateFrame) + { + // Ignore connection window: + if (windowUpdateFrame.StreamId != streamId) continue; + + credit += windowUpdateFrame.UpdateSize; + maxCredit = Math.Max(credit, maxCredit); // Detect if client grows the window + _output.WriteLine("MaxCredit: " + maxCredit); + creditReceivedSemaphore.Release(); + } + else if (frame is not null) + { + throw new Exception("Unexpected frame: " + frame); + } + } + } + + static void Wait(TimeSpan dt) { if (dt != TimeSpan.Zero) Thread.Sleep(dt); } + } [OuterLoop("Uses Task.Delay")] [ConditionalFact(nameof(SupportsAlpn))] @@ -1895,6 +2043,7 @@ public async Task Http2_MaxConcurrentStreams_LimitEnforced() // Process first request and send response. int streamId = await connection.ReadRequestHeaderAsync(); await connection.SendDefaultResponseAsync(streamId); + connection.SetupAutomaticPingResponse(); // Handle RTT PING HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -1963,6 +2112,7 @@ public async Task Http2_WaitingForStream_Cancellation() // Process first request and send response. int streamId = await connection.ReadRequestHeaderAsync(); await connection.SendDefaultResponseAsync(streamId); + await connection.RespondToPingFrameAsync(); // Handle 1 RTT PING received in response to HEADERS HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -2137,6 +2287,7 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async url => frame = null; (streamId, requestData) = await connection.ReadAndParseRequestHeaderAsync(); await connection.SendResponseHeadersAsync(streamId, endStream: false, HttpStatusCode.OK); + connection.SetupAutomaticPingResponse(); // Handle RTT PING await connection.SendResponseBodyAsync(streamId, Encoding.ASCII.GetBytes($"Http2_PendingSend_SendsReset(waitForData: {waitForData})"), isFinal: false); // Wait for any lingering frames or extra reset frames. try @@ -2304,6 +2455,8 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async url => (int streamId, HttpRequestData requestData) = await connection.ReadAndParseRequestHeaderAsync(readBody : false); Assert.Equal("100-continue", requestData.GetSingleHeaderValue("Expect")); + connection.SetupAutomaticPingResponse(); + if (send100Continue) { await connection.SendResponseHeadersAsync(streamId, endStream: false, HttpStatusCode.Continue); @@ -2350,6 +2503,7 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async url => // Reject content with 403. await connection.SendResponseHeadersAsync(streamId, endStream: false, HttpStatusCode.Forbidden); + connection.SetupAutomaticPingResponse(); // Respond to RTT PINGs await connection.SendResponseBodyAsync(streamId, Encoding.ASCII.GetBytes(responseContent)); // Client should send empty request body @@ -2431,7 +2585,10 @@ private async Task SendAndReceiveRequestDataAsync(Memory data, Stream requ { await requestStream.WriteAsync(data); await requestStream.FlushAsync(); - DataFrame dataFrame = (DataFrame)await connection.ReadFrameAsync(TimeSpan.FromSeconds(30)); + + Frame frame = await connection.ReadFrameAsync(TimeSpan.FromSeconds(30)); + DataFrame dataFrame = (DataFrame)frame; + Assert.True(data.Span.SequenceEqual(dataFrame.Data.Span)); } @@ -2478,6 +2635,7 @@ public async Task PostAsyncDuplex_ClientSendsEndStream_Success() // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -2538,6 +2696,7 @@ public async Task PostAsyncDuplex_ServerSendsEndStream_Success() // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); // Handle RTT PING HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -2603,6 +2762,7 @@ public async Task PostAsyncDuplex_RequestContentException_ResetsStream() // Send some data back and forth await SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId); + connection.SetupAutomaticPingResponse(); // Handle RTT PING frames await SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId); await SendAndReceiveRequestDataAsync(contentBytes, requestStream, connection, streamId); await SendAndReceiveRequestDataAsync(contentBytes, requestStream, connection, streamId); @@ -2655,6 +2815,7 @@ public async Task PostAsyncDuplex_RequestContentExceptionAfterResponseEndReceive // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); // Handle RTT PING HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -2775,6 +2936,7 @@ public async Task PostAsyncDuplex_ServerResetsStream_Throws() // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -2790,6 +2952,7 @@ public async Task PostAsyncDuplex_ServerResetsStream_Throws() // Trying to read on the response stream should fail now, and client should ignore any data received await AssertProtocolErrorForIOExceptionAsync(SendAndReceiveResponseDataAsync(contentBytes, responseStream, connection, streamId), ProtocolErrors.ENHANCE_YOUR_CALM); + // Attempting to write on the request body should now fail with OperationCanceledException. Exception e = await Assert.ThrowsAnyAsync(async () => { await SendAndReceiveRequestDataAsync(contentBytes, requestStream, connection, streamId); }); @@ -2836,6 +2999,7 @@ public async Task PostAsyncDuplex_DisposeResponseBodyBeforeEnd_ResetsStreamAndTh // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); // Handler RTT PING HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -2903,6 +3067,7 @@ public async Task PostAsyncDuplex_DisposeResponseBodyAfterEndReceivedButBeforeCo // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); // Handle RTT PING HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -2976,6 +3141,7 @@ public async Task PostAsyncDuplex_FinishRequestBodyAndDisposeResponseBodyAfterEn // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); + connection.SetupAutomaticPingResponse(); // Handle RTT PING HttpResponseMessage response = await responseTask; Stream responseStream = await response.Content.ReadAsStreamAsync(); @@ -3043,6 +3209,7 @@ public async Task PostAsyncDuplex_ServerCompletesResponseBodyThenResetsStreamWit // Send data to the server, even before we've received response headers. await SendAndReceiveRequestDataAsync(contentBytes, requestStream, connection, streamId); + connection.SetupAutomaticPingResponse(); // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false); @@ -3170,6 +3337,7 @@ public async Task SendAsync_ConcurentSendReceive_Ok(bool shouldWaitForRequestBod // Send response headers await connection.SendResponseHeadersAsync(streamId, endStream: false, responseCode); + connection.SetupAutomaticPingResponse(); // Handle RTT PING HttpResponseMessage response = await responseTask; Assert.Equal(responseCode, response.StatusCode); @@ -3310,6 +3478,7 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async url => Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); (int streamId, HttpRequestData requestData) = await connection.ReadAndParseRequestHeaderAsync().ConfigureAwait(false); await connection.SendResponseHeadersAsync(streamId); + await connection.RespondToPingFrameAsync(); // Handle 1 RTT PING in response to HEADERS await sendAsyncCompleted.Task; await connection.WaitForConnectionShutdownAsync(); }); @@ -3500,6 +3669,8 @@ await Http2LoopbackServer.CreateClientAndServerAsync( // Write the response. await connection.SendDefaultResponseHeadersAsync(streamId); + // Auto-respond to all incoming RTT PINGs + connection.SetupAutomaticPingResponse(); byte[] buffer = new byte[4096]; int totalSent = 0; @@ -3770,6 +3941,7 @@ await Http2LoopbackServer.CreateClientAndServerAsync( pos += HPackEncoder.EncodeHeader("header-that-gos", "bar", HPackFlags.NewIndexed, frameData.AsSpan(pos)); pos += HPackEncoder.EncodeHeader("header-that-stays", "foo", HPackFlags.NewIndexed, frameData.AsSpan(pos)); await con.WriteFrameAsync(new HeadersFrame(frameData.AsMemory(0, pos), FrameFlags.EndHeaders | FrameFlags.EndStream, 0, 0, 0, streamId)); + con.SetupAutomaticPingResponse(); // Second stream, resize the table so that the header-that-gos is removed from table, and add a new header. // 1) resize: header-that-stays: 62 @@ -3822,6 +3994,8 @@ await Http2LoopbackServer.CreateClientAndServerAsync( Debug.Assert(settings.GetHeaderTableSize() >= 4096, "Data for this theory requires a header table size of at least 4096."); + con.SetupAutomaticPingResponse(); + // First stream, create dynamic indexes. int streamId = await con.ReadRequestHeaderAsync(); diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs index 94d59d89b03ffb..a5a0a20a01f255 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Net.Quic; using System.Net.Security; using System.Net.Sockets; using System.Net.Test.Common; diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2FlowControl.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2FlowControl.cs new file mode 100644 index 00000000000000..0d3566df12d908 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2FlowControl.cs @@ -0,0 +1,275 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Quic; +using System.Net.Quic.Implementations; +using System.Net.Security; +using System.Net.Sockets; +using System.Net.Test.Common; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Http.Functional.Tests +{ + [CollectionDefinition(nameof(NonParallelTestCollection), DisableParallelization = true)] + public class NonParallelTestCollection + { + } + + // This test class contains tests which are strongly timing-dependant. + // There are two mitigations avoid flaky behavior on CI: + // - The tests are executed in a non-parallel manner + // - The timing-dependent behavior is pushed to the extremes, making it very unlikely to fail. + [Collection(nameof(NonParallelTestCollection))] + [ConditionalClass(typeof(SocketsHttpHandler_Http2FlowControl_Test), nameof(IsSupported))] + public sealed class SocketsHttpHandler_Http2FlowControl_Test : HttpClientHandlerTestBase + { + public static readonly bool IsSupported = PlatformDetection.SupportsAlpn && PlatformDetection.IsNotBrowser; + + protected override Version UseVersion => HttpVersion20.Value; + + public SocketsHttpHandler_Http2FlowControl_Test(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task InitialHttp2StreamWindowSize_SentInSettingsFrame() + { + const int WindowSize = 123456; + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + using var handler = CreateHttpClientHandler(); + GetUnderlyingSocketsHttpHandler(handler).InitialHttp2StreamWindowSize = WindowSize; + using HttpClient client = CreateHttpClient(handler); + + Task clientTask = client.GetAsync(server.Address); + Http2LoopbackConnection connection = await server.AcceptConnectionAsync().ConfigureAwait(false); + SettingsFrame clientSettingsFrame = await connection.ReadSettingsAsync().ConfigureAwait(false); + SettingsEntry entry = clientSettingsFrame.Entries.First(e => e.SettingId == SettingId.InitialWindowSize); + + Assert.Equal(WindowSize, (int)entry.Value); + } + + [Fact] + public void DisableDynamicWindowScaling_HighBandwidthDelayProduct_WindowRemainsConstant() + { + static async Task RunTest() + { + AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamic2WindowSizing", true); + + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.FromMilliseconds(30), + TimeSpan.Zero, + 2 * 1024 * 1024, + null); + + Assert.Equal(DefaultInitialWindowSize, maxCredit); + } + + RemoteExecutor.Invoke(RunTest).Dispose(); + } + + [Fact] + public void MaxStreamWindowSize_HighBandwidthDelayProduct_WindowStopsAtMaxValue() + { + const int MaxWindow = 654321; + + static async Task RunTest() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.FromMilliseconds(30), + TimeSpan.Zero, + 2 * 1024 * 1024, + null); + + Assert.True(maxCredit <= MaxWindow); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions(); + options.StartInfo.EnvironmentVariables["DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_MAXSTREAMWINDOWSIZE"] = MaxWindow.ToString(); + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [Fact] + public async Task HighBandwidthDelayProduct_ClientStreamReceiveWindowWindowScalesUp() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.FromMilliseconds(30), + TimeSpan.Zero, + 2 * 1024 * 1024, + _output); + + // Expect the client receive window to grow over 1MB: + Assert.True(maxCredit > 1024 * 1024); + } + + [Fact] + public async Task LowBandwidthDelayProduct_ClientStreamReceiveWindowStopsScaling() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.Zero, + TimeSpan.FromMilliseconds(15), + 2 * 1024 * 1024, + _output); + + // Expect the client receive window to stay below 1MB: + Assert.True(maxCredit < 1024 * 1024); + } + + [Fact] + public void StreamWindowScaleThresholdMultiplier_HighValue_WindowScalesSlower() + { + static async Task RunTest() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.FromMilliseconds(30), + TimeSpan.Zero, + 2 * 1024 * 1024, + null); + + Assert.True(maxCredit <= 128 * 1024); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions(); + options.StartInfo.EnvironmentVariables["DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_STREAMWINDOWSCALETHRESHOLDMULTIPLIER"] = "1000"; // Extreme value + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [Fact] + public void StreamWindowScaleThresholdMultiplier_LowValue_WindowScalesFaster() + { + static async Task RunTest() + { + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.Zero, + TimeSpan.FromMilliseconds(15), // Low bandwidth * delay product + 2 * 1024 * 1024, + null); + + Assert.True(maxCredit >= 256 * 1024); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions(); + options.StartInfo.EnvironmentVariables["DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_STREAMWINDOWSCALETHRESHOLDMULTIPLIER"] = "0.00001"; // Extreme value + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + private static async Task TestClientWindowScalingAsync( + TimeSpan networkDelay, + TimeSpan slowBandwidthSimDelay, + int bytesToDownload, + ITestOutputHelper output) + { + TimeSpan timeout = TimeSpan.FromSeconds(30); + + HttpClientHandler handler = CreateHttpClientHandler(HttpVersion20.Value); + + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + using HttpClient client = new HttpClient(handler, true); + client.DefaultRequestVersion = HttpVersion20.Value; + + Task clientTask = client.GetAsync(server.Address); + Http2LoopbackConnection connection = await server.AcceptConnectionAsync().ConfigureAwait(false); + SettingsFrame clientSettingsFrame = await connection.ReadSettingsAsync().ConfigureAwait(false); + + // send server SETTINGS: + await connection.WriteFrameAsync(new SettingsFrame()).ConfigureAwait(false); + + // Initial client SETTINGS also works as a PING. Do not send ACK immediately to avoid low RTT estimation + await Task.Delay(networkDelay); + await connection.WriteFrameAsync(new SettingsFrame(FrameFlags.Ack, new SettingsEntry[0])); + + // Expect SETTINGS ACK from client: + await connection.ExpectSettingsAckAsync(); + + int maxCredit = (int)clientSettingsFrame.Entries.SingleOrDefault(e => e.SettingId == SettingId.InitialWindowSize).Value; + if (maxCredit == default) maxCredit = DefaultInitialWindowSize; + int credit = maxCredit; + + int streamId = await connection.ReadRequestHeaderAsync(); + // Write the response. + await connection.SendDefaultResponseHeadersAsync(streamId); + + using SemaphoreSlim creditReceivedSemaphore = new SemaphoreSlim(0); + using SemaphoreSlim writeSemaphore = new SemaphoreSlim(1); + int remainingBytes = bytesToDownload; + _ = Task.Run(ProcessIncomingFramesAsync); + byte[] buffer = new byte[16384]; + + while (remainingBytes > 0) + { + Wait(slowBandwidthSimDelay); + while (credit == 0) await creditReceivedSemaphore.WaitAsync(timeout); + int bytesToSend = Math.Min(Math.Min(buffer.Length, credit), remainingBytes); + + Memory responseData = buffer.AsMemory(0, bytesToSend); + + int nextRemainingBytes = remainingBytes - bytesToSend; + bool endStream = nextRemainingBytes == 0; + + await writeSemaphore.WaitAsync(); + Interlocked.Add(ref credit, -bytesToSend); + await connection.SendResponseDataAsync(streamId, responseData, endStream); + writeSemaphore.Release(); + output?.WriteLine($"Sent {bytesToSend}, credit reduced to: {credit}"); + + remainingBytes = nextRemainingBytes; + } + + using HttpResponseMessage response = await clientTask; + int dataReceived = (await response.Content.ReadAsByteArrayAsync()).Length; + Assert.Equal(bytesToDownload, dataReceived); + + return maxCredit; + + async Task ProcessIncomingFramesAsync() + { + while (remainingBytes > 0) + { + Frame frame = await connection.ReadFrameAsync(timeout); + + if (frame is PingFrame pingFrame) + { + // Simulate network delay for RTT PING + Wait(networkDelay); + + await writeSemaphore.WaitAsync(); + await connection.SendPingAckAsync(pingFrame.Data); + writeSemaphore.Release(); + } + else if (frame is WindowUpdateFrame windowUpdateFrame) + { + // Ignore connection window: + if (windowUpdateFrame.StreamId != streamId) continue; + + int currentCredit = Interlocked.Add(ref credit, windowUpdateFrame.UpdateSize); + maxCredit = Math.Max(currentCredit, maxCredit); // Detect if client grows the window + creditReceivedSemaphore.Release(); + + output?.WriteLine($"UpdateSize:{windowUpdateFrame.UpdateSize} currentCredit:{currentCredit} MaxCredit: {maxCredit}"); + } + else if (frame is not null) + { + throw new Exception("Unexpected frame: " + frame); + } + } + } + + static void Wait(TimeSpan dt) { if (dt != TimeSpan.Zero) Thread.Sleep(dt); } + } + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 0ac38b257277fa..d827167d00cba0 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -1933,6 +1933,27 @@ public void PlaintextStreamFilter_GetSet_Roundtrips() } } + [Theory] + [InlineData(HttpClientHandlerTestBase.DefaultInitialWindowSize)] + [InlineData(1048576)] + public void InitialHttp2StreamWindowSize_Roundtrips(int value) + { + using var handler = new SocketsHttpHandler(); + handler.InitialHttp2StreamWindowSize = value; + Assert.Equal(value, handler.InitialHttp2StreamWindowSize); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(65534)] + [InlineData(32 * 1024 * 1024)] + public void InitialHttp2StreamWindowSize_InvalidValue_ThrowsArgumentOutOfRangeException(int value) + { + using var handler = new SocketsHttpHandler(); + Assert.Throws(() => handler.InitialHttp2StreamWindowSize = value); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -1972,6 +1993,7 @@ await Assert.ThrowsAnyAsync(() => Assert.True(handler.UseProxy); Assert.Null(handler.ConnectCallback); Assert.Null(handler.PlaintextStreamFilter); + Assert.Equal(HttpClientHandlerTestBase.DefaultInitialWindowSize, handler.InitialHttp2StreamWindowSize); Assert.Throws(expectedExceptionType, () => handler.AllowAutoRedirect = false); Assert.Throws(expectedExceptionType, () => handler.AutomaticDecompression = DecompressionMethods.GZip); @@ -1993,6 +2015,7 @@ await Assert.ThrowsAnyAsync(() => Assert.Throws(expectedExceptionType, () => handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests); Assert.Throws(expectedExceptionType, () => handler.ConnectCallback = (context, token) => default); Assert.Throws(expectedExceptionType, () => handler.PlaintextStreamFilter = (context, token) => default); + Assert.Throws(expectedExceptionType, () => handler.InitialHttp2StreamWindowSize = 128 * 1024); } } } @@ -2145,11 +2168,11 @@ public async Task Http2_MultipleConnectionsEnabled_OpenAndCloseMultipleConnectio { server.AllowMultipleConnections = true; List> sendTasks = new List>(); - Http2LoopbackConnection connection0 = await PrepareConnection(server, client, MaxConcurrentStreams).ConfigureAwait(false); + Http2LoopbackConnection connection0 = await PrepareConnection(server, client, MaxConcurrentStreams, setupAutomaticPingResponse: true).ConfigureAwait(false); AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); - Http2LoopbackConnection connection1 = await PrepareConnection(server, client, MaxConcurrentStreams).ConfigureAwait(false); + Http2LoopbackConnection connection1 = await PrepareConnection(server, client, MaxConcurrentStreams, setupAutomaticPingResponse: true).ConfigureAwait(false); AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); - Http2LoopbackConnection connection2 = await PrepareConnection(server, client, MaxConcurrentStreams).ConfigureAwait(false); + Http2LoopbackConnection connection2 = await PrepareConnection(server, client, MaxConcurrentStreams, setupAutomaticPingResponse: true).ConfigureAwait(false); AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); Task<(int Count, int LastStreamId)>[] handleRequestTasks = new[] { @@ -2170,9 +2193,9 @@ public async Task Http2_MultipleConnectionsEnabled_OpenAndCloseMultipleConnectio //Fill all connection1's stream slots AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); - Http2LoopbackConnection connection3 = await PrepareConnection(server, client, MaxConcurrentStreams).ConfigureAwait(false); + Http2LoopbackConnection connection3 = await PrepareConnection(server, client, MaxConcurrentStreams, setupAutomaticPingResponse: true).ConfigureAwait(false); AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); - Http2LoopbackConnection connection4 = await PrepareConnection(server, client, MaxConcurrentStreams).ConfigureAwait(false); + Http2LoopbackConnection connection4 = await PrepareConnection(server, client, MaxConcurrentStreams, setupAutomaticPingResponse: true).ConfigureAwait(false); AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); Task<(int Count, int LastStreamId)>[] finalHandleTasks = new[] { @@ -2272,10 +2295,14 @@ private async Task VerifySendTasks(IReadOnlyList> send SslOptions = { RemoteCertificateValidationCallback = delegate { return true; } } }; - private async Task PrepareConnection(Http2LoopbackServer server, HttpClient client, uint maxConcurrentStreams, int readTimeout = 3, int expectedWarmUpTasks = 1) + private async Task PrepareConnection(Http2LoopbackServer server, HttpClient client, uint maxConcurrentStreams, int readTimeout = 3, int expectedWarmUpTasks = 1, bool setupAutomaticPingResponse = false) { Task warmUpTask = client.GetAsync(server.Address); Http2LoopbackConnection connection = await GetConnection(server, maxConcurrentStreams, readTimeout).WaitAsync(TestHelper.PassingTestTimeout * 2).ConfigureAwait(false); + if (setupAutomaticPingResponse) + { + connection.SetupAutomaticPingResponse(); // Respond to RTT PING frames + } // Wait until the client confirms MaxConcurrentStreams setting took into effect. Task settingAckReceived = connection.SettingAckWaiter; while (true) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj index 7c9b9d6d157382..d9a87490fc0d62 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj @@ -1,4 +1,4 @@ - + ../../src/Resources/Strings.resx $(DefineConstants);TargetsWindows @@ -22,6 +22,7 @@ + + diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs index dc4f2ef45ab086..2f6db3a348dec1 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs @@ -621,6 +621,7 @@ await GetFactoryForVersion(version).CreateClientAndServerAsync( { http2Server.AllowMultipleConnections = true; connection = await http2Server.EstablishConnectionAsync(new SettingsEntry { SettingId = SettingId.MaxConcurrentStreams, Value = 1 }); + ((Http2LoopbackConnection)connection).SetupAutomaticPingResponse(); // Handle RTT PING } else { diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/_LargeFileBenchmark.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/_LargeFileBenchmark.cs new file mode 100644 index 00000000000000..e0afbf5e4d7426 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/_LargeFileBenchmark.cs @@ -0,0 +1,453 @@ +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Http.Functional.Tests +{ + [CollectionDefinition("NoParallelTests", DisableParallelization = true)] + public class LargeFileBenchmark_ShouldNotBeParallell { } + + [Collection(nameof(LargeFileBenchmark_ShouldNotBeParallell))] + public class LargeFileBenchmark : IDisposable + { +#pragma warning disable xUnit1004 // Test methods should not be skipped + //public const string SkipSwitch = null; + public const string SkipSwitch = "Local benchmark"; + + private readonly ITestOutputHelper _output; + private LogHttpEventListener _listener; + + public LargeFileBenchmark(ITestOutputHelper output) + { + _output = output; + _listener = new LogHttpEventListener(output); + _listener.Filter = m => m.Contains("[FlowControl]"); + } + + public void Dispose() => _listener?.Dispose(); + + private const double LengthMb = 100; + private const int TestRunCount = 10; + + //private const string BenchmarkServer = "10.194.114.94"; + //private const string BenchmarkServer = "169.254.132.170"; // duo1 + private const string BenchmarkServer = "192.168.0.152"; + //private const string BenchmarkServer = "127.0.0.1"; + private const string BenchmarkServerGo = "192.168.0.152:5002"; + // private const string BenchmarkServer = "127.0.0.1:5000"; + + //private static readonly IPAddress LocalAddress = IPAddress.Parse("169.254.59.132"); // duo2 + private static readonly IPAddress LocalAddress = null; + + //private const string ReportDir = @"C:\_dev\r6r\artifacts\bin\System.Net.Http.Functional.Tests\net6.0-windows-Release\TestResults"; + //private const string ReportDir = @"C:\Users\anfirszo\dev\dotnet\6.0\runtime\artifacts\bin\System.Net.Http.Functional.Tests\net6.0-windows-Release\TestResults"; + private const string ReportDir = @"C:\_dev\r6d\artifacts\bin\System.Net.Http.Functional.Tests\net6.0-windows-Debug\TestResults"; + + [Theory(Skip = SkipSwitch)] + [InlineData(BenchmarkServer)] + public Task Download11(string hostName) => TestHandler("SocketsHttpHandler HTTP 1.1 - Run1", hostName, false, LengthMb, details: "http1.1"); + + [Theory(Skip = SkipSwitch)] + [InlineData(BenchmarkServer, 1024)] + [InlineData(BenchmarkServer, 2048)] + [InlineData(BenchmarkServer, 4096)] + [InlineData(BenchmarkServer, 8192)] + [InlineData(BenchmarkServer, 16384)] + public Task Download20_SpecificWindow_MegaBytes(string hostName, int initialWindowKbytes) => Download20_SpecificWindow(hostName, initialWindowKbytes); + + [Theory(Skip = SkipSwitch)] + [InlineData(BenchmarkServer, 64)] + [InlineData(BenchmarkServer, 128)] + [InlineData(BenchmarkServer, 256)] + [InlineData(BenchmarkServer, 512)] + public Task Download20_SpecificWindow_KiloBytes(string hostName, int initialWindowKbytes) => Download20_SpecificWindow(hostName, initialWindowKbytes); + + private Task Download20_SpecificWindow(string hostName, int initialWindowKbytes) + { + SocketsHttpHandler handler = new SocketsHttpHandler() + { + InitialHttp2StreamWindowSize = initialWindowKbytes * 1024 + }; + ChangeSettingValue(handler, "_disableDynamicHttp2WindowSizing", true); + string details = $"SpecificWindow({initialWindowKbytes})"; + return TestHandler($"SocketsHttpHandler HTTP 2.0 - W: {initialWindowKbytes} KB", hostName, true, LengthMb, handler, details); + } + + + public static TheoryData Download20_Data = new TheoryData + { + { BenchmarkServer, 8, 1 }, + { BenchmarkServer, 8, 2 }, + { BenchmarkServer, 8, 4 }, + { BenchmarkServer, 8, 8 }, + { BenchmarkServer, 4, 1 }, + { BenchmarkServer, 4, 2 }, + { BenchmarkServer, 4, 4 }, + }; + + public static TheoryData Download20_Data8 = new TheoryData + { + { BenchmarkServer, 8, 1 }, + { BenchmarkServer, 8, 2 }, + { BenchmarkServer, 8, 4 }, + { BenchmarkServer, 8, 8 }, + { BenchmarkServer, 8, 16 }, + }; + + + public static TheoryData Download20_Data4 = new TheoryData + { + { BenchmarkServer, 4, 1 }, + { BenchmarkServer, 4, 2 }, + { BenchmarkServer, 4, 4 }, + { BenchmarkServer, 4, 8 }, + }; + + [Theory(Skip = SkipSwitch)] + [MemberData(nameof(Download20_Data8))] + public Task Download20_Dynamic_SingleStream_8(string hostName, int ratio, int correction) => Download20_Dynamic_SingleStream(hostName, ratio, correction); + + [Theory(Skip = SkipSwitch)] + [MemberData(nameof(Download20_Data4))] + public Task Download20_Dynamic_SingleStream_4(string hostName, int ratio, int correction) => Download20_Dynamic_SingleStream(hostName, ratio, correction); + + [Fact(Skip = SkipSwitch)] + public Task Download20_Dynamic_Test() + { + _listener.Enabled = true; + return Download20_Dynamic_SingleStream(BenchmarkServer, 8, 8, true); + } + + private async Task Download20_Dynamic_SingleStream(string hostName, int ratio, int correction, bool keepFilter = false) + { + _listener.Enabled = true; + if (!keepFilter) + { + _listener.Filter = m => m.Contains("[FlowControl]") && m.Contains("Updated"); + } + + var handler = new SocketsHttpHandler(); + + double multiplier = (double)correction / ratio; + ChangeSettingValue(handler, "_http2StreamWindowScaleThresholdMultiplier", multiplier); + + string details = $"Dynamic_R({ratio})_C({correction})"; + await TestHandler($"SocketsHttpHandler HTTP 2.0 Dynamic single stream | host:{hostName} multiplier: {multiplier}", + hostName, true, LengthMb, handler, details); + } + + [Theory(Skip = SkipSwitch)] + [InlineData(BenchmarkServer, 1)] + [InlineData(BenchmarkServer, 10)] + [InlineData(BenchmarkServer, 50)] + public async Task Download20_Dynamic_MultiStream(string hostName, int streamCount) + { + _listener.Enabled = true; + _listener.Filter = m => m.Contains("[FlowControl]") && m.Contains("Updated"); + string info = $"SocketsHttpHandler HTTP 2.0 Dynamic {streamCount} concurrent streams R=8 D=8"; + + var handler = new SocketsHttpHandler(); + + string details = $"SC({streamCount})"; + + await TestHandler(info, hostName, true, LengthMb, handler, details, streamCount); + } + + private async Task TestHandler(string info, string hostName, bool http2, double lengthMb, SocketsHttpHandler handler = null, string details = "", int streamCount = -1) + { + handler ??= new SocketsHttpHandler(); + + if (LocalAddress != null) handler.ConnectCallback = CustomConnect; + + string reportFileName = CreateOutputFile(details); + _output.WriteLine("REPORT: " + reportFileName); + using StreamWriter report = new StreamWriter(reportFileName); + + _output.WriteLine($"############ Warmup Run ############"); + await TestHandlerCore(info, hostName, http2, lengthMb, handler, null); + + for (int i = 0; i < TestRunCount; i++) + { + _output.WriteLine($"############ run {i} ############"); + if (streamCount > 0) + { + await TestHandlerCoreMultiStream(info, hostName, http2, lengthMb, handler, report, streamCount); + } + else + { + await TestHandlerCore(info, hostName, http2, lengthMb, handler, report); + } + await report.FlushAsync(); + } + handler.Dispose(); + } + + private static string CreateOutputFile(string details) + { + if (!Directory.Exists(ReportDir)) Directory.CreateDirectory(ReportDir); + return Path.Combine(ReportDir, $"report_{Environment.TickCount64}_{details}.csv"); + } + + private async Task TestHandlerCore(string info, string hostName, bool http2, double lengthMb, SocketsHttpHandler handler, StreamWriter report) + { + _listener.Log2.Clear(); + using var client = new HttpClient(CopyHandler(handler), true); + client.Timeout = TimeSpan.FromMinutes(3); + using var message = GenerateRequestMessage(hostName, http2, lengthMb); + _output.WriteLine($"{info} / {lengthMb} MB from {message.RequestUri}"); + Stopwatch sw = Stopwatch.StartNew(); + using var response = await client.SendAsync(message); + + double elapsedSec = sw.ElapsedMilliseconds * 0.001; + elapsedSec = Math.Round(elapsedSec, 3); + _output.WriteLine($"{info}: completed in {elapsedSec} sec"); + + if (report != null) + { + report.Write(elapsedSec); + double? window = GetStreamWindowSizeInMegabytes(); + if (window.HasValue) report.Write($", {window}"); + double? rtt = GetRtt(); + if (rtt.HasValue) report.Write($", {rtt}"); + report.WriteLine(); + } + } + + private async Task TestHandlerCoreMultiStream(string info, string hostName, bool http2, double lengthMb, SocketsHttpHandler handler, StreamWriter report, int streamCount) + { + _listener.Log2.Clear(); + using var client = new HttpClient(CopyHandler(handler), true); + client.Timeout = TimeSpan.FromMinutes(3); + + async Task SendRequestAsync(int i) + { + using var message = GenerateRequestMessage(hostName, http2, lengthMb); + _output.WriteLine($"[STREAM {i}] {info} / {lengthMb} MB from {message.RequestUri}"); + Stopwatch sw = Stopwatch.StartNew(); + + using var response = await client.SendAsync(message).ConfigureAwait(false); + double elapsedSec = sw.ElapsedMilliseconds * 0.001; + elapsedSec = Math.Round(elapsedSec, 3); + _output.WriteLine($"[STREAM {i}] {info}: completed in {elapsedSec} sec"); + return elapsedSec; + } + + List> allTasks = new List>(); + + for (int i = 0; i < streamCount; i++) + { + Task task = SendRequestAsync(i); + allTasks.Add(task); + } + + await Task.WhenAll(allTasks); + + if (report != null) + { + double averageTime = allTasks.Select(t => t.Result).Average(); + averageTime = Math.Round(averageTime, 3); + report.Write($"{averageTime}"); + double? window = GetStreamWindowSizeInMegabytes(); + if (window.HasValue) report.Write($", {window}"); + double? rtt = GetRtt(); + if (rtt.HasValue) report.Write($", {rtt}"); + report.WriteLine(); + } + } + + private double? GetStreamWindowSizeInMegabytes() + { + const string Prefix = "Updated Stream Window. StreamWindowSize: "; + string log = _listener.Log2.ToString(); + + int idx = log.LastIndexOf(Prefix); + if (idx < 0) return null; + ReadOnlySpan text = log.AsSpan().Slice(idx + Prefix.Length); + text = text.Slice(0, text.IndexOf(',')); + + double size = int.Parse(text); + double sizeMb = size / 1024 / 1024; + return Math.Round(sizeMb, 3); + } + + private double? GetRtt() + { + const string Prefix = "Updated MinRtt: "; + string log = _listener.Log2.ToString(); + + int idx = log.LastIndexOf(Prefix); + if (idx < 0) return null; + + ReadOnlySpan text = log.AsSpan().Slice(idx + Prefix.Length); + text = text.Slice(0, text.IndexOf(' ')); + + double rtt = double.Parse(text); + return Math.Round(rtt, 3); + } + + private static SocketsHttpHandler CopyHandler(SocketsHttpHandler h) + { + var clone = new SocketsHttpHandler(); + + FieldInfo fieldInfo = h.GetType().GetField("_settings", BindingFlags.NonPublic | BindingFlags.Instance); + object settings = fieldInfo.GetValue(h); + MethodInfo m = settings.GetType().GetMethod("CloneAndNormalize", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + fieldInfo.SetValue(clone, m.Invoke(settings, Array.Empty())); + return clone; + } + + private static async ValueTask CustomConnect(SocketsHttpConnectionContext ctx, CancellationToken cancellationToken) + { + Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + socket.Bind(new IPEndPoint(LocalAddress, 0)); + + try + { + await socket.ConnectAsync(ctx.DnsEndPoint, cancellationToken).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + + static HttpRequestMessage GenerateRequestMessage(string hostName, bool http2, double lengthMb = 5) + { + int port = http2 ? 5001 : 5000; + int sep = hostName.IndexOf(':'); + if (sep > 0) + { + string portStr = hostName.Substring(sep + 1, hostName.Length - sep - 1); + int.TryParse(portStr, out port); + hostName = hostName.Substring(0, sep); + } + + string url = $"http://{hostName}:{port}?lengthMb={lengthMb}"; + var msg = new HttpRequestMessage(HttpMethod.Get, url) + { + Version = new Version(1, 1) + }; + + if (http2) + { + msg.Version = new Version(2, 0); + msg.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + } + + return msg; + } + + + private static object GetInnerSettings(SocketsHttpHandler handler) + { + FieldInfo fieldInfo = handler.GetType().GetField("_settings", BindingFlags.NonPublic | BindingFlags.Instance); + return fieldInfo.GetValue(handler); + } + + private static void ChangeSettingValue(SocketsHttpHandler handler, string name, object value) + { + object settings = GetInnerSettings(handler); + FieldInfo field = settings.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); + field.SetValue(settings, value); + } + } + + public sealed class LogHttpEventListener : EventListener + { + private Channel _messagesChannel = Channel.CreateUnbounded(); + private Task _processMessages; + private CancellationTokenSource _stopProcessing; + private ITestOutputHelper _log; + + public StringBuilder Log2 { get; } + + public LogHttpEventListener(ITestOutputHelper log) + { + _log = log; + _messagesChannel = Channel.CreateUnbounded(); + _processMessages = ProcessMessagesAsync(); + _stopProcessing = new CancellationTokenSource(); + Log2 = new StringBuilder(1024 * 1024); + } + + public bool Enabled { get; set; } + public Predicate Filter { get; set; } = _ => true; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (eventSource.Name == "Private.InternalDiagnostics.System.Net.Http") + { + EnableEvents(eventSource, EventLevel.LogAlways); + } + } + + private async Task ProcessMessagesAsync() + { + await Task.Yield(); + + try + { + await foreach (string message in _messagesChannel.Reader.ReadAllAsync(_stopProcessing.Token)) + { + if (Filter(message)) + { + _log.WriteLine(message); + Log2.AppendLine(message); + } + } + } + catch (OperationCanceledException) + { + return; + } + } + + public ValueTask WriteAsync(string message) => _messagesChannel.Writer.WriteAsync(message); + + protected override async void OnEventWritten(EventWrittenEventArgs eventData) + { + if (!Enabled) return; + + var sb = new StringBuilder().Append($"{eventData.TimeStamp:HH:mm:ss.fffffff}[{eventData.EventName}] "); + for (int i = 0; i < eventData.Payload?.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + sb.Append(eventData.PayloadNames?[i]).Append(": ").Append(eventData.Payload[i]); + } + await _messagesChannel.Writer.WriteAsync(sb.ToString()); + } + + public override void Dispose() + { + base.Dispose(); + var timeout = TimeSpan.FromSeconds(2); + + if (!_processMessages.Wait(timeout)) + { + _stopProcessing.Cancel(); + _processMessages.Wait(timeout); + } + } + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/_RunAll.ps1 b/src/libraries/System.Net.Http/tests/FunctionalTests/_RunAll.ps1 new file mode 100644 index 00000000000000..b1e44772402d4c --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/_RunAll.ps1 @@ -0,0 +1,7 @@ +dotnet test --no-build -c Release --filter FullyQualifiedName~Download11 +dotnet test --no-build -c Release --filter FullyQualifiedName~Download20_SpecificWindow_KiloBytes +dotnet test --no-build -c Release --filter FullyQualifiedName~Download20_SpecificWindow_MegaBytes +dotnet test --no-build -c Release --filter FullyQualifiedName~Download20_StaticRtt_8 +dotnet test --no-build -c Release --filter FullyQualifiedName~Download20_StaticRtt_4 +dotnet test --no-build -c Release --filter FullyQualifiedName~Download20_Dynamic_SingleStream_8 +dotnet test --no-build -c Release --filter FullyQualifiedName~Download20_Dynamic_SingleStream_4 diff --git a/src/libraries/System.Net.Http/tests/UnitTests/RuntimeSettingParserTest.cs b/src/libraries/System.Net.Http/tests/UnitTests/RuntimeSettingParserTest.cs new file mode 100644 index 00000000000000..d1aba5fde7c509 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/UnitTests/RuntimeSettingParserTest.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.RemoteExecutor; +using Xunit; + +namespace System.Net.Http.Tests +{ + public class RuntimeSettingParserTest + { + public static bool SupportsRemoteExecutor = RemoteExecutor.IsSupported; + + [ConditionalTheory(nameof(SupportsRemoteExecutor))] + [InlineData(false)] + [InlineData(true)] + public void QueryRuntimeSettingSwitch_WhenNotSet_DefaultIsUsed(bool defaultValue) + { + static void RunTest(string defaultValueStr) + { + bool expected = bool.Parse(defaultValueStr); + bool actual = RuntimeSettingParser.QueryRuntimeSettingSwitch("Foo.Bar", "FOO_BAR", expected); + Assert.Equal(expected, actual); + } + + RemoteExecutor.Invoke(RunTest, defaultValue.ToString()).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void QueryRuntimeSettingSwitch_AppContextHasPriority() + { + static void RunTest() + { + AppContext.SetSwitch("Foo.Bar", false); + bool actual = RuntimeSettingParser.QueryRuntimeSettingSwitch("Foo.Bar", "FOO_BAR", true); + Assert.False(actual); + } + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "true"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void QueryRuntimeSettingSwitch_EnvironmentVariable() + { + static void RunTest() + { + bool actual = RuntimeSettingParser.QueryRuntimeSettingSwitch("Foo.Bar", "FOO_BAR", true); + Assert.False(actual); + } + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "false"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void QueryRuntimeSettingSwitch_InvalidValue_FallbackToDefault() + { + static void RunTest() + { + bool actual = RuntimeSettingParser.QueryRuntimeSettingSwitch("Foo.Bar", "FOO_BAR", true); + Assert.True(actual); + } + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "cheese"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void ParseInt32EnvironmentVariableValue_WhenNotSet_DefaultIsUsed() + { + static void RunTest() + { + int actual = RuntimeSettingParser.ParseInt32EnvironmentVariableValue("FOO_BAR", -42); + Assert.Equal(-42, actual); + } + RemoteExecutor.Invoke(RunTest).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void ParseInt32EnvironmentVariableValue_ValidValue() + { + static void RunTest() + { + int actual = RuntimeSettingParser.ParseInt32EnvironmentVariableValue("FOO_BAR", -42); + Assert.Equal(84, actual); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "84"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void ParseInt32EnvironmentVariableValue_InvalidValue_FallbackToDefault() + { + static void RunTest() + { + int actual = RuntimeSettingParser.ParseInt32EnvironmentVariableValue("FOO_BAR", -42); + Assert.Equal(-42, actual); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "-~4!"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void ParseDoubleEnvironmentVariableValue_WhenNotSet_DefaultIsUsed() + { + static void RunTest() + { + double actual = RuntimeSettingParser.ParseDoubleEnvironmentVariableValue("FOO_BAR", -0.42); + Assert.Equal(-0.42, actual); + } + RemoteExecutor.Invoke(RunTest).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void ParseDoubleEnvironmentVariableValue_ValidValue() + { + static void RunTest() + { + double actual = RuntimeSettingParser.ParseDoubleEnvironmentVariableValue("FOO_BAR", -0.42); + Assert.Equal(0.84, actual); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "0.84"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + + [ConditionalFact(nameof(SupportsRemoteExecutor))] + public void ParseDoubleEnvironmentVariableValue_InvalidValue_FallbackToDefault() + { + static void RunTest() + { + double actual = RuntimeSettingParser.ParseDoubleEnvironmentVariableValue("FOO_BAR", -0.42); + Assert.Equal(-0.42, actual); + } + + RemoteInvokeOptions options = new RemoteInvokeOptions() + { + StartInfo = new Diagnostics.ProcessStartInfo() + }; + options.StartInfo.EnvironmentVariables["FOO_BAR"] = "-~4!"; + + RemoteExecutor.Invoke(RunTest, options).Dispose(); + } + } +} diff --git a/src/libraries/System.Net.Http/tests/UnitTests/System.Net.Http.Unit.Tests.csproj b/src/libraries/System.Net.Http/tests/UnitTests/System.Net.Http.Unit.Tests.csproj index 186f1cbd888081..47638d59fbef35 100644 --- a/src/libraries/System.Net.Http/tests/UnitTests/System.Net.Http.Unit.Tests.csproj +++ b/src/libraries/System.Net.Http/tests/UnitTests/System.Net.Http.Unit.Tests.csproj @@ -1,4 +1,4 @@ - + ../../src/Resources/Strings.resx true @@ -236,6 +236,8 @@ Link="ProductionCode\System\Net\Http\StreamToStreamCopy.cs" /> + +