Skip to content

Commit

Permalink
Implement dynamic HTTP/2 window scaling (#54755)
Browse files Browse the repository at this point in the history
Fixes #43086 by introducing automatic scaling of the HTTP/2 stream receive window based on measuring RTT of PING frames.
  • Loading branch information
antonfirsov authored Jul 8, 2021
1 parent a25bece commit c7ffa32
Show file tree
Hide file tree
Showing 24 changed files with 1,216 additions and 262 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,23 @@ public class Http2LoopbackConnection : GenericLoopbackConnection
private TaskCompletionSource<bool> _ignoredSettingsAckPromise;
private bool _ignoreWindowUpdates;
private TaskCompletionSource<PingFrame> _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);
public bool IsInvalid => _connectionSocket == null;
public Stream Stream => _connectionStream;
public Task<bool> 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<Http2LoopbackConnection> CreateAsync(SocketWrapper socket, Stream stream, Http2Options httpOptions)
Expand Down Expand Up @@ -76,7 +79,7 @@ public static async Task<Http2LoopbackConnection> 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -159,7 +162,7 @@ public async Task<Frame> ReadFrameAsync(TimeSpan timeout)
return await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false);
}

private async Task<Frame> ReadFrameAsync(CancellationToken cancellationToken)
public async Task<Frame> 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];
Expand Down Expand Up @@ -198,11 +201,12 @@ private async Task<Frame> 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.
Expand All @@ -224,11 +228,37 @@ private async Task<Frame> 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<bool> 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()
{
Expand Down Expand Up @@ -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<PingFrame> ExpectPingFrameAsync()
{
if (_transparentPingResponse)
{
throw new InvalidOperationException(
$"{nameof(Http2LoopbackConnection)}.{nameof(ExpectPingFrameAsync)} can not be used when transparent PING response is enabled.");
}

_expectPingFrame ??= new TaskCompletionSource<PingFrame>();
return _expectPingFrame.Task;
}
Expand Down Expand Up @@ -297,6 +334,7 @@ public async Task WaitForClientDisconnectAsync(bool ignoreUnexpectedFrames = fal
{
IgnoreWindowUpdates();

_expectClientDisconnect = true;
Frame frame = await ReadFrameAsync(_timeout).ConfigureAwait(false);
if (frame != null)
{
Expand Down Expand Up @@ -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<PingFrame> ReadPingAsync() => ReadPingAsync(_timeout);

public async Task<PingFrame> ReadPingAsync(TimeSpan timeout)
{
_expectPingFrame?.TrySetCanceled();
Expand All @@ -743,7 +785,7 @@ public async Task<PingFrame> ReadPingAsync(TimeSpan timeout)
return Assert.IsAssignableFrom<PingFrame>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ public class Http2Options : GenericLoopbackOptions
{
public bool ClientCertificateRequired { get; set; }

public bool EnableTransparentPingResponse { get; set; } = true;

public Http2Options()
{
SslProtocols = SslProtocols.Tls12;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/libraries/System.Net.Http/ref/System.Net.Http.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { } }
Expand Down
3 changes: 3 additions & 0 deletions src/libraries/System.Net.Http/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@
<data name="net_http_http2_connection_not_established" xml:space="preserve">
<value>An HTTP/2 connection could not be established because the server did not complete the HTTP/2 handshake.</value>
</data>
<data name="net_http_http2_invalidinitialstreamwindowsize" xml:space="preserve">
<value>The initial HTTP/2 stream window size must be between {0} and {1}.</value>
</data>
<data name="net_MethodNotImplementedException" xml:space="preserve">
<value>This method is not implemented by this class.</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/libraries/System.Net.Http/src/System.Net.Http.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
<Compile Include="System\Net\Http\ReadOnlyMemoryContent.cs" />
<Compile Include="System\Net\Http\RequestRetryType.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\HttpMessageHandlerStage.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\RuntimeSettingParser.cs" />
<Compile Include="System\Net\Http\StreamContent.cs" />
<Compile Include="System\Net\Http\StreamToStreamCopy.cs" />
<Compile Include="System\Net\Http\StringContent.cs" />
Expand Down Expand Up @@ -160,6 +161,7 @@
<Compile Include="System\Net\Http\SocketsHttpHandler\Http2ProtocolException.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\Http2Stream.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\Http2StreamException.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\Http2StreamWindowManager.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\Http3Connection.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\Http3ConnectionException.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\Http3ProtocolException.cs" />
Expand Down Expand Up @@ -495,6 +497,7 @@
<ItemGroup>
<Compile Include="System\Net\Http\DiagnosticsHandler.cs" />
<Compile Include="System\Net\Http\DiagnosticsHandlerLoggingStrings.cs" />
<Compile Include="System\Net\Http\GlobalHttpSettings.cs" />
<Compile Include="System\Net\Http\SocketsHttpHandler\SocketsHttpPlaintextStreamFilterContext.cs" />
<Compile Include="$(CommonPath)System\Threading\Tasks\TaskCompletionSourceWithCancellation.cs"
Link="Common\System\Threading\Tasks\TaskCompletionSourceWithCancellation.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -147,7 +153,6 @@ public TimeSpan KeepAlivePingTimeout
set => throw new PlatformNotSupportedException();
}


public HttpKeepAlivePingPolicy KeepAlivePingPolicy
{
get => throw new PlatformNotSupportedException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ namespace System.Net.Http
/// </summary>
internal sealed class DiagnosticsHandler : DelegatingHandler
{
private static readonly DiagnosticListener s_diagnosticListener =
new DiagnosticListener(DiagnosticsHandlerLoggingStrings.DiagnosticListenerName);

/// <summary>
/// DiagnosticHandler constructor
/// </summary>
Expand All @@ -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.
Expand All @@ -59,7 +59,7 @@ private async ValueTask<HttpResponseMessage> 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
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit c7ffa32

Please sign in to comment.