diff --git a/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs b/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs index d42462f6f856d..537519288734b 100644 --- a/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs +++ b/src/libraries/Common/src/System/Net/Http/HttpHandlerDefaults.cs @@ -9,7 +9,7 @@ namespace System.Net.Http /// Central repository for default values used in http handler settings. Not all settings are relevant /// to or configurable by all handlers. /// - internal static class HttpHandlerDefaults + internal static partial class HttpHandlerDefaults { public const int DefaultMaxAutomaticRedirections = 50; public const int DefaultMaxConnectionsPerServer = int.MaxValue; diff --git a/src/libraries/Common/tests/System/Net/Http/Http2Frames.cs b/src/libraries/Common/tests/System/Net/Http/Http2Frames.cs index 3195e239438b4..214dab635061a 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http2Frames.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http2Frames.cs @@ -344,9 +344,9 @@ public override string ToString() public class PingFrame : Frame { - public byte[] Data; + public long Data; - public PingFrame(byte[] data, FrameFlags flags, int streamId) : + public PingFrame(long data, FrameFlags flags, int streamId) : base(8, FrameType.Ping, flags, streamId) { Data = data; @@ -354,7 +354,7 @@ public PingFrame(byte[] data, FrameFlags flags, int streamId) : public static PingFrame ReadFrom(Frame header, ReadOnlySpan buffer) { - byte[] data = buffer.ToArray(); + long data = BinaryPrimitives.ReadInt64BigEndian(buffer); return new PingFrame(data, header.Flags, header.StreamId); } @@ -364,12 +364,12 @@ public override void WriteTo(Span buffer) base.WriteTo(buffer); buffer = buffer.Slice(Frame.FrameHeaderLength, 8); - Data.CopyTo(buffer); + BinaryPrimitives.WriteInt64BigEndian(buffer, Data); } public override string ToString() { - return base.ToString() + $"\nOpaque Data: {string.Join(", ", Data)}"; + return base.ToString() + $"\nOpaque Data: {Data:X16}"; } } diff --git a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs index 846e2acc74e60..08eee1084e200 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs @@ -24,6 +24,7 @@ public class Http2LoopbackConnection : GenericLoopbackConnection private Stream _connectionStream; private TaskCompletionSource _ignoredSettingsAckPromise; private bool _ignoreWindowUpdates; + private TaskCompletionSource _expectPingFrame; private readonly TimeSpan _timeout; private int _lastStreamId; @@ -186,6 +187,13 @@ private async Task ReadFrameAsync(CancellationToken cancellationToken) return await ReadFrameAsync(cancellationToken).ConfigureAwait(false); } + if (_expectPingFrame != null && header.Type == FrameType.Ping) + { + _expectPingFrame.SetResult(PingFrame.ReadFrom(header, data)); + _expectPingFrame = null; + return await ReadFrameAsync(cancellationToken).ConfigureAwait(false); + } + // Construct the correct frame type and return it. switch (header.Type) { @@ -245,6 +253,15 @@ public void IgnoreWindowUpdates() _ignoreWindowUpdates = true; } + // Set up loopback server to expect PING frames among other frames. + // Once PING frame is read in ReadFrameAsync, the returned task is completed. + // The returned task is canceled in ReadPingAsync if no PING frame has been read so far. + public Task ExpectPingFrameAsync() + { + _expectPingFrame ??= new TaskCompletionSource(); + return _expectPingFrame.Task; + } + public async Task ReadRstStreamAsync(int streamId) { Frame frame = await ReadFrameAsync(_timeout); @@ -663,7 +680,7 @@ public async Task SendGoAway(int lastStreamId, ProtocolErrors errorCode = Protoc public async Task PingPong() { - byte[] pingData = new byte[8] { 1, 2, 3, 4, 50, 60, 70, 80 }; + long pingData = BitConverter.ToInt64(new byte[8] { 1, 2, 3, 4, 50, 60, 70, 80 }, 0); PingFrame ping = new PingFrame(pingData, FrameFlags.None, 0); await WriteFrameAsync(ping).ConfigureAwait(false); PingFrame pingAck = (PingFrame)await ReadFrameAsync(_timeout).ConfigureAwait(false); @@ -675,6 +692,27 @@ public async Task PingPong() Assert.Equal(pingData, pingAck.Data); } + public async Task ReadPingAsync(TimeSpan timeout) + { + _expectPingFrame?.TrySetCanceled(); + _expectPingFrame = null; + + Frame frame = await ReadFrameAsync(timeout).ConfigureAwait(false); + Assert.NotNull(frame); + Assert.Equal(FrameType.Ping, frame.Type); + Assert.Equal(0, frame.StreamId); + Assert.False(frame.AckFlag); + Assert.Equal(8, frame.Length); + + return Assert.IsAssignableFrom(frame); + } + + public async Task SendPingAckAsync(long payload) + { + PingFrame pingAck = new PingFrame(payload, FrameFlags.Ack, 0); + await WriteFrameAsync(pingAck).ConfigureAwait(false); + } + public async Task SendDefaultResponseHeadersAsync(int streamId) { byte[] headers = new byte[] { 0x88 }; // Encoding for ":status: 200" diff --git a/src/libraries/System.Net.Http.WinHttpHandler/src/System.Net.Http.WinHttpHandler.csproj b/src/libraries/System.Net.Http.WinHttpHandler/src/System.Net.Http.WinHttpHandler.csproj index 71f4b005c6b4d..1a499a9c2b109 100644 --- a/src/libraries/System.Net.Http.WinHttpHandler/src/System.Net.Http.WinHttpHandler.csproj +++ b/src/libraries/System.Net.Http.WinHttpHandler/src/System.Net.Http.WinHttpHandler.csproj @@ -34,9 +34,9 @@ + Link="Common\Interop\Windows\Crypt32\Interop.certificates.cs" /> + Link="Common\Interop\Windows\Kernel32\Interop.FormatMessage.cs" /> + Link="Common\System\Net\HttpKnownHeaderNames.TryGetHeaderName.cs" /> + Link="Common\System\Threading\Tasks\RendezvousAwaitable.cs" /> 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 dbf127857b1bd..6a7cf68cc12ab 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -347,6 +347,9 @@ public SocketsHttpHandler() { } public System.Net.ICredentials? Credentials { get { throw null; } set { } } public System.Net.ICredentials? DefaultProxyCredentials { get { throw null; } set { } } public System.TimeSpan Expect100ContinueTimeout { get { throw null; } set { } } + public System.TimeSpan KeepAlivePingDelay { get { throw null; } set { } } + public System.TimeSpan KeepAlivePingTimeout { get { throw null; } set { } } + public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get { throw null; } set { } } public int MaxAutomaticRedirections { get { throw null; } set { } } public int MaxConnectionsPerServer { get { throw null; } set { } } public int MaxResponseDrainSize { get { throw null; } set { } } @@ -369,6 +372,11 @@ protected override void Dispose(bool disposing) { } protected internal override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { throw null; } public bool EnableMultipleHttp2Connections { get { throw null; } set { } } } + public enum HttpKeepAlivePingPolicy + { + WithActiveRequests, + Always + } public partial class StreamContent : System.Net.Http.HttpContent { public StreamContent(System.IO.Stream content) { } diff --git a/src/libraries/System.Net.Http/src/Resources/Strings.resx b/src/libraries/System.Net.Http/src/Resources/Strings.resx index 61b45be26fdd9..84bc59ad8750e 100644 --- a/src/libraries/System.Net.Http/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Http/src/Resources/Strings.resx @@ -1,17 +1,17 @@  - @@ -264,6 +264,9 @@ The specified value must be greater than {0}. + + The specified value '{0}' must be greater than or equal to '{1}'. + An invalid character was found in the mail header: '{0}'. 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 f7d8e23d571e5..ec91aeb408c9e 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -131,6 +131,7 @@ + @@ -168,6 +169,7 @@ + @@ -670,6 +672,7 @@ Link="Common\System\Text\ValueStringBuilder.cs" /> + 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 2a1d63d35b15f..214f5621415eb 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 @@ -130,6 +130,25 @@ public TimeSpan Expect100ContinueTimeout set => throw new PlatformNotSupportedException(); } + public TimeSpan KeepAlivePingDelay + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } + + public TimeSpan KeepAlivePingTimeout + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } + + + public HttpKeepAlivePingPolicy KeepAlivePingPolicy + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } + public ConnectionFactory? ConnectionFactory { get => throw new PlatformNotSupportedException(); 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 new file mode 100644 index 0000000000000..21cc00a744446 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; + +namespace System.Net.Http +{ + /// + /// Additional default values used used only in this assembly. + /// + 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; + } +} 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 289fd7a1cb770..3aae8a9357d85 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 @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; @@ -96,6 +97,20 @@ internal sealed partial class Http2Connection : HttpConnectionBase, IDisposable // Channel options for creating _writeChannel private static readonly UnboundedChannelOptions s_channelOptions = new UnboundedChannelOptions() { SingleReader = true }; + internal enum KeepAliveState + { + None, + PingSent + } + + private readonly long _keepAlivePingDelay; + private readonly long _keepAlivePingTimeout; + private readonly HttpKeepAlivePingPolicy _keepAlivePingPolicy; + private long _keepAlivePingPayload; + private long _nextPingRequestTimestamp; + private long _keepAlivePingTimeoutTimestamp; + private volatile KeepAliveState _keepAliveState; + public Http2Connection(HttpConnectionPool pool, Connection connection) { _pool = pool; @@ -119,7 +134,18 @@ public Http2Connection(HttpConnectionPool pool, Connection connection) _pendingWindowUpdate = 0; _idleSinceTickCount = Environment.TickCount64; + + _keepAlivePingDelay = TimeSpanToMs(_pool.Settings._keepAlivePingDelay); + _keepAlivePingTimeout = TimeSpanToMs(_pool.Settings._keepAlivePingTimeout); + _nextPingRequestTimestamp = Environment.TickCount64 + _keepAlivePingDelay; + _keepAlivePingPolicy = _pool.Settings._keepAlivePingPolicy; + if (NetEventSource.Log.IsEnabled()) TraceConnection(_stream); + + static long TimeSpanToMs(TimeSpan value) { + double milliseconds = value.TotalMilliseconds; + return (long)(milliseconds > int.MaxValue ? int.MaxValue : milliseconds); + } } private object SyncObject => _httpStreams; @@ -298,6 +324,8 @@ private async Task ProcessIncomingFramesAsync() frameHeader = await ReadFrameAsync().ConfigureAwait(false); if (NetEventSource.Log.IsEnabled()) Trace($"Frame {frameNum}: {frameHeader}."); + RefreshPingTimestamp(); + // Process the frame. switch (frameHeader.Type) { @@ -667,12 +695,6 @@ private void ProcessPingFrame(FrameHeader frameHeader) ThrowProtocolError(); } - if (frameHeader.AckFlag) - { - // We never send PING, so an ACK indicates a protocol error - ThrowProtocolError(); - } - if (frameHeader.PayloadLength != FrameHeader.PingLength) { ThrowProtocolError(Http2ProtocolErrorCode.FrameSizeError); @@ -685,8 +707,14 @@ private void ProcessPingFrame(FrameHeader frameHeader) ReadOnlySpan pingContent = _incomingBuffer.ActiveSpan.Slice(0, FrameHeader.PingLength); long pingContentLong = BinaryPrimitives.ReadInt64BigEndian(pingContent); - LogExceptions(SendPingAckAsync(pingContentLong)); - + if (frameHeader.AckFlag) + { + ProcessPingAck(pingContentLong); + } + else + { + LogExceptions(SendPingAsync(pingContentLong, isAck: true)); + } _incomingBuffer.Discard(frameHeader.PayloadLength); } @@ -936,15 +964,16 @@ private Task SendSettingsAckAsync() => }); /// The 8-byte ping content to send, read as a big-endian integer. - private Task SendPingAckAsync(long pingContent) => - PerformWriteAsync(FrameHeader.Size + FrameHeader.PingLength, (thisRef: this, pingContent), static (state, writeBuffer) => + /// Determine whether the frame is ping or ping ack. + private Task SendPingAsync(long pingContent, bool isAck = false) => + PerformWriteAsync(FrameHeader.Size + FrameHeader.PingLength, (thisRef: this, pingContent, isAck), static (state, writeBuffer) => { if (NetEventSource.Log.IsEnabled()) state.thisRef.Trace("Started writing."); Debug.Assert(sizeof(long) == FrameHeader.PingLength); Span span = writeBuffer.Span; - FrameHeader.WriteTo(span, FrameHeader.PingLength, FrameType.Ping, FrameFlags.Ack, streamId: 0); + FrameHeader.WriteTo(span, FrameHeader.PingLength, FrameType.Ping, state.isAck ? FrameFlags.Ack: FrameFlags.None, streamId: 0); BinaryPrimitives.WriteInt64BigEndian(span.Slice(FrameHeader.Size), state.pingContent); return true; @@ -962,6 +991,24 @@ private Task SendRstStreamAsync(int streamId, Http2ProtocolErrorCode errorCode) return true; }); + + internal void HeartBeat() + { + if (_disposed) + return; + + try + { + VerifyKeepAlive(); + } + catch (Exception e) + { + if (NetEventSource.Log.IsEnabled()) Trace($"{nameof(HeartBeat)}: {e.Message}"); + + Abort(e); + } + } + private static (ReadOnlyMemory first, ReadOnlyMemory rest) SplitBuffer(ReadOnlyMemory buffer, int maxSize) => buffer.Length > maxSize ? (buffer.Slice(0, maxSize), buffer.Slice(maxSize)) : @@ -1849,6 +1896,56 @@ private void RemoveStream(Http2Stream http2Stream) _concurrentStreams.AdjustCredit(1); } + private void RefreshPingTimestamp() + { + _nextPingRequestTimestamp = Environment.TickCount64 + _keepAlivePingDelay; + } + + private void ProcessPingAck(long payload) + { + if (_keepAliveState != KeepAliveState.PingSent) + ThrowProtocolError(); + if (Interlocked.Read(ref _keepAlivePingPayload) != payload) + ThrowProtocolError(); + _keepAliveState = KeepAliveState.None; + } + + private void VerifyKeepAlive() + { + if (_keepAlivePingPolicy == HttpKeepAlivePingPolicy.WithActiveRequests) + { + lock (SyncObject) + { + if (_httpStreams.Count == 0) return; + } + } + + long now = Environment.TickCount64; + switch (_keepAliveState) + { + case KeepAliveState.None: + // Check whether keep alive delay has passed since last frame received + if (now > _nextPingRequestTimestamp) + { + // Set the status directly to ping sent and set the timestamp + _keepAliveState = KeepAliveState.PingSent; + _keepAlivePingTimeoutTimestamp = now + _keepAlivePingTimeout; + + long pingPayload = Interlocked.Increment(ref _keepAlivePingPayload); + SendPingAsync(pingPayload); + return; + } + break; + case KeepAliveState.PingSent: + if (now > _keepAlivePingTimeoutTimestamp) + ThrowProtocolError(); + break; + default: + Debug.Fail($"Unexpected keep alive state ({_keepAliveState})"); + break; + } + } + public sealed override string ToString() => $"{nameof(Http2Connection)}({_pool})"; // Description for diagnostic purposes public override void Trace(string message, [CallerMemberName] string? memberName = null) => diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs index c74f32a10755e..33a93b0755012 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs @@ -1774,6 +1774,19 @@ private static bool GetIsWindows7Or2008R2() return false; } + internal void HeartBeat() + { + Http2Connection[]? localHttp2Connections = _http2Connections; + if (localHttp2Connections != null) + { + foreach (Http2Connection http2Connection in localHttp2Connections) + { + http2Connection.HeartBeat(); + } + } + } + + // For diagnostic purposes public override string ToString() => $"{nameof(HttpConnectionPool)} " + diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs index 601b5833b5494..290d2952fe876 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs @@ -36,6 +36,8 @@ internal sealed class HttpConnectionPoolManager : IDisposable private readonly ConcurrentDictionary _pools; /// Timer used to initiate cleaning of the pools. private readonly Timer? _cleaningTimer; + /// Heart beat timer currently used for Http2 ping only. + private readonly Timer? _heartBeatTimer; /// The maximum number of connections allowed per pool. indicates unlimited. private readonly int _maxConnectionsPerServer; // Temporary @@ -102,6 +104,8 @@ public HttpConnectionPoolManager(HttpConnectionSettings settings) // Create the timer. Ensure the Timer has a weak reference to this manager; otherwise, it // can introduce a cycle that keeps the HttpConnectionPoolManager rooted by the Timer // implementation until the handler is Disposed (or indefinitely if it's not). + var thisRef = new WeakReference(this); + _cleaningTimer = new Timer(static s => { var wr = (WeakReference)s!; @@ -109,7 +113,23 @@ public HttpConnectionPoolManager(HttpConnectionSettings settings) { thisRef.RemoveStalePools(); } - }, new WeakReference(this), Timeout.Infinite, Timeout.Infinite); + }, thisRef, Timeout.Infinite, Timeout.Infinite); + + + // For now heart beat is used only for ping functionality. + if (_settings._keepAlivePingDelay != Timeout.InfiniteTimeSpan) + { + long heartBeatInterval = (long)Math.Max(1000, Math.Min(_settings._keepAlivePingDelay.TotalMilliseconds, _settings._keepAlivePingTimeout.TotalMilliseconds) / 4); + + _heartBeatTimer = new Timer(static state => + { + var wr = (WeakReference)state!; + if (wr.TryGetTarget(out HttpConnectionPoolManager? thisRef)) + { + thisRef.HeartBeat(); + } + }, thisRef, heartBeatInterval, heartBeatInterval); + } } finally { @@ -455,7 +475,7 @@ private async ValueTask SendAsyncMultiProxy(HttpRequestMess public void Dispose() { _cleaningTimer?.Dispose(); - + _heartBeatTimer?.Dispose(); foreach (KeyValuePair pool in _pools) { pool.Value.Dispose(); @@ -519,6 +539,14 @@ private void RemoveStalePools() // be returned to pools they weren't associated with. } + private void HeartBeat() + { + foreach (KeyValuePair pool in _pools) + { + pool.Value.HeartBeat(); + } + } + private static string GetIdentityIfDefaultCredentialsUsed(bool defaultCredentialsUsed) { return defaultCredentialsUsed ? CurrentUserIdentityProvider.GetIdentity() : string.Empty; 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 e3231b1c820aa..3eefbd35f300d 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 @@ -42,6 +42,9 @@ internal sealed class HttpConnectionSettings internal TimeSpan _pooledConnectionLifetime = HttpHandlerDefaults.DefaultPooledConnectionLifetime; internal TimeSpan _pooledConnectionIdleTimeout = HttpHandlerDefaults.DefaultPooledConnectionIdleTimeout; internal TimeSpan _expect100ContinueTimeout = HttpHandlerDefaults.DefaultExpect100ContinueTimeout; + internal TimeSpan _keepAlivePingTimeout = HttpHandlerDefaults.DefaultKeepAlivePingTimeout; + internal TimeSpan _keepAlivePingDelay = HttpHandlerDefaults.DefaultKeepAlivePingDelay; + internal HttpKeepAlivePingPolicy _keepAlivePingPolicy = HttpHandlerDefaults.DefaultKeepAlivePingPolicy; internal TimeSpan _connectTimeout = HttpHandlerDefaults.DefaultConnectTimeout; internal HeaderEncodingSelector? _requestHeaderEncodingSelector; @@ -103,6 +106,9 @@ public HttpConnectionSettings CloneAndNormalize() _sslOptions = _sslOptions?.ShallowClone(), // shallow clone the options for basic prevention of mutation issues while processing _useCookies = _useCookies, _useProxy = _useProxy, + _keepAlivePingTimeout = _keepAlivePingTimeout, + _keepAlivePingDelay = _keepAlivePingDelay, + _keepAlivePingPolicy = _keepAlivePingPolicy, _requestHeaderEncodingSelector = _requestHeaderEncodingSelector, _responseHeaderEncodingSelector = _responseHeaderEncodingSelector, _enableMultipleHttp2Connections = _enableMultipleHttp2Connections, diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpKeepAlivePingPolicy.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpKeepAlivePingPolicy.cs new file mode 100644 index 0000000000000..7eb9f45226ac5 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpKeepAlivePingPolicy.cs @@ -0,0 +1,18 @@ +// 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 +{ + public enum HttpKeepAlivePingPolicy + { + /// + /// Sends keep alive ping for only when there are active streams on the connection. + /// + WithActiveRequests, + + /// + /// Sends keep alive ping for whole connection lifetime. + /// + Always + } +} 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 6a8a3d5f03921..fbe84c6a4a667 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 @@ -280,6 +280,70 @@ public TimeSpan Expect100ContinueTimeout } } + /// + /// 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 + /// to close broken connections. + /// + /// Delay value must be greater than or equal to 1 second. Set to to + /// disable the keep alive ping. + /// Defaults to . + /// + /// + public TimeSpan KeepAlivePingDelay + { + get => _settings._keepAlivePingDelay; + set + { + if (value.Ticks < TimeSpan.TicksPerSecond && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value), value, SR.Format(SR.net_http_value_must_be_greater_than_or_equal, value, TimeSpan.FromSeconds(1))); + } + + CheckDisposedOrStarted(); + _settings._keepAlivePingDelay = value; + } + } + + /// + /// Gets or sets the keep alive ping timeout. Keep alive pings are sent when a period of inactivity exceeds + /// the configured value. The client will close the connection if it + /// doesn't receive any frames within the timeout. + /// + /// Timeout must be greater than or equal to 1 second. Set to to + /// disable the keep alive ping timeout. + /// Defaults to 20 seconds. + /// + /// + public TimeSpan KeepAlivePingTimeout + { + get => _settings._keepAlivePingTimeout; + set + { + if (value.Ticks < TimeSpan.TicksPerSecond && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value), value, SR.Format(SR.net_http_value_must_be_greater_than_or_equal, value, TimeSpan.FromSeconds(1))); + } + + CheckDisposedOrStarted(); + _settings._keepAlivePingTimeout = value; + } + } + + /// + /// Gets or sets the keep alive ping behaviour. Keep alive pings are sent when a period of inactivity exceeds + /// the configured value. + /// + public HttpKeepAlivePingPolicy KeepAlivePingPolicy + { + get => _settings._keepAlivePingPolicy; + set + { + CheckDisposedOrStarted(); + _settings._keepAlivePingPolicy = value; + } + } + /// /// Gets or sets a value that indicates whether additional HTTP/2 connections can be established to the same server /// when the maximum of concurrent streams is reached on all existing connections. 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 28cae6757d245..77fcdcff6adde 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs @@ -1417,6 +1417,117 @@ public async Task Http2_InitialWindowSize_ClientDoesNotExceedWindows() } } + public static IEnumerable KeepAliveTestDataSource() + { + yield return new object[] { Timeout.InfiniteTimeSpan, HttpKeepAlivePingPolicy.Always, false }; + yield return new object[] { TimeSpan.FromSeconds(1), HttpKeepAlivePingPolicy.WithActiveRequests, false }; + yield return new object[] { TimeSpan.FromSeconds(1), HttpKeepAlivePingPolicy.Always, false }; + yield return new object[] { TimeSpan.FromSeconds(1), HttpKeepAlivePingPolicy.WithActiveRequests, true }; + } + + [OuterLoop("Significant delay.")] + [MemberData(nameof(KeepAliveTestDataSource))] + [ConditionalTheory(nameof(SupportsAlpn))] + public async Task 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; + + 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. + await serverFinished.Task.TimeoutAfter(pingTimeout * 2); + }, + async server => + { + using Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + + Task receivePingTask = expectStreamPing ? connection.ExpectPingFrameAsync() : null; + + // Warmup the connection. + int streamId1 = await connection.ReadRequestHeaderAsync(); + await connection.SendDefaultResponseAsync(streamId1); + + // 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) + { + ping = await receivePingTask; + } + else + { + ping = await connection.ReadPingAsync(pingTimeout); + } + await Task.Delay(pongDelay); + + 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 + { + await Assert.ThrowsAsync(() => connection.ReadPingAsync(pingTimeout)); + } + serverFinished.SetResult(); + await connection.WaitForClientDisconnectAsync(true); + }); + } + [OuterLoop("Uses Task.Delay")] [ConditionalFact(nameof(SupportsAlpn))] public async Task Http2_MaxConcurrentStreams_LimitEnforced() diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index b58fc6bc788c7..49266bd125ff0 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -136,7 +136,7 @@ public async Task CustomConnectionFactory_AsyncRequest_Success() { using HttpClientHandler handler = CreateHttpClientHandler(); handler.ServerCertificateCustomValidationCallback = TestHelper.AllowAllCertificates; - + var socketsHandler = (SocketsHttpHandler)GetUnderlyingSocketsHttpHandler(handler); socketsHandler.ConnectionFactory = connectionFactory; @@ -872,7 +872,7 @@ public async Task Http2GetAsync_MissingTrailer_TrailingHeadersAccepted() await connection.WriteFrameAsync(MakeDataFrame(streamId, DataBytes)); // Additional trailing header frame. - await connection.SendResponseHeadersAsync(streamId, isTrailingHeader:true, headers: TrailingHeaders, endStream : true); + await connection.SendResponseHeadersAsync(streamId, isTrailingHeader: true, headers: TrailingHeaders, endStream: true); HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -898,7 +898,7 @@ public async Task Http2GetAsync_TrailerHeaders_TrailingPseudoHeadersThrow() await connection.SendDefaultResponseHeadersAsync(streamId); await connection.WriteFrameAsync(MakeDataFrame(streamId, DataBytes)); // Additional trailing header frame with pseudo-headers again.. - await connection.SendResponseHeadersAsync(streamId, isTrailingHeader:false, headers: TrailingHeaders, endStream : true); + await connection.SendResponseHeadersAsync(streamId, isTrailingHeader: false, headers: TrailingHeaders, endStream: true); await Assert.ThrowsAsync(() => sendTask); } @@ -937,10 +937,10 @@ public async Task Http2GetAsyncResponseHeadersReadOption_TrailingHeaders_Availab // Finish data stream and write out trailing headers. await connection.WriteFrameAsync(MakeDataFrame(streamId, DataBytes)); - await connection.SendResponseHeadersAsync(streamId, endStream : true, isTrailingHeader:true, headers: TrailingHeaders); + await connection.SendResponseHeadersAsync(streamId, endStream: true, isTrailingHeader: true, headers: TrailingHeaders); // Read data until EOF is reached - while (stream.Read(data, 0, data.Length) != 0); + while (stream.Read(data, 0, data.Length) != 0) ; Assert.Equal(TrailingHeaders.Count, response.TrailingHeaders.Count()); Assert.Contains("amazingtrailer", response.TrailingHeaders.GetValues("MyCoolTrailerHeader")); @@ -962,7 +962,7 @@ public async Task Http2GetAsync_TrailerHeaders_TrailingHeaderNoBody() // Response header. await connection.SendDefaultResponseHeadersAsync(streamId); - await connection.SendResponseHeadersAsync(streamId, endStream : true, isTrailingHeader:true, headers: TrailingHeaders); + await connection.SendResponseHeadersAsync(streamId, endStream: true, isTrailingHeader: true, headers: TrailingHeaders); HttpResponseMessage response = await sendTask; Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -1302,14 +1302,14 @@ public async Task MultipleIterativeRequests_SameConnectionReused() using (var serverStream = new NetworkStream(server, ownsSocket: false)) using (var serverReader = new StreamReader(serverStream)) { - while (!string.IsNullOrWhiteSpace(await serverReader.ReadLineAsync())); + while (!string.IsNullOrWhiteSpace(await serverReader.ReadLineAsync())) ; await server.SendAsync(new ArraySegment(Encoding.ASCII.GetBytes(responseBody)), SocketFlags.None); await firstRequest; Task secondAccept = listener.AcceptAsync(); // shouldn't complete Task additionalRequest = client.GetStringAsync(uri); - while (!string.IsNullOrWhiteSpace(await serverReader.ReadLineAsync())); + while (!string.IsNullOrWhiteSpace(await serverReader.ReadLineAsync())) ; await server.SendAsync(new ArraySegment(Encoding.ASCII.GetBytes(responseBody)), SocketFlags.None); await additionalRequest; @@ -1584,7 +1584,7 @@ public async Task ProxyAuth_SameConnection_Succeeds() "Content-Length: 0\r\n" + "\r\n"; - using (var handler = new HttpClientHandler()) + using (var handler = new HttpClientHandler()) { handler.Proxy = new UseSpecifiedUriWebProxy(proxyUrl, new NetworkCredential("abc", "password")); @@ -1597,7 +1597,7 @@ await proxyServer.AcceptConnectionAsync(async connection => // Get first request, no body for GET. await connection.ReadRequestHeaderAndSendCustomResponseAsync(responseBody).ConfigureAwait(false); // Client should send another request after being rejected with 407. - await connection.ReadRequestHeaderAndSendResponseAsync(content:"OK").ConfigureAwait(false); + await connection.ReadRequestHeaderAndSendResponseAsync(content: "OK").ConfigureAwait(false); }); string response = await request; @@ -1684,6 +1684,30 @@ public void DefaultProxyCredentials_GetSet_Roundtrips() } } + [Fact] + public void KeepAlivePing_GetSet_Roundtrips() + { + using var handler = new SocketsHttpHandler(); + + var testTimeSpanValue = TimeSpan.FromSeconds(5); + var invalidTimeSpanValue = TimeSpan.FromTicks(TimeSpan.TicksPerSecond - 1); + + Assert.Equal(TimeSpan.FromSeconds(20), handler.KeepAlivePingTimeout); + handler.KeepAlivePingTimeout = testTimeSpanValue; + Assert.Equal(testTimeSpanValue, handler.KeepAlivePingTimeout); + + Assert.Equal(Timeout.InfiniteTimeSpan, handler.KeepAlivePingDelay); + handler.KeepAlivePingDelay = testTimeSpanValue; + Assert.Equal(testTimeSpanValue, handler.KeepAlivePingDelay); + + Assert.Equal(HttpKeepAlivePingPolicy.Always, handler.KeepAlivePingPolicy); + handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests; + Assert.Equal(HttpKeepAlivePingPolicy.WithActiveRequests, handler.KeepAlivePingPolicy); + + Assert.Throws(() => handler.KeepAlivePingTimeout = invalidTimeSpanValue); + Assert.Throws(() => handler.KeepAlivePingDelay = invalidTimeSpanValue); + } + [Fact] public void MaxAutomaticRedirections_GetSet_Roundtrips() { @@ -1930,6 +1954,9 @@ await Assert.ThrowsAnyAsync(() => Assert.Throws(expectedExceptionType, () => handler.SslOptions = new SslClientAuthenticationOptions()); Assert.Throws(expectedExceptionType, () => handler.UseCookies = false); Assert.Throws(expectedExceptionType, () => handler.UseProxy = false); + Assert.Throws(expectedExceptionType, () => handler.KeepAlivePingTimeout = TimeSpan.FromSeconds(5)); + Assert.Throws(expectedExceptionType, () => handler.KeepAlivePingDelay = TimeSpan.FromSeconds(5)); + Assert.Throws(expectedExceptionType, () => handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests); } } } 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 ea67ef0b9c7ce..0af91c2ddd99f 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 @@ -70,6 +70,8 @@ Link="ProductionCode\Common\System\Threading\Tasks\TaskToApm.cs" /> + +