diff --git a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs index 97787e658497c..4840ad7ab4459 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs @@ -25,8 +25,10 @@ public class Http2LoopbackConnection : GenericLoopbackConnection private TaskCompletionSource _ignoredSettingsAckPromise; private bool _ignoreWindowUpdates; private TaskCompletionSource _expectPingFrame; + private bool _transparentPingResponse; private readonly TimeSpan _timeout; private int _lastStreamId; + private bool _expectClientDisconnect; private readonly byte[] _prefix = new byte[24]; public string PrefixString => Encoding.UTF8.GetString(_prefix, 0, _prefix.Length); @@ -34,11 +36,12 @@ public class Http2LoopbackConnection : GenericLoopbackConnection public Stream Stream => _connectionStream; public Task SettingAckWaiter => _ignoredSettingsAckPromise?.Task; - private Http2LoopbackConnection(SocketWrapper socket, Stream stream, TimeSpan timeout) + private Http2LoopbackConnection(SocketWrapper socket, Stream stream, TimeSpan timeout, bool transparentPingResponse) { _connectionSocket = socket; _connectionStream = stream; _timeout = timeout; + _transparentPingResponse = transparentPingResponse; } public static Task CreateAsync(SocketWrapper socket, Stream stream, Http2Options httpOptions) @@ -76,7 +79,7 @@ public static async Task CreateAsync(SocketWrapper sock stream = sslStream; } - var con = new Http2LoopbackConnection(socket, stream, timeout); + var con = new Http2LoopbackConnection(socket, stream, timeout, httpOptions.EnableTransparentPingResponse); await con.ReadPrefixAsync().ConfigureAwait(false); return con; @@ -121,11 +124,11 @@ public async Task SendConnectionPrefaceAsync() clientSettings = await ReadFrameAsync(_timeout).ConfigureAwait(false); } - public async Task WriteFrameAsync(Frame frame) + public async Task WriteFrameAsync(Frame frame, CancellationToken cancellationToken = default) { byte[] writeBuffer = new byte[Frame.FrameHeaderLength + frame.Length]; frame.WriteTo(writeBuffer); - await _connectionStream.WriteAsync(writeBuffer, 0, writeBuffer.Length).ConfigureAwait(false); + await _connectionStream.WriteAsync(writeBuffer, 0, writeBuffer.Length, cancellationToken).ConfigureAwait(false); } // Read until the buffer is full @@ -159,7 +162,7 @@ public async Task ReadFrameAsync(TimeSpan timeout) return await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false); } - private async Task ReadFrameAsync(CancellationToken cancellationToken) + public async Task ReadFrameAsync(CancellationToken cancellationToken) { // First read the frame headers, which should tell us how long the rest of the frame is. byte[] headerBytes = new byte[Frame.FrameHeaderLength]; @@ -198,11 +201,12 @@ private async Task ReadFrameAsync(CancellationToken cancellationToken) return await ReadFrameAsync(cancellationToken).ConfigureAwait(false); } - if (_expectPingFrame != null && header.Type == FrameType.Ping) + if (header.Type == FrameType.Ping && (_expectPingFrame != null || _transparentPingResponse)) { - _expectPingFrame.SetResult(PingFrame.ReadFrom(header, data)); - _expectPingFrame = null; - return await ReadFrameAsync(cancellationToken).ConfigureAwait(false); + PingFrame pingFrame = PingFrame.ReadFrom(header, data); + + bool processed = await TryProcessExpectedPingFrameAsync(pingFrame); + return processed ? await ReadFrameAsync(cancellationToken).ConfigureAwait(false) : pingFrame; } // Construct the correct frame type and return it. @@ -224,11 +228,37 @@ 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 TryProcessExpectedPingFrameAsync(PingFrame pingFrame) + { + if (_expectPingFrame != null) + { + _expectPingFrame.SetResult(pingFrame); + _expectPingFrame = null; + return true; + } + else if (_transparentPingResponse && !pingFrame.AckFlag) + { + try + { + await SendPingAckAsync(pingFrame.Data); + } + catch (IOException ex) when (_expectClientDisconnect && ex.InnerException is SocketException se && se.SocketErrorCode == SocketError.Shutdown) + { + // couldn't send PING ACK, because client is already disconnected + _transparentPingResponse = false; + } + return true; + } + return false; + } + // Reset and return underlying networking objects. public (SocketWrapper, Stream) ResetNetwork() { @@ -263,11 +293,18 @@ public void IgnoreWindowUpdates() _ignoreWindowUpdates = true; } - // Set up loopback server to expect PING frames among other frames. + // Set up loopback server to expect a 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. + // Does not work when Http2Options.EnableTransparentPingResponse == true public Task ExpectPingFrameAsync() { + if (_transparentPingResponse) + { + throw new InvalidOperationException( + $"{nameof(Http2LoopbackConnection)}.{nameof(ExpectPingFrameAsync)} can not be used when transparent PING response is enabled."); + } + _expectPingFrame ??= new TaskCompletionSource(); return _expectPingFrame.Task; } @@ -297,6 +334,7 @@ public async Task WaitForClientDisconnectAsync(bool ignoreUnexpectedFrames = fal { IgnoreWindowUpdates(); + _expectClientDisconnect = true; Frame frame = await ReadFrameAsync(_timeout).ConfigureAwait(false); if (frame != null) { @@ -720,14 +758,18 @@ 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); } + public Task ReadPingAsync() => ReadPingAsync(_timeout); + public async Task ReadPingAsync(TimeSpan timeout) { _expectPingFrame?.TrySetCanceled(); @@ -743,7 +785,7 @@ public async Task ReadPingAsync(TimeSpan timeout) return Assert.IsAssignableFrom(frame); } - public async Task SendPingAckAsync(long payload) + public async Task SendPingAckAsync(long payload, CancellationToken cancellationToken = default) { PingFrame pingAck = new PingFrame(payload, FrameFlags.Ack, 0); await WriteFrameAsync(pingAck).ConfigureAwait(false); diff --git a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackServer.cs b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackServer.cs index 45df14c0380ea..edbefefb6ac1f 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackServer.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackServer.cs @@ -179,6 +179,8 @@ public class Http2Options : GenericLoopbackOptions { public bool ClientCertificateRequired { get; set; } + public bool EnableTransparentPingResponse { get; set; } = true; + public Http2Options() { SslProtocols = SslProtocols.Tls12; diff --git a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTestBase.cs b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTestBase.cs index 26a084b969f20..7d56c4ce48838 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 ea669da03d357..875a471418940 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -353,6 +353,7 @@ protected override void SerializeToStream(System.IO.Stream stream, System.Net.Tr public sealed partial class SocketsHttpHandler : System.Net.Http.HttpMessageHandler { public SocketsHttpHandler() { } + public int InitialHttp2StreamWindowSize { get { throw null; } set { } } [System.Runtime.Versioning.UnsupportedOSPlatformGuardAttribute("browser")] public static bool IsSupported { get { throw null; } } public bool AllowAutoRedirect { 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 0f725d661b20d..35ce6f88feb33 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 0fee14684e2ea..613416b90aa78 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -62,6 +62,7 @@ + @@ -160,6 +161,7 @@ + @@ -495,6 +497,7 @@ + diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs index 4a29ff1ea2bea..4805010002b22 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs @@ -135,6 +135,12 @@ public TimeSpan Expect100ContinueTimeout set => throw new PlatformNotSupportedException(); } + public int InitialHttp2StreamWindowSize + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } + public TimeSpan KeepAlivePingDelay { get => throw new PlatformNotSupportedException(); @@ -147,7 +153,6 @@ public TimeSpan KeepAlivePingTimeout set => throw new PlatformNotSupportedException(); } - public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get => throw new PlatformNotSupportedException(); 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 4e4d730c4c188..f3de5028d911c 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 @@ -15,6 +15,9 @@ namespace System.Net.Http /// internal sealed class DiagnosticsHandler : DelegatingHandler { + private static readonly DiagnosticListener s_diagnosticListener = + new DiagnosticListener(DiagnosticsHandlerLoggingStrings.DiagnosticListenerName); + /// /// DiagnosticHandler constructor /// @@ -28,13 +31,10 @@ internal static bool IsEnabled() { // check if there is a parent Activity (and propagation is not suppressed) // or if someone listens to HttpHandlerDiagnosticListener - return IsGloballyEnabled() && (Activity.Current != null || Settings.s_diagnosticListener.IsEnabled()); + return IsGloballyEnabled && (Activity.Current != null || s_diagnosticListener.IsEnabled()); } - internal static bool IsGloballyEnabled() - { - return Settings.s_activityPropagationEnabled; - } + internal static bool IsGloballyEnabled => GlobalHttpSettings.DiagnosticsHandler.EnableActivityPropagation; // SendAsyncCore returns already completed ValueTask for when async: false is passed. // Internally, it calls the synchronous Send method of the base class. @@ -59,7 +59,7 @@ private async ValueTask SendAsyncCore(HttpRequestMessage re } Activity? activity = null; - DiagnosticListener diagnosticListener = Settings.s_diagnosticListener; + DiagnosticListener diagnosticListener = s_diagnosticListener; // if there is no listener, but propagation is enabled (with previous IsEnabled() check) // do not write any events just start/stop Activity and propagate Ids @@ -269,37 +269,6 @@ internal ResponseData(HttpResponseMessage? response, Guid loggingRequestId, long public override string ToString() => $"{{ {nameof(Response)} = {Response}, {nameof(LoggingRequestId)} = {LoggingRequestId}, {nameof(Timestamp)} = {Timestamp}, {nameof(RequestTaskStatus)} = {RequestTaskStatus} }}"; } - private static class Settings - { - private const string EnableActivityPropagationEnvironmentVariableSettingName = "DOTNET_SYSTEM_NET_HTTP_ENABLEACTIVITYPROPAGATION"; - private const string EnableActivityPropagationAppCtxSettingName = "System.Net.Http.EnableActivityPropagation"; - - 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; - } - - public static readonly DiagnosticListener s_diagnosticListener = - new DiagnosticListener(DiagnosticsHandlerLoggingStrings.DiagnosticListenerName); - } - private static void InjectHeaders(Activity currentActivity, HttpRequestMessage request) { if (currentActivity.IdFormat == ActivityIdFormat.W3C) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs new file mode 100644 index 0000000000000..7382f4ca0da13 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Net.Http +{ + /// + /// Exposes process-wide settings for handlers. + /// + internal static class GlobalHttpSettings + { + internal static class DiagnosticsHandler + { + public static bool EnableActivityPropagation { get; } = RuntimeSettingParser.QueryRuntimeSettingSwitch( + "System.Net.Http.EnableActivityPropagation", + "DOTNET_SYSTEM_NET_HTTP_ENABLEACTIVITYPROPAGATION", + true); + } + +#if !BROWSER + internal static class SocketsHttpHandler + { + // 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. + public static bool AllowHttp2 { get; } = 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. + public static bool AllowDraftHttp3 { get; } = 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. + public static bool DisableDynamicHttp2WindowSizing { get; } = RuntimeSettingParser.QueryRuntimeSettingSwitch( + "System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing", + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2FLOWCONTROL_DISABLEDYNAMICWINDOWSIZING", + false); + + // The maximum size of the HTTP/2 stream receive window. Defaults to 16 MB. + public static int MaxHttp2StreamWindowSize { get; } = GetMaxHttp2StreamWindowSize(); + + // Defaults to 1.0. Higher values result in shorter window, but slower downloads. + public static double Http2StreamWindowScaleThresholdMultiplier { get; } = GetHttp2StreamWindowScaleThresholdMultiplier(); + + public const int DefaultHttp2MaxStreamWindowSize = 16 * 1024 * 1024; + public const double DefaultHttp2StreamWindowScaleThresholdMultiplier = 1.0; + + private static int GetMaxHttp2StreamWindowSize() + { + int value = RuntimeSettingParser.ParseInt32EnvironmentVariableValue( + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_MAXSTREAMWINDOWSIZE", + DefaultHttp2MaxStreamWindowSize); + + // Disallow small values: + if (value < HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize) + { + value = HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize; + } + return value; + } + + private static double GetHttp2StreamWindowScaleThresholdMultiplier() + { + double value = RuntimeSettingParser.ParseDoubleEnvironmentVariableValue( + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_FLOWCONTROL_STREAMWINDOWSCALETHRESHOLDMULTIPLIER", + DefaultHttp2StreamWindowScaleThresholdMultiplier); + + // Disallow negative values: + if (value < 0) + { + value = DefaultHttp2StreamWindowScaleThresholdMultiplier; + } + return value; + } + } +#endif + } +} diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs index 2e3289643cfbe..89cb508d220e4 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.cs @@ -28,7 +28,7 @@ public partial class HttpClientHandler : HttpMessageHandler public HttpClientHandler() { _underlyingHandler = new HttpHandlerType(); - if (DiagnosticsHandler.IsGloballyEnabled()) + if (DiagnosticsHandler.IsGloballyEnabled) { _diagnosticsHandler = new DiagnosticsHandler(_underlyingHandler); } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs index 21cc00a744446..9e2b938c424f2 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs @@ -13,5 +13,11 @@ internal static partial class HttpHandlerDefaults public static readonly TimeSpan DefaultKeepAlivePingTimeout = TimeSpan.FromSeconds(20); public static readonly TimeSpan DefaultKeepAlivePingDelay = Timeout.InfiniteTimeSpan; public const HttpKeepAlivePingPolicy DefaultKeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always; + + // This is the default value for SocketsHttpHandler.InitialHttp2StreamWindowSize, + // which defines the value we communicate in stream SETTINGS frames. + // Should not be confused with Http2Connection.DefaultInitialWindowSize, which defines the RFC default. + // Unlike that value, DefaultInitialHttp2StreamWindowSize might be changed in the future. + public const int DefaultInitialHttp2StreamWindowSize = 65535; } } 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 c60370d6d8052..61ddafcff9659 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,21 +80,24 @@ 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 + // Unlike HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize, this value should never be changed. + 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; - // We hold off on sending WINDOW_UPDATE until we hit thi minimum threshold. + // We hold off on sending WINDOW_UPDATE until we hit the minimum threshold. // This value is somewhat arbitrary; the intent is to ensure it is much smaller than // the window size itself, or we risk stalling the server because it runs out of window space. // 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 ConnectionWindowUpdateRatio = 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 +135,12 @@ public Http2Connection(HttpConnectionPool pool, Stream stream) _connectionWindow = new CreditManager(this, nameof(_connectionWindow), DefaultInitialWindowSize); _concurrentStreams = new CreditManager(this, nameof(_concurrentStreams), InitialMaxConcurrentStreams); + _rttEstimator = RttEstimator.Create(); + _writeChannel = Channel.CreateUnbounded(s_channelOptions); _nextStream = 1; - _initialWindowSize = DefaultInitialWindowSize; + _initialServerStreamWindowSize = DefaultInitialWindowSize; _maxConcurrentStreams = InitialMaxConcurrentStreams; _pendingWindowUpdate = 0; @@ -178,21 +184,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 +454,7 @@ private async ValueTask ProcessHeadersFrame(FrameHeader frameHeader) if (http2Stream != null) { http2Stream.OnHeadersStart(); + _rttEstimator.OnDataOrHeadersReceived(this); headersHandler = http2Stream; } else @@ -457,6 +473,7 @@ private async ValueTask ProcessHeadersFrame(FrameHeader frameHeader) while (!frameHeader.EndHeadersFlag) { frameHeader = await ReadFrameAsync().ConfigureAwait(false); + if (frameHeader.Type != FrameType.Continuation || frameHeader.StreamId != streamId) { @@ -570,6 +587,11 @@ private void ProcessDataFrame(FrameHeader frameHeader) bool endStream = frameHeader.EndStreamFlag; http2Stream.OnResponseData(frameData, endStream); + + if (!endStream && frameData.Length > 0) + { + _rttEstimator.OnDataOrHeadersReceived(this); + } } if (frameData.Length > 0) @@ -604,6 +626,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(this); } else { @@ -691,8 +714,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 +1418,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 +1689,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 +2001,20 @@ private void RefreshPingTimestamp() private void ProcessPingAck(long payload) { - if (_keepAliveState != KeepAliveState.PingSent) - ThrowProtocolError(); - if (Interlocked.Read(ref _keepAlivePingPayload) != payload) - ThrowProtocolError(); - _keepAliveState = KeepAliveState.None; + // RttEstimator is using negative values in PING payloads. + // _keepAlivePingPayload is always non-negative. + if (payload < 0) // RTT ping + { + _rttEstimator.OnPingAckReceived(payload, this); + } + 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 4297c50fc0be5..1f9e98128721d 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,8 @@ public Http2Stream(HttpRequestMessage request, Http2Connection connection) _responseBuffer = new MultiArrayBuffer(InitialStreamBufferSize); - _pendingWindowUpdate = 0; + _windowManager = new Http2StreamWindowManager(connection, this); + _headerBudgetRemaining = connection._pool.Settings._maxResponseHeadersLength * 1024; if (_request.Content == null) @@ -149,6 +145,10 @@ public void Initialize(int streamId, int initialWindowSize) public bool SendRequestFinished => _requestCompletionState != StreamCompletionState.InProgress; + public bool ExpectResponseData => _responseProtocolState == ResponseProtocolState.ExpectingData; + + public Http2Connection Connection => _connection; + public HttpResponseMessage GetAndClearResponse() { // Once SendAsync completes, the Http2Stream should no longer hold onto the response message. @@ -781,6 +781,10 @@ public void OnHeadersComplete(bool endStream) Debug.Assert(_requestCompletionState != StreamCompletionState.Failed); } + if (_responseProtocolState == ResponseProtocolState.ExpectingData) + { + _windowManager.Start(); + } signalWaiter = _hasWaiter; _hasWaiter = false; } @@ -811,7 +815,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); @@ -1019,30 +1023,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); @@ -1095,7 +1075,7 @@ public int ReadData(Span buffer, HttpResponseMessage responseMessage) if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead, this); } else { @@ -1124,7 +1104,7 @@ public async ValueTask ReadDataAsync(Memory buffer, HttpResponseMessa if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead, this); } else { @@ -1154,7 +1134,7 @@ public void CopyTo(HttpResponseMessage responseMessage, Stream destination, int if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead, this); destination.Write(new ReadOnlySpan(buffer, 0, bytesRead)); } else @@ -1190,7 +1170,7 @@ public async Task CopyToAsync(HttpResponseMessage responseMessage, Stream destin if (bytesRead != 0) { - ExtendWindow(bytesRead); + _windowManager.AdjustWindow(bytesRead, this); 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 0000000000000..4a1f7ee6b568a --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2StreamWindowManager.cs @@ -0,0 +1,271 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + internal sealed partial class Http2Connection + { + // Maintains a dynamically-sized stream receive window, and sends WINDOW_UPDATE frames to the server. + private struct Http2StreamWindowManager + { + private static readonly double StopWatchToTimesSpan = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; + private static double WindowScaleThresholdMultiplier => GlobalHttpSettings.SocketsHttpHandler.Http2StreamWindowScaleThresholdMultiplier; + private static int MaxStreamWindowSize => GlobalHttpSettings.SocketsHttpHandler.MaxHttp2StreamWindowSize; + private static bool WindowScalingEnabled => !GlobalHttpSettings.SocketsHttpHandler.DisableDynamicHttp2WindowSizing; + + private int _deliveredBytes; + private int _streamWindowSize; + private long _lastWindowUpdate; + + public Http2StreamWindowManager(Http2Connection connection, Http2Stream stream) + { + HttpConnectionSettings settings = connection._pool.Settings; + _streamWindowSize = settings._initialHttp2StreamWindowSize; + _deliveredBytes = 0; + _lastWindowUpdate = default; + + if (NetEventSource.Log.IsEnabled()) stream.Trace($"[FlowControl] InitialClientStreamWindowSize: {StreamWindowSize}, StreamWindowThreshold: {StreamWindowThreshold}, WindowScaleThresholdMultiplier: {WindowScaleThresholdMultiplier}"); + } + + // We hold off on sending WINDOW_UPDATE until we hit the minimum threshold. + // This value is somewhat arbitrary; the intent is to ensure it is much smaller than + // the window size itself, or we risk stalling the server because it runs out of window space. + public const int StreamWindowUpdateRatio = 8; + internal int StreamWindowThreshold => _streamWindowSize / StreamWindowUpdateRatio; + + internal int StreamWindowSize => _streamWindowSize; + + public void Start() + { + _lastWindowUpdate = Stopwatch.GetTimestamp(); + } + + public void AdjustWindow(int bytesConsumed, Http2Stream stream) + { + Debug.Assert(_lastWindowUpdate != default); // Make sure Start() has been invoked, otherwise we should not be receiving DATA. + 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, stream); + } + else + { + AjdustWindowStatic(bytesConsumed, stream); + } + } + + private void AjdustWindowStatic(int bytesConsumed, Http2Stream stream) + { + _deliveredBytes += bytesConsumed; + if (_deliveredBytes < StreamWindowThreshold) + { + return; + } + + int windowUpdateIncrement = _deliveredBytes; + _deliveredBytes = 0; + + Http2Connection connection = stream.Connection; + Task sendWindowUpdateTask = connection.SendWindowUpdateAsync(stream.StreamId, windowUpdateIncrement); + connection.LogExceptions(sendWindowUpdateTask); + } + + private void AdjustWindowDynamic(int bytesConsumed, Http2Stream stream) + { + _deliveredBytes += bytesConsumed; + + if (_deliveredBytes < StreamWindowThreshold) + { + return; + } + + int windowUpdateIncrement = _deliveredBytes; + long currentTime = Stopwatch.GetTimestamp(); + Http2Connection connection = stream.Connection; + + TimeSpan rtt = connection._rttEstimator.MinRtt; + if (rtt > TimeSpan.Zero && _streamWindowSize < MaxStreamWindowSize) + { + TimeSpan dt = StopwatchTicksToTimeSpan(currentTime - _lastWindowUpdate); + + // We are detecting bursts in the amount of data consumed within a single 'dt' window update period. + // The value "_deliveredBytes / dt" correlates with the bandwidth of the connection. + // We need to extend the window, if the bandwidth-delay product grows over the current window size. + // To enable empirical fine tuning, we apply a configurable multiplier (_windowScaleThresholdMultiplier) to the window size, which defaults to 1.0 + // + // The condition to extend the window is: + // (_deliveredBytes / dt) * rtt > _streamWindowSize * _windowScaleThresholdMultiplier + // + // Which is reordered into the form below, to avoid the division: + if (_deliveredBytes * (double)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}."); + } + } + } + + _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); + } + } + + // Estimates Round Trip Time between the client and the server by sending PING frames, and measuring the time interval until a PING ACK is received. + // Assuming that the network characteristics of the connection wouldn't change much within its lifetime, we are maintaining a running minimum value. + // The more PINGs we send, the more accurate is the estimation of MinRtt, however we should be careful not to send too many of them, + // to avoid triggering the server's PING flood protection which may result in an unexpected GOAWAY. + // With most servers we are fine to send PINGs, as long as we are reading their data, this rule is well formalized for gRPC: + // https://github.com/grpc/proposal/blob/master/A8-client-side-keepalive.md + // As a rule of thumb, we can send send a PING whenever we receive DATA or HEADERS, however, there are some servers which allow receiving only + // a limited amount of PINGs within a given timeframe. + // To deal with the conflicting requirements: + // - We send an initial burst of 'InitialBurstCount' PINGs, to get a relatively good estimation fast + // - Afterwards, we send PINGs with the maximum frequency of 'PingIntervalInSeconds' PINGs per second + // + // Threading: + // OnInitialSettingsSent() is called during initialization, all other methods are triggered by HttpConnection.ProcessIncomingFramesAsync(), + // therefore the assumption is that the invocation of RttEstimator's methods is sequential, and there is no race beetween them. + // Http2StreamWindowManager is reading MinRtt from another concurrent thread, therefore its value has to be changed atomically. + private struct RttEstimator + { + private enum State + { + Disabled, + Init, + Waiting, + PingSent, + TerminatingMayReceivePingAck + } + + private const double PingIntervalInSeconds = 2; + private const int InitialBurstCount = 4; + private static readonly long PingIntervalInTicks = (long)(PingIntervalInSeconds * Stopwatch.Frequency); + + private State _state; + private long _pingSentTimestamp; + private long _pingCounter; + private int _initialBurst; + private long _minRtt; + + public TimeSpan MinRtt => new TimeSpan(_minRtt); + + public static RttEstimator Create() + { + RttEstimator e = default; + e._state = GlobalHttpSettings.SocketsHttpHandler.DisableDynamicHttp2WindowSizing ? State.Disabled : State.Init; + e._initialBurst = InitialBurstCount; + return e; + } + + internal void OnInitialSettingsSent() + { + if (_state == State.Disabled) return; + _pingSentTimestamp = Stopwatch.GetTimestamp(); + } + + internal void OnInitialSettingsAckReceived(Http2Connection connection) + { + if (_state == State.Disabled) return; + RefreshRtt(connection); + _state = State.Waiting; + } + + internal void OnDataOrHeadersReceived(Http2Connection connection) + { + if (_state != State.Waiting) return; + + long now = Stopwatch.GetTimestamp(); + bool initial = _initialBurst > 0; + if (initial || now - _pingSentTimestamp > PingIntervalInTicks) + { + if (initial) _initialBurst--; + + // Send a PING + _pingCounter--; + connection.LogExceptions(connection.SendPingAsync(_pingCounter, isAck: false)); + _pingSentTimestamp = now; + _state = State.PingSent; + } + } + + internal void OnPingAckReceived(long payload, Http2Connection connection) + { + if (_state != State.PingSent && _state != State.TerminatingMayReceivePingAck) + { + ThrowProtocolError(); + } + + if (_state == State.TerminatingMayReceivePingAck) + { + _state = State.Disabled; + return; + } + + // RTT PINGs always carry negative payload, positive values indicate a response to KeepAlive PING. + Debug.Assert(payload < 0); + + if (_pingCounter != payload) + ThrowProtocolError(); + + RefreshRtt(connection); + _state = State.Waiting; + } + + internal void OnGoAwayReceived() + { + if (_state == State.PingSent) + { + // We may still receive a PING ACK, but we should not send anymore PING: + _state = State.TerminatingMayReceivePingAck; + } + else + { + _state = State.Disabled; + } + } + + private void RefreshRtt(Http2Connection connection) + { + long elapsedTicks = Stopwatch.GetTimestamp() - _pingSentTimestamp; + long prevRtt = _minRtt == 0 ? long.MaxValue : _minRtt; + TimeSpan currentRtt = TimeSpan.FromSeconds(elapsedTicks / (double)Stopwatch.Frequency); + long minRtt = Math.Min(prevRtt, currentRtt.Ticks); + + Interlocked.Exchange(ref _minRtt, minRtt); // MinRtt is being queried from another thread + + 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/HttpConnectionSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs index 3c19737ae4003..03cb3e9a40624 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 @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net.Security; using System.IO; -using System.Net.Quic; using System.Net.Quic.Implementations; using System.Runtime.Versioning; using System.Threading; @@ -15,11 +14,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,11 +61,15 @@ internal sealed class HttpConnectionSettings internal IDictionary? _properties; + // Http2 flow control settings: + internal int _initialHttp2StreamWindowSize = HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize; + public HttpConnectionSettings() { - bool allowHttp2 = AllowHttp2; + bool allowHttp2 = GlobalHttpSettings.SocketsHttpHandler.AllowHttp2; + bool allowHttp3 = GlobalHttpSettings.SocketsHttpHandler.AllowDraftHttp3; _maxHttpVersion = - AllowDraftHttp3 && allowHttp2 ? HttpVersion.Version30 : + allowHttp3 && allowHttp2 ? HttpVersion.Version30 : allowHttp2 ? HttpVersion.Version20 : HttpVersion.Version11; _defaultCredentialsUsedForProxy = _proxy != null && (_proxy.Credentials == CredentialCache.DefaultCredentials || _defaultProxyCredentials == CredentialCache.DefaultCredentials); @@ -119,7 +117,8 @@ public HttpConnectionSettings CloneAndNormalize() _responseHeaderEncodingSelector = _responseHeaderEncodingSelector, _enableMultipleHttp2Connections = _enableMultipleHttp2Connections, _connectCallback = _connectCallback, - _plaintextStreamFilter = _plaintextStreamFilter + _plaintextStreamFilter = _plaintextStreamFilter, + _initialHttp2StreamWindowSize = _initialHttp2StreamWindowSize, }; // TODO: Remove if/when QuicImplementationProvider is removed from System.Net.Quic. @@ -131,58 +130,6 @@ public HttpConnectionSettings CloneAndNormalize() return settings; } - private static bool AllowHttp2 - { - 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; - } - - // 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 HTTP/2 protocol. - return false; - } - - // Default to a maximum of HTTP/2. - return true; - } - } - - private static bool AllowDraftHttp3 - { - 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. - - // First check for the AppContext switch, giving it priority over the environment variable. - if (AppContext.TryGetSwitch(Http3DraftSupportAppCtxSettingName, out bool allowHttp3)) - { - return allowHttp3; - } - - // 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; - } - } - public bool EnableMultipleHttp2Connections => _enableMultipleHttp2Connections; private byte[]? _http3SettingsFrame; 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 0000000000000..2fc23028f9b58 --- /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 76080c6cfc562..157ef2dde7e4c 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 @@ -285,6 +285,32 @@ public TimeSpan Expect100ContinueTimeout } } + /// + /// Defines the initial HTTP2 stream receive window size for all connections opened by the this . + /// + /// + /// Larger the values may lead to faster download speed, but potentially higher memory footprint. + /// The property must be set to a value between 65535 and the configured maximum window size, which is 16777216 by default. + /// + public int InitialHttp2StreamWindowSize + { + get => _settings._initialHttp2StreamWindowSize; + set + { + if (value < HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize || value > GlobalHttpSettings.SocketsHttpHandler.MaxHttp2StreamWindowSize) + { + string message = SR.Format( + SR.net_http_http2_invalidinitialstreamwindowsize, + HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize, + GlobalHttpSettings.SocketsHttpHandler.MaxHttp2StreamWindowSize); + + throw new ArgumentOutOfRangeException(nameof(InitialHttp2StreamWindowSize), message); + } + CheckDisposedOrStarted(); + _settings._initialHttp2StreamWindowSize = value; + } + } + /// /// Gets or sets the keep alive ping delay. The client will send a keep alive ping to the server if it /// doesn't receive any frames on a connection for this period of time. This property is used together with 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 b24c7037c2d06..2459f3657951f 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs @@ -12,6 +12,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; using Xunit; using Xunit.Abstractions; @@ -815,7 +816,7 @@ public async Task ResponseStreamFrames_DataAfterHeadersAndContinuationWithoutEnd await AssertProtocolErrorAsync(sendTask, ProtocolErrors.PROTOCOL_ERROR); // The client should close the connection as this is a fatal connection level error. - Assert.Null(await connection.ReadFrameAsync(TimeSpan.FromSeconds(30))); + await connection.WaitForClientDisconnectAsync(); } } @@ -1437,8 +1438,6 @@ private static async Task ReadToEndOfStream(Http2LoopbackConnection connect return bytesReceived; } - const int DefaultInitialWindowSize = 65535; - [OuterLoop("Uses Task.Delay")] [ConditionalFact(nameof(SupportsAlpn))] public async Task Http2_FlowControl_ClientDoesNotExceedWindows() @@ -1763,121 +1762,135 @@ public static IEnumerable KeepAliveTestDataSource() [MemberData(nameof(KeepAliveTestDataSource))] [ConditionalTheory(nameof(SupportsAlpn))] [ActiveIssue("https://github.com/dotnet/runtime/issues/41929")] - public async Task Http2_PingKeepAlive(TimeSpan keepAlivePingDelay, HttpKeepAlivePingPolicy keepAlivePingPolicy, bool expectRequestFail) + public void Http2_PingKeepAlive(TimeSpan keepAlivePingDelay, HttpKeepAlivePingPolicy keepAlivePingPolicy, bool expectRequestFail) { - TimeSpan pingTimeout = TimeSpan.FromSeconds(5); - // Simulate failure by delaying the pong, otherwise send it immediately. - TimeSpan pongDelay = expectRequestFail ? pingTimeout * 2 : TimeSpan.Zero; - // Pings are send only if KeepAlivePingDelay is not infinite. - bool expectStreamPing = keepAlivePingDelay != Timeout.InfiniteTimeSpan; - // Pings (regardless ongoing communication) are send only if sending is on and policy is set to always. - bool expectPingWithoutStream = expectStreamPing && keepAlivePingPolicy == HttpKeepAlivePingPolicy.Always; + RemoteExecutor.Invoke(RunTest, keepAlivePingDelay.Ticks.ToString(), keepAlivePingPolicy.ToString(), expectRequestFail.ToString()).Dispose(); - TaskCompletionSource serverFinished = new TaskCompletionSource(); + static async Task RunTest(string keepAlivePingDelayString, string keepAlivePingPolicyString, string expectRequestFailString) + { + // We should refactor this test so it can react to RTT PINGs. + // For now, avoid interference by disabling them: + AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing", true); - await Http2LoopbackServer.CreateClientAndServerAsync( - async uri => - { - SocketsHttpHandler handler = new SocketsHttpHandler() - { - KeepAlivePingTimeout = pingTimeout, - KeepAlivePingPolicy = keepAlivePingPolicy, - KeepAlivePingDelay = keepAlivePingDelay - }; - handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; + bool expectRequestFail = bool.Parse(expectRequestFailString); + TimeSpan keepAlivePingDelay = TimeSpan.FromTicks(long.Parse(keepAlivePingDelayString)); + HttpKeepAlivePingPolicy keepAlivePingPolicy = Enum.Parse(keepAlivePingPolicyString); - using HttpClient client = new HttpClient(handler); - client.DefaultRequestVersion = HttpVersion.Version20; + TimeSpan pingTimeout = TimeSpan.FromSeconds(5); + // Simulate failure by delaying the pong, otherwise send it immediately. + TimeSpan pongDelay = expectRequestFail ? pingTimeout * 2 : TimeSpan.Zero; + // Pings are send only if KeepAlivePingDelay is not infinite. + bool expectStreamPing = keepAlivePingDelay != Timeout.InfiniteTimeSpan; + // Pings (regardless ongoing communication) are send only if sending is on and policy is set to always. + bool expectPingWithoutStream = expectStreamPing && keepAlivePingPolicy == HttpKeepAlivePingPolicy.Always; - // Warmup request to create connection. - await client.GetStringAsync(uri); - // Request under the test scope. - if (expectRequestFail) - { - await Assert.ThrowsAsync(() => client.GetStringAsync(uri)); - // As stream is closed we don't want to continue with sending data. - return; - } - else + TaskCompletionSource serverFinished = new TaskCompletionSource(); + + await Http2LoopbackServer.CreateClientAndServerAsync( + async uri => { + SocketsHttpHandler handler = new SocketsHttpHandler() + { + KeepAlivePingTimeout = pingTimeout, + KeepAlivePingPolicy = keepAlivePingPolicy, + KeepAlivePingDelay = keepAlivePingDelay + }; + handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; + + using HttpClient client = new HttpClient(handler); + client.DefaultRequestVersion = HttpVersion.Version20; + + // Warmup request to create connection. await client.GetStringAsync(uri); - } + // Request under the test scope. + if (expectRequestFail) + { + await Assert.ThrowsAsync(() => client.GetStringAsync(uri)); + // As stream is closed we don't want to continue with sending data. + return; + } + else + { + await client.GetStringAsync(uri); + } - // Let connection live until server finishes. - try + // Let connection live until server finishes. + try + { + await serverFinished.Task.WaitAsync(pingTimeout * 3); + } + catch (TimeoutException) { } + }, + async server => { - await serverFinished.Task.WaitAsync(pingTimeout * 3); - } - catch (TimeoutException) { } - }, - async server => - { - using Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + using Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); - Task receivePingTask = expectStreamPing ? connection.ExpectPingFrameAsync() : null; + Task receivePingTask = expectStreamPing ? connection.ExpectPingFrameAsync() : null; - // Warmup the connection. - int streamId1 = await connection.ReadRequestHeaderAsync(); - await connection.SendDefaultResponseAsync(streamId1); + // Warmup the connection. + int streamId1 = await connection.ReadRequestHeaderAsync(); + await connection.SendDefaultResponseAsync(streamId1); - // Request under the test scope. - int streamId2 = await connection.ReadRequestHeaderAsync(); + // Request under the test scope. + int streamId2 = await connection.ReadRequestHeaderAsync(); - // Test ping with active stream. - if (!expectStreamPing) - { - await Assert.ThrowsAsync(() => connection.ReadPingAsync(pingTimeout)); - } - else - { - PingFrame ping; - if (receivePingTask != null && receivePingTask.IsCompleted) + // Test ping with active stream. + if (!expectStreamPing) { - ping = await receivePingTask; + await Assert.ThrowsAsync(() => connection.ReadPingAsync(pingTimeout)); } else { - ping = await connection.ReadPingAsync(pingTimeout); + PingFrame ping; + if (receivePingTask != null && receivePingTask.IsCompleted) + { + ping = await receivePingTask; + } + else + { + ping = await connection.ReadPingAsync(pingTimeout); + } + if (pongDelay > TimeSpan.Zero) + { + await Task.Delay(pongDelay); + } + + await connection.SendPingAckAsync(ping.Data); } - if (pongDelay > TimeSpan.Zero) + + // Send response and close the stream. + if (expectRequestFail) { - await Task.Delay(pongDelay); + await Assert.ThrowsAsync(() => connection.SendDefaultResponseAsync(streamId2)); + // As stream is closed we don't want to continue with sending data. + return; } - - await connection.SendPingAckAsync(ping.Data); - } - - // Send response and close the stream. - if (expectRequestFail) - { - await Assert.ThrowsAsync(() => connection.SendDefaultResponseAsync(streamId2)); - // As stream is closed we don't want to continue with sending data. - return; - } - await connection.SendDefaultResponseAsync(streamId2); - // Test ping with no active stream. - if (expectPingWithoutStream) - { - PingFrame ping = await connection.ReadPingAsync(pingTimeout); - await connection.SendPingAckAsync(ping.Data); - } - else - { - // If the pings were recently coming, just give the connection time to clear up streams - // and still accept one stray ping. - if (expectStreamPing) + await connection.SendDefaultResponseAsync(streamId2); + // Test ping with no active stream. + if (expectPingWithoutStream) { - try + PingFrame ping = await connection.ReadPingAsync(pingTimeout); + await connection.SendPingAckAsync(ping.Data); + } + else + { + // If the pings were recently coming, just give the connection time to clear up streams + // and still accept one stray ping. + if (expectStreamPing) { - await connection.ReadPingAsync(pingTimeout); + try + { + await connection.ReadPingAsync(pingTimeout); + } + catch (OperationCanceledException) { } // if it failed once, it will fail again } - catch (OperationCanceledException) { } // if it failed once, it will fail again + await Assert.ThrowsAsync(() => connection.ReadPingAsync(pingTimeout)); } - await Assert.ThrowsAsync(() => connection.ReadPingAsync(pingTimeout)); - } - serverFinished.SetResult(); - await connection.WaitForClientDisconnectAsync(true); - }); + serverFinished.SetResult(); + await connection.WaitForClientDisconnectAsync(true); + }, + new Http2Options() { EnableTransparentPingResponse = false }); + } } [OuterLoop("Uses Task.Delay")] diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs index 451bb20d58fa1..a5448b012d238 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 0000000000000..427b8fcc0e101 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2FlowControl.cs @@ -0,0 +1,320 @@ +// 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.Linq; +using System.Net.Test.Common; +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-dependent. + // There are two mitigations avoid flaky behavior on CI: + // - Parallel test execution is disabled + // - Using extreme parameters, and checks which are very unlikely to fail, if the implementation is correct + [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) + { + } + + private static Http2Options NoAutoPingResponseHttp2Options => new Http2Options() { EnableTransparentPingResponse = false }; + + [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 Task InvalidRttPingResponse_RequestShouldFail() + { + return Http2LoopbackServer.CreateClientAndServerAsync(async uri => + { + using var handler = CreateHttpClientHandler(); + using HttpClient client = CreateHttpClient(handler); + HttpRequestException exception = await Assert.ThrowsAsync(() => client.GetAsync(uri)); + _output.WriteLine(exception.Message + exception.StatusCode); + }, + async server => + { + Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + (int streamId, _) = await connection.ReadAndParseRequestHeaderAsync(); + await connection.SendDefaultResponseHeadersAsync(streamId); + PingFrame pingFrame = await connection.ReadPingAsync(); // expect an RTT PING + await connection.SendPingAckAsync(-6666); // send an invalid PING response + await connection.SendResponseDataAsync(streamId, new byte[] { 1, 2, 3 }, true); // otherwise fine response + }, + NoAutoPingResponseHttp2Options); + } + + + [OuterLoop("Runs long")] + [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); + } + + [OuterLoop("Runs long")] + [Fact] + public void DisableDynamicWindowScaling_HighBandwidthDelayProduct_WindowRemainsConstant() + { + static async Task RunTest() + { + AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing", true); + + int maxCredit = await TestClientWindowScalingAsync( + TimeSpan.FromMilliseconds(30), + TimeSpan.Zero, + 2 * 1024 * 1024, + null); + + Assert.Equal(DefaultInitialWindowSize, maxCredit); + } + + RemoteExecutor.Invoke(RunTest).Dispose(); + } + + [OuterLoop("Runs long")] + [Fact] + public void MaxStreamWindowSize_WhenSet_WindowDoesNotScaleAboveMaximum() + { + 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(); + } + + [OuterLoop("Runs long")] + [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(); + } + + [OuterLoop("Runs long")] + [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 = null, + int maxWindowForPingStopValidation = int.MaxValue, // set to actual maximum to test if we stop sending PING when window reached maximum + Action configureHandler = null) + { + TimeSpan timeout = TimeSpan.FromSeconds(30); + CancellationTokenSource timeoutCts = new CancellationTokenSource(timeout); + + HttpClientHandler handler = CreateHttpClientHandler(HttpVersion20.Value); + configureHandler?.Invoke(GetUnderlyingSocketsHttpHandler(handler)); + + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(NoAutoPingResponseHttp2Options); + using HttpClient client = new HttpClient(handler, true); + client.DefaultRequestVersion = HttpVersion20.Value; + + Task clientTask = client.GetAsync(server.Address, timeoutCts.Token); + 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; + + bool pingReceivedAfterReachingMaxWindow = false; + bool unexpectedFrameReceived = false; + CancellationTokenSource stopFrameProcessingCts = new CancellationTokenSource(); + + CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stopFrameProcessingCts.Token, timeoutCts.Token); + Task processFramesTask = ProcessIncomingFramesAsync(linkedCts.Token); + 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; + + stopFrameProcessingCts.Cancel(); + await processFramesTask; + + int dataReceived = (await response.Content.ReadAsByteArrayAsync()).Length; + Assert.Equal(bytesToDownload, dataReceived); + Assert.False(pingReceivedAfterReachingMaxWindow, "Server received a PING after reaching max window"); + Assert.False(unexpectedFrameReceived, "Server received an unexpected frame, see test output for more details."); + + return maxCredit; + + async Task ProcessIncomingFramesAsync(CancellationToken cancellationToken) + { + // If credit > 90% of the maximum window, we are safe to assume we reached the max window. + // We should not receive any more RTT PING's after this point + int maxWindowCreditThreshold = (int) (0.9 * maxWindowForPingStopValidation); + output?.WriteLine($"maxWindowCreditThreshold: {maxWindowCreditThreshold} maxWindowForPingStopValidation: {maxWindowForPingStopValidation}"); + + try + { + while (remainingBytes > 0 && !cancellationToken.IsCancellationRequested) + { + Frame frame = await connection.ReadFrameAsync(cancellationToken); + + if (frame is PingFrame pingFrame) + { + // Simulate network delay for RTT PING + Wait(networkDelay); + + output?.WriteLine($"Received PING ({pingFrame.Data})"); + + if (maxCredit > maxWindowCreditThreshold) + { + output?.WriteLine("PING was unexpected"); + Volatile.Write(ref pingReceivedAfterReachingMaxWindow, true); + } + + await writeSemaphore.WaitAsync(cancellationToken); + await connection.SendPingAckAsync(pingFrame.Data, cancellationToken); + 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) + { + Volatile.Write(ref unexpectedFrameReceived, true); + output?.WriteLine("Received unexpected frame: " + frame); + } + } + } + catch (OperationCanceledException) + { + } + + + output?.WriteLine("ProcessIncomingFramesAsync finished"); + } + + 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 9902e490ce5fb..10e81d5f67919 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -1927,6 +1927,30 @@ public void PlaintextStreamFilter_GetSet_Roundtrips() } } + [Fact] + public void InitialHttp2StreamWindowSize_GetSet_Roundtrips() + { + using var handler = new SocketsHttpHandler(); + Assert.Equal(HttpClientHandlerTestBase.DefaultInitialWindowSize, handler.InitialHttp2StreamWindowSize); // default value + + handler.InitialHttp2StreamWindowSize = 1048576; + Assert.Equal(1048576, handler.InitialHttp2StreamWindowSize); + + handler.InitialHttp2StreamWindowSize = HttpClientHandlerTestBase.DefaultInitialWindowSize; + Assert.Equal(HttpClientHandlerTestBase.DefaultInitialWindowSize, 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)] @@ -1966,6 +1990,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); @@ -1987,6 +2012,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); } } } @@ -2268,6 +2294,7 @@ private async Task PrepareConnection(Http2LoopbackServe { Task warmUpTask = client.GetAsync(server.Address); Http2LoopbackConnection connection = await GetConnection(server, maxConcurrentStreams, readTimeout).WaitAsync(TestHelper.PassingTestTimeout * 2).ConfigureAwait(false); + // 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 7a33b8cac5c7a..b3413cbef3053 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 @@ -130,6 +130,7 @@ Link="Common\System\Net\Http\HttpClientHandlerTest.DefaultProxyCredentials.cs" /> + 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 0000000000000..9b933215a84ca --- /dev/null +++ b/src/libraries/System.Net.Http/tests/UnitTests/RuntimeSettingParserTest.cs @@ -0,0 +1,153 @@ +// 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 186f1cbd88808..3b950f2a36c9f 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" /> + +