From 4b6bd6da2af2d5e000501f4ca2f7f52dae15cf97 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Tue, 30 Jun 2026 07:19:29 +0200 Subject: [PATCH 01/11] Add support for user-controlled HTTP connection eviction --- docs/project/list-of-diagnostics.md | 1 + .../Common/src/System/Experimentals.cs | 3 + .../System.Net.Http/ref/System.Net.Http.cs | 14 + .../src/System.Net.Http.csproj | 7 + .../BrowserHttpHandler/SocketsHttpHandler.cs | 7 + .../HttpConnectionPool.Http1.cs | 13 +- .../HttpConnectionPool.Http2.cs | 16 +- .../ConnectionPool/HttpConnectionPool.cs | 139 ++++++- .../SocketsHttpHandler/Http2Connection.cs | 4 +- .../SocketsHttpHandler/Http3Connection.cs | 2 +- .../Http/SocketsHttpHandler/HttpConnection.cs | 5 +- .../SocketsHttpHandler/HttpConnectionBase.cs | 126 +++++- .../HttpConnectionPoolManager.cs | 12 + .../HttpConnectionSettings.cs | 3 + .../SocketsHttpConnectionContext.cs | 17 +- .../SocketsHttpConnectionEvictionContext.cs | 74 ++++ .../SocketsHttpHandler/SocketsHttpHandler.cs | 35 ++ .../FunctionalTests/SocketsHttpHandlerTest.cs | 365 ++++++++++++++++++ .../System.Net.Http.Functional.Tests.csproj | 2 + 19 files changed, 809 insertions(+), 36 deletions(-) create mode 100644 src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs diff --git a/docs/project/list-of-diagnostics.md b/docs/project/list-of-diagnostics.md index 9f312d97c5aa18..1804440f4a510d 100644 --- a/docs/project/list-of-diagnostics.md +++ b/docs/project/list-of-diagnostics.md @@ -320,3 +320,4 @@ Diagnostic id values for experimental APIs must not be recycled, as that could s | __`SYSLIB5004`__ | .NET 9 | TBD | `X86Base.DivRem` is experimental since performance is not as optimized as `T.DivRem` | | __`SYSLIB5005`__ | .NET 9 | .NET 10 | `System.Formats.Nrbf` is experimental | | __`SYSLIB5006`__ | .NET 10 | TBD | Types for Post-Quantum Cryptography (PQC) are experimental. | +| __`SYSLIB5007`__ | .NET 11 | TBD | `SocketsHttpHandler` connection eviction control APIs are experimental. | diff --git a/src/libraries/Common/src/System/Experimentals.cs b/src/libraries/Common/src/System/Experimentals.cs index caeea798d6654d..a009bd665825c6 100644 --- a/src/libraries/Common/src/System/Experimentals.cs +++ b/src/libraries/Common/src/System/Experimentals.cs @@ -33,6 +33,9 @@ internal static class Experimentals // Types for Post-Quantum Cryptography (PQC) are experimental. internal const string PostQuantumCryptographyDiagId = "SYSLIB5006"; + // SocketsHttpHandler connection eviction control APIs are experimental. + internal const string SocketsHttpHandlerConnectionEvictionDiagId = "SYSLIB5007"; + // When adding a new diagnostic ID, add it to the table in docs\project\list-of-diagnostics.md as well. // Keep new const identifiers above this comment. } 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 2eb4200dc536d3..49bddaee2bf464 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -424,9 +424,21 @@ protected override void SerializeToStream(System.IO.Stream stream, System.Net.Tr public sealed partial class SocketsHttpConnectionContext { internal SocketsHttpConnectionContext() { } + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + public long ConnectionId { get { throw null; } } public System.Net.DnsEndPoint DnsEndPoint { get { throw null; } } public System.Net.Http.HttpRequestMessage InitialRequestMessage { get { throw null; } } } + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + public sealed partial class SocketsHttpConnectionEvictionContext + { + internal SocketsHttpConnectionEvictionContext() { } + public System.TimeSpan Age { get { throw null; } } + public long ConnectionId { get { throw null; } } + public System.Net.DnsEndPoint DnsEndPoint { get { throw null; } } + public System.Version HttpVersion { get { throw null; } } + public System.Net.IPEndPoint? RemoteEndPoint { get { throw null; } } + } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")] public sealed partial class SocketsHttpHandler : System.Net.Http.HttpMessageHandler { @@ -465,6 +477,8 @@ public SocketsHttpHandler() { } public System.Net.Http.HeaderEncodingSelector? RequestHeaderEncodingSelector { get { throw null; } set { } } public System.TimeSpan ResponseDrainTimeout { get { throw null; } set { } } public System.Net.Http.HeaderEncodingSelector? ResponseHeaderEncodingSelector { get { throw null; } set { } } + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + public System.Func>? ShouldEvictConnection { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public System.Net.Security.SslClientAuthenticationOptions SslOptions { get { throw null; } set { } } public bool UseCookies { get { throw null; } set { } } 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 18c9177a49753b..415ae07228e70a 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -4,6 +4,8 @@ $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-linux;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-freebsd;$(NetCoreAppCurrent)-openbsd;$(NetCoreAppCurrent)-maccatalyst;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-tvos;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent)-illumos;$(NetCoreAppCurrent)-solaris;$(NetCoreAppCurrent)-haiku;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent) true $(DefineConstants);HTTP_DLL + + $(NoWarn);SYSLIB5007 false @@ -160,6 +162,8 @@ Link="Common\System\Text\ValueStringBuilder.AppendSpanFormattable.cs" /> + @@ -218,6 +222,7 @@ + @@ -434,6 +439,7 @@ Link="Common\System\Net\HttpStatusDescription.cs" /> + @@ -449,6 +455,7 @@ Link="Common\System\Net\HttpStatusDescription.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 955375e3317933..b75dd007e2a055 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 @@ -213,5 +213,12 @@ public Func throw new PlatformNotSupportedException(); set => throw new PlatformNotSupportedException(); } + + [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + public Func>? ShouldEvictConnection + { + get => throw new PlatformNotSupportedException(); + set => throw new PlatformNotSupportedException(); + } } } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs index 32bac6218eec0e..27a4234c190c02 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs @@ -293,14 +293,14 @@ private async Task InjectNewHttp11ConnectionAsync(RequestQueue.Q internal async ValueTask CreateHttp11ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken) { - (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(request, async, isForHttp2: false, cancellationToken).ConfigureAwait(false); - return await ConstructHttp11ConnectionAsync(async, stream, transportContext, request, activity, remoteEndPoint, cancellationToken).ConfigureAwait(false); + (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId) = await ConnectAsync(request, async, isForHttp2: false, cancellationToken).ConfigureAwait(false); + return await ConstructHttp11ConnectionAsync(async, stream, transportContext, request, activity, remoteEndPoint, connectionId, cancellationToken).ConfigureAwait(false); } - private async ValueTask ConstructHttp11ConnectionAsync(bool async, Stream stream, TransportContext? transportContext, HttpRequestMessage request, Activity? activity, IPEndPoint? remoteEndPoint, CancellationToken cancellationToken) + private async ValueTask ConstructHttp11ConnectionAsync(bool async, Stream stream, TransportContext? transportContext, HttpRequestMessage request, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId, CancellationToken cancellationToken) { Stream newStream = await ApplyPlaintextFilterAsync(async, stream, HttpVersion.Version11, request, cancellationToken).ConfigureAwait(false); - return new HttpConnection(this, newStream, transportContext, activity, remoteEndPoint); + return new HttpConnection(this, newStream, transportContext, activity, remoteEndPoint, connectionId); } private void HandleHttp11ConnectionFailure(HttpConnectionWaiter? requestWaiter, Exception e) @@ -368,6 +368,11 @@ private void ReturnHttp11Connection(HttpConnection connection) { connection.MarkConnectionAsIdle(); + // Eviction callback checks are normally triggered from a background timer that looks at all idle connections. + // If this connection was in use during that time, the eviction callback may have been skipped for it. + // Check whether that's the case now by comparing the last EvictionGeneration of the connection with that of the pool. + connection.RunEvictionEvaluationIfNeeded(); + // The fast path when there are enough connections and no pending requests // is that we'll see _http11RequestQueueIsEmptyAndNotDisposed being true both // times, and all we'll have to do as part of returning the connection is diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs index 3141ba44a8d628..9c37ddebb562b4 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs @@ -185,7 +185,7 @@ private async Task InjectNewHttp2ConnectionAsync(RequestQueue. CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource(waiter); try { - (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(queueItem.Request, true, isForHttp2: true, cts.Token).ConfigureAwait(false); + (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId) = await ConnectAsync(queueItem.Request, true, isForHttp2: true, cts.Token).ConfigureAwait(false); if (IsSecure) { @@ -202,19 +202,19 @@ private async Task InjectNewHttp2ConnectionAsync(RequestQueue. } else { - connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, activity, remoteEndPoint, cts.Token).ConfigureAwait(false); + connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, activity, remoteEndPoint, connectionId, cts.Token).ConfigureAwait(false); } } else { // We established an SSL connection, but the server denied our request for HTTP2. - await HandleHttp11Downgrade(queueItem.Request, stream, transportContext, activity, remoteEndPoint, cts.Token).ConfigureAwait(false); + await HandleHttp11Downgrade(queueItem.Request, stream, transportContext, activity, remoteEndPoint, connectionId, cts.Token).ConfigureAwait(false); return; } } else { - connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, activity, remoteEndPoint, cts.Token).ConfigureAwait(false); + connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, activity, remoteEndPoint, connectionId, cts.Token).ConfigureAwait(false); } } catch (Exception e) @@ -244,11 +244,11 @@ private async Task InjectNewHttp2ConnectionAsync(RequestQueue. } } - private async ValueTask ConstructHttp2ConnectionAsync(Stream stream, HttpRequestMessage request, Activity? activity, IPEndPoint? remoteEndPoint, CancellationToken cancellationToken) + private async ValueTask ConstructHttp2ConnectionAsync(Stream stream, HttpRequestMessage request, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId, CancellationToken cancellationToken) { stream = await ApplyPlaintextFilterAsync(async: true, stream, HttpVersion.Version20, request, cancellationToken).ConfigureAwait(false); - Http2Connection http2Connection = new Http2Connection(this, stream, activity, remoteEndPoint); + Http2Connection http2Connection = new Http2Connection(this, stream, activity, remoteEndPoint, connectionId); try { await http2Connection.SetupAsync(cancellationToken).ConfigureAwait(false); @@ -299,7 +299,7 @@ internal void OnSessionAuthenticationChallengeSeen() _http2SessionAuthSeen = true; } - private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint, CancellationToken cancellationToken) + private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId, CancellationToken cancellationToken) { if (NetEventSource.Log.IsEnabled()) Trace("Server does not support HTTP2; disabling HTTP2 use and proceeding with HTTP/1.1 connection"); @@ -357,7 +357,7 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stre try { // Note, the same CancellationToken from the original HTTP2 connection establishment still applies here. - http11Connection = await ConstructHttp11ConnectionAsync(true, stream, transportContext, request, activity, remoteEndPoint, cancellationToken).ConfigureAwait(false); + http11Connection = await ConstructHttp11ConnectionAsync(true, stream, transportContext, request, activity, remoteEndPoint, connectionId, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException oce) when (oce.CancellationToken == cancellationToken) { diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs index 4eec4dd1012ebf..334727065e1637 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs @@ -85,9 +85,12 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK _maxHttp11Connections = Settings._maxConnectionsPerServer; _telemetryServerAddress = telemetryServerAddress; - // The only case where 'host' will not be set is if this is a Proxy connection pool. + // The only case where 'host' will not be set is if this is a Proxy connection pool. In that case the + // connection targets the proxy itself, so use the proxy's host and port for the origin authority. Debug.Assert(host is not null || (kind == HttpConnectionKind.Proxy && proxyUri is not null)); - _originAuthority = new HttpAuthority(host ?? proxyUri!.IdnHost, port); + _originAuthority = host is not null + ? new HttpAuthority(host, port) + : new HttpAuthority(proxyUri!.IdnHost, proxyUri.Port); _http2Enabled = _poolManager.Settings._maxHttpVersion >= HttpVersion.Version20; @@ -560,13 +563,18 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn } } - private async ValueTask<(Stream, TransportContext?, Activity?, IPEndPoint?)> ConnectAsync(HttpRequestMessage request, bool async, bool isForHttp2, CancellationToken cancellationToken) + private async ValueTask<(Stream, TransportContext?, Activity?, IPEndPoint?, long)> ConnectAsync(HttpRequestMessage request, bool async, bool isForHttp2, CancellationToken cancellationToken) { Stream? stream = null; IPEndPoint? remoteEndPoint = null; Exception? exception = null; TransportContext? transportContext = null; + // Allocate the connection id up front so it can be surfaced to a custom ConnectCallback (via + // SocketsHttpConnectionContext) and reused as the final connection's Id, allowing the caller to + // correlate connect-time state with the connection (e.g. in the ShouldEvictConnection callback). + long connectionId = HttpConnectionBase.GetNextConnectionId(); + Activity? activity = ConnectionSetupDistributedTracing.StartConnectionSetupActivity(IsSecure, _telemetryServerAddress, OriginAuthority.Port); try @@ -576,7 +584,7 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn case HttpConnectionKind.Http: case HttpConnectionKind.Https: case HttpConnectionKind.ProxyConnect: - stream = await ConnectToTcpHostAsync(_originAuthority.IdnHost, _originAuthority.Port, request, async, cancellationToken).ConfigureAwait(false); + stream = await ConnectToTcpHostAsync(_originAuthority.IdnHost, _originAuthority.Port, request, async, connectionId, cancellationToken).ConfigureAwait(false); // remoteEndPoint is returned for diagnostic purposes. remoteEndPoint = GetRemoteEndPoint(stream); if (_kind == HttpConnectionKind.ProxyConnect && _sslOptionsProxy != null) @@ -586,7 +594,7 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn break; case HttpConnectionKind.Proxy: - stream = await ConnectToTcpHostAsync(_proxyUri!.IdnHost, _proxyUri.Port, request, async, cancellationToken).ConfigureAwait(false); + stream = await ConnectToTcpHostAsync(_proxyUri!.IdnHost, _proxyUri.Port, request, async, connectionId, cancellationToken).ConfigureAwait(false); // remoteEndPoint is returned for diagnostic purposes. remoteEndPoint = GetRemoteEndPoint(stream); if (_sslOptionsProxy != null) @@ -608,7 +616,7 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn case HttpConnectionKind.SocksTunnel: case HttpConnectionKind.SslSocksTunnel: - stream = await EstablishSocksTunnel(request, async, cancellationToken).ConfigureAwait(false); + stream = await EstablishSocksTunnel(request, async, connectionId, cancellationToken).ConfigureAwait(false); // remoteEndPoint is returned for diagnostic purposes. remoteEndPoint = GetRemoteEndPoint(stream); break; @@ -647,12 +655,12 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn } } - return (stream, transportContext, activity, remoteEndPoint); + return (stream, transportContext, activity, remoteEndPoint, connectionId); static IPEndPoint? GetRemoteEndPoint(Stream stream) => (stream as NetworkStream)?.Socket?.RemoteEndPoint as IPEndPoint; } - private async ValueTask ConnectToTcpHostAsync(string host, int port, HttpRequestMessage initialRequest, bool async, CancellationToken cancellationToken) + private async ValueTask ConnectToTcpHostAsync(string host, int port, HttpRequestMessage initialRequest, bool async, long connectionId, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -663,7 +671,7 @@ private async ValueTask ConnectToTcpHostAsync(string host, int port, Htt // If a ConnectCallback was supplied, use that to establish the connection. if (Settings._connectCallback != null) { - ValueTask streamTask = Settings._connectCallback(new SocketsHttpConnectionContext(endPoint, initialRequest), cancellationToken); + ValueTask streamTask = Settings._connectCallback(new SocketsHttpConnectionContext(endPoint, initialRequest, connectionId), cancellationToken); if (!async && !streamTask.IsCompleted) { @@ -805,11 +813,11 @@ private async ValueTask EstablishProxyTunnelAsync(bool async, Cancellati } } - private async ValueTask EstablishSocksTunnel(HttpRequestMessage request, bool async, CancellationToken cancellationToken) + private async ValueTask EstablishSocksTunnel(HttpRequestMessage request, bool async, long connectionId, CancellationToken cancellationToken) { Debug.Assert(_proxyUri != null); - Stream stream = await ConnectToTcpHostAsync(_proxyUri.IdnHost, _proxyUri.Port, request, async, cancellationToken).ConfigureAwait(false); + Stream stream = await ConnectToTcpHostAsync(_proxyUri.IdnHost, _proxyUri.Port, request, async, connectionId, cancellationToken).ConfigureAwait(false); try { @@ -887,10 +895,15 @@ private bool CheckExpirationOnGet(HttpConnectionBase connection) { Debug.Assert(!HasSyncObjLock); + if (connection.MarkedForEviction) + { + return true; + } + TimeSpan pooledConnectionLifetime = _poolManager.Settings._pooledConnectionLifetime; if (pooledConnectionLifetime != Timeout.InfiniteTimeSpan) { - return connection.GetLifetimeTicks(Environment.TickCount64) > pooledConnectionLifetime.TotalMilliseconds; + return connection.Age > pooledConnectionLifetime; } return false; @@ -898,15 +911,106 @@ private bool CheckExpirationOnGet(HttpConnectionBase connection) private bool CheckExpirationOnReturn(HttpConnectionBase connection) { + if (connection.MarkedForEviction) + { + return true; + } + TimeSpan lifetime = _poolManager.Settings._pooledConnectionLifetime; if (lifetime != Timeout.InfiniteTimeSpan) { - return lifetime == TimeSpan.Zero || connection.GetLifetimeTicks(Environment.TickCount64) > lifetime.TotalMilliseconds; + return lifetime == TimeSpan.Zero || connection.Age > lifetime; } return false; } + /// Guards against overlapping runs for this pool. + private bool _evictionEvaluationInProgress; + + /// + /// Incremented at the start of each eviction evaluation pass. Connections record the generation at which they + /// were last evaluated, so an HTTP/1.1 connection that was busy during a pass (and therefore not visible to it) + /// can be re-evaluated in the background when it is returned to the pool. + /// + internal int EvictionGeneration { get; private set; } + + /// + /// Invokes the user-supplied callback for each + /// pooled connection and marks for eviction those the callback selects. List snapshots are taken under + /// the pool lock; the callback itself is always awaited outside the lock. + /// + private async Task EvaluateConnectionsForEvictionAsync() + { + Debug.Assert(!HasSyncObjLock); + + if (Interlocked.Exchange(ref _evictionEvaluationInProgress, true)) + { + return; + } + + EvictionGeneration++; + + try + { + // HTTP/1.1: the idle stack is lock-free and its enumerator returns a snapshot, so we can inspect + // connections without removing them. Connections currently in use (and therefore not on the stack) + // are evaluated by ReturnHttp11Connection when they are returned to the pool. + foreach (HttpConnection connection in _http11Connections) + { + await connection.EvaluateForEvictionAsync().ConfigureAwait(false); + } + + // HTTP/2: snapshot the available list under the lock, then evaluate outside of it. + Http2Connection[]? http2Connections = null; + lock (SyncObj) + { + if (_availableHttp2Connections is { Count: > 0 } http2) + { + http2Connections = http2.ToArray(); + } + } + + if (http2Connections is not null) + { + foreach (Http2Connection connection in http2Connections) + { + await connection.EvaluateForEvictionAsync().ConfigureAwait(false); + } + } + + if (GlobalHttpSettings.SocketsHttpHandler.AllowHttp3) + { + Http3Connection[]? http3Connections = null; + lock (SyncObj) + { + if (_availableHttp3Connections is { Count: > 0 } http3) + { + http3Connections = http3.ToArray(); + } + } + + if (http3Connections is not null) + { + foreach (Http3Connection connection in http3Connections) + { + await connection.EvaluateForEvictionAsync().ConfigureAwait(false); + } + } + } + } + catch (Exception e) + { + Debug.Fail($"Unexpected exception while evaluating connections for eviction: {e}"); + + if (NetEventSource.Log.IsEnabled()) Trace($"Unexpected exception while evaluating connections for eviction: {e}"); + } + finally + { + _evictionEvaluationInProgress = false; + } + } + /// /// Disposes the connection pool. This is only needed when the pool currently contains /// or has associated connections. @@ -981,6 +1085,15 @@ public bool CleanCacheAndDisposeIfUnused() TimeSpan pooledConnectionIdleTimeout = _poolManager.Settings._pooledConnectionIdleTimeout; long nowTicks = Environment.TickCount64; + // If the user supplied an eviction callback, give them a chance to mark pooled connections for + // eviction before we scavenge. The callback is asynchronous and may be slow (e.g. perform a DNS + // lookup), so it runs off the maintenance timer thread; connections it evicts are retired by a later + // scavenge pass or by the get/return paths. + if (_poolManager.Settings._shouldEvictConnection is not null) + { + _ = EvaluateConnectionsForEvictionAsync(); + } + List? toDispose = null; lock (SyncObj) 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 cc53c81c94e7d6..5ce339e37d3a5c 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 @@ -131,8 +131,8 @@ internal enum KeepAliveState private long _keepAlivePingTimeoutTimestamp; private volatile KeepAliveState _keepAliveState; - public Http2Connection(HttpConnectionPool pool, Stream stream, Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint) - : base(pool, connectionSetupActivity, remoteEndPoint) + public Http2Connection(HttpConnectionPool pool, Stream stream, Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint, long connectionId) + : base(pool, connectionId, connectionSetupActivity, remoteEndPoint) { _stream = stream; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs index ebe52071a870bd..58c275e8a668fa 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs @@ -70,7 +70,7 @@ private bool ShuttingDown } public Http3Connection(HttpConnectionPool pool, HttpAuthority authority, bool includeAltUsedHeader) - : base(pool) + : base(pool, GetNextConnectionId()) { _authority = authority; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs index 5044c561fbf54b..7a002057649262 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs @@ -74,8 +74,9 @@ public HttpConnection( Stream stream, TransportContext? transportContext, Activity? connectionSetupActivity, - IPEndPoint? remoteEndPoint) - : base(pool, connectionSetupActivity, remoteEndPoint) + IPEndPoint? remoteEndPoint, + long connectionId) + : base(pool, connectionId, connectionSetupActivity, remoteEndPoint) { Debug.Assert(stream != null); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs index d92131e892a0a4..c87ce0af12f13f 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -31,31 +30,86 @@ internal abstract class HttpConnectionBase : IDisposable, IHttpTrace private readonly long _creationTickCount = Environment.TickCount64; private long? _idleSinceTickCount; + /// + /// The context passed to the callback. + /// A single instance is reused to avoid allocating a new context for each eviction check. + /// + private SocketsHttpConnectionEvictionContext? _evictionContext; + + /// + /// Allocated at establishment only when + /// is configured, and canceled (but not disposed, so handed-out tokens stay valid) when the connection closes. + /// + private CancellationTokenSource? _connectionDisposalCts; + + /// + /// The at which this connection was last evaluated by the + /// callback. + /// + private int _lastEvictionGeneration; + + /// + /// Set while the callback is running for this connection. + /// Ensures the callback is invoked for at most one caller at a time for a given connection. + /// + private bool _evictionCallbackInProgress; + + private volatile bool _markedForEviction; + /// Cached string for the last Date header received on this connection. private string? _lastDateHeaderValue; /// Cached string for the last Server header received on this connection. private string? _lastServerHeaderValue; - public long Id { get; } = Interlocked.Increment(ref s_connectionCounter); + /// Whether the connection has been marked for eviction by and should no longer be used for new requests. + public bool MarkedForEviction => _markedForEviction; + + public long Id { get; } public Activity? ConnectionSetupActivity { get; private set; } - public HttpConnectionBase(HttpConnectionPool pool) + public HttpConnectionBase(HttpConnectionPool pool, long connectionId) { Debug.Assert(this is HttpConnection or Http2Connection or Http3Connection); Debug.Assert(pool != null); _pool = pool; + Id = connectionId; } - public HttpConnectionBase(HttpConnectionPool pool, Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint) - : this(pool) + public HttpConnectionBase(HttpConnectionPool pool, long connectionId, Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint) + : this(pool, connectionId) { MarkConnectionAsEstablished(connectionSetupActivity, remoteEndPoint); } + /// Allocates the next unique connection id. Generated before the connection object exists so that + /// the same id can be surfaced to and telemetry. + internal static long GetNextConnectionId() => Interlocked.Increment(ref s_connectionCounter); + protected void MarkConnectionAsEstablished(Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint) { ConnectionSetupActivity = connectionSetupActivity; + + // Baseline the eviction generation to the pool's current value so a freshly established connection isn't + // immediately re-evaluated; it becomes eligible once the next maintenance pass advances the generation. + _lastEvictionGeneration = _pool.EvictionGeneration; + + // Only the ShouldEvictConnection callback consumes the disposal token, so the source is created only when + // such a callback is configured. + if (_pool.Settings._shouldEvictConnection is not null) + { + _connectionDisposalCts = new CancellationTokenSource(); + + HttpAuthority authority = _pool.OriginAuthority; + + _evictionContext = new SocketsHttpConnectionEvictionContext( + new DnsEndPoint(authority.IdnHost, authority.Port), + remoteEndPoint, + Id, + this is HttpConnection ? HttpVersion.Version11 : this is Http2Connection ? HttpVersion.Version20 : HttpVersion.Version30, + _creationTickCount); + } + if (GlobalHttpSettings.MetricsHandler.IsGloballyEnabled) { Debug.Assert(_pool.Settings._metrics is not null); @@ -100,6 +154,10 @@ protected void MarkConnectionAsEstablished(Activity? connectionSetupActivity, IP public void MarkConnectionAsClosed() { + // Cancel the disposal token used by the ShouldEvictConnection callback. The source is intentionally not + // disposed so tokens already handed to a running callback stay valid (and observe the cancellation). + _connectionDisposalCts?.Cancel(); + if (GlobalHttpSettings.MetricsHandler.IsGloballyEnabled) _connectionMetrics?.ConnectionClosed(durationMs: Environment.TickCount64 - _creationTickCount); if (HttpTelemetry.Log.IsEnabled()) @@ -170,8 +228,59 @@ protected void TraceConnection(Stream stream) public long GetLifetimeTicks(long nowTicks) => nowTicks - _creationTickCount; + /// The amount of time that has elapsed since the connection was established. + internal TimeSpan Age => TimeSpan.FromMilliseconds(GetLifetimeTicks(Environment.TickCount64)); + public long GetIdleTicks(long nowTicks) => _idleSinceTickCount is long idleSinceTickCount ? nowTicks - idleSinceTickCount : 0; + public void RunEvictionEvaluationIfNeeded() + { + if (_pool.EvictionGeneration != _lastEvictionGeneration) + { + _ = EvaluateForEvictionAsync(); + } + } + + /// + /// Runs the callback for this connection + /// and marks the connection for eviction if the callback requests it. The callback runs at most + /// once at a time for this connection. + /// + public async Task EvaluateForEvictionAsync() + { + Debug.Assert(_pool.Settings._shouldEvictConnection is not null); + Debug.Assert(_connectionDisposalCts is not null); + Debug.Assert(_evictionContext is not null); + + // There's a benign race condition here where we might run the callback more than once for a given generation. + if (Interlocked.Exchange(ref _evictionCallbackInProgress, true) || + MarkedForEviction || + _connectionDisposalCts.IsCancellationRequested) + { + return; + } + + try + { + if (await _pool.Settings._shouldEvictConnection(_evictionContext, _connectionDisposalCts.Token).ConfigureAwait(false)) + { + _markedForEviction = true; + + if (NetEventSource.Log.IsEnabled()) Trace("Marking connection for eviction per ShouldEvictConnection callback."); + } + } + catch (Exception e) + { + // Don't let a misbehaving user callback take down pool maintenance. + if (NetEventSource.Log.IsEnabled()) Trace($"{nameof(SocketsHttpHandler.ShouldEvictConnection)} threw an exception: {e}"); + } + finally + { + _lastEvictionGeneration = _pool.EvictionGeneration; + _evictionCallbackInProgress = false; + } + } + /// Check whether a connection is still usable, or should be scavenged. /// True if connection can be used. public virtual bool CheckUsabilityOnScavenge() => true; @@ -223,6 +332,13 @@ static void LogFaulted(HttpConnectionBase connection, Task task) /// public bool IsUsable(long nowTicks, TimeSpan pooledConnectionLifetime, TimeSpan pooledConnectionIdleTimeout) { + // The connection may have been marked for eviction by the ShouldEvictConnection callback. + if (MarkedForEviction) + { + if (NetEventSource.Log.IsEnabled()) Trace("Scavenging connection. Connection was evicted."); + return false; + } + // Validate that the connection hasn't been idle in the pool for longer than is allowed. if (pooledConnectionIdleTimeout != Timeout.InfiniteTimeSpan) { 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 b99f9e719c897e..622ea13ebe69a2 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 @@ -94,6 +94,18 @@ public HttpConnectionPoolManager(HttpConnectionSettings settings) _cleanPoolTimeout = timerPeriod.TotalSeconds >= MinScavengeSeconds ? timerPeriod : TimeSpan.FromSeconds(MinScavengeSeconds); } + // The connection eviction callback is invoked from this timer. If one is set, make sure the timer + // fires at least this often so eviction decisions happen on a predictable cadence, regardless of + // how large (or infinite) the idle timeout is, which would otherwise drive the period alone. + if (settings._shouldEvictConnection is not null) + { + const int MaxEvictionIntervalSeconds = 5; + if (_cleanPoolTimeout.TotalSeconds > MaxEvictionIntervalSeconds) + { + _cleanPoolTimeout = TimeSpan.FromSeconds(MaxEvictionIntervalSeconds); + } + } + using (ExecutionContext.SuppressFlow()) // Don't capture the current ExecutionContext and its AsyncLocals onto the timer causing them to live forever { // Create the timer. Ensure the Timer has a weak reference to this manager; otherwise, it 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 b4006543504d70..547726076809f5 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 @@ -66,6 +66,8 @@ internal sealed class HttpConnectionSettings internal Func>? _connectCallback; internal Func>? _plaintextStreamFilter; + internal Func>? _shouldEvictConnection; + internal IDictionary? _properties; // Http2 flow control settings: @@ -126,6 +128,7 @@ public HttpConnectionSettings CloneAndNormalize() _enableMultipleHttp3Connections = _enableMultipleHttp3Connections, _connectCallback = _connectCallback, _plaintextStreamFilter = _plaintextStreamFilter, + _shouldEvictConnection = _shouldEvictConnection, _initialHttp2StreamWindowSize = _initialHttp2StreamWindowSize, _activityHeadersPropagator = _activityHeadersPropagator, _defaultCredentialsUsedForProxy = _proxy != null && (_proxy.Credentials == CredentialCache.DefaultCredentials || _defaultProxyCredentials == CredentialCache.DefaultCredentials), diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs index 21f8fe7d07831d..4e3dfb4be32690 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs @@ -1,6 +1,9 @@ // 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.CodeAnalysis; +using System.Diagnostics.Tracing; + namespace System.Net.Http { /// @@ -10,11 +13,13 @@ public sealed class SocketsHttpConnectionContext { private readonly DnsEndPoint _dnsEndPoint; private readonly HttpRequestMessage _initialRequestMessage; + private readonly long _connectionId; - internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessage initialRequestMessage) + internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessage initialRequestMessage, long connectionId) { _dnsEndPoint = dnsEndPoint; _initialRequestMessage = initialRequestMessage; + _connectionId = connectionId; } /// @@ -26,5 +31,15 @@ internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessag /// The initial HttpRequestMessage that is causing the connection to be created. /// public HttpRequestMessage InitialRequestMessage => _initialRequestMessage; + + /// + /// The identifier that will be assigned to the connection being established. This matches the connection id + /// reported through telemetry, and the + /// passed to + /// . It can be used to associate caller state (for + /// example, the resolved address used) with the connection so it can be recovered when deciding on eviction. + /// + [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + public long ConnectionId => _connectionId; } } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs new file mode 100644 index 00000000000000..90ab5c26940143 --- /dev/null +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs @@ -0,0 +1,74 @@ +// 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.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace System.Net.Http +{ + /// + /// Represents the context passed to when a pooled + /// connection is being considered for eviction. + /// + /// + /// The instance is only valid for the duration of the callback invocation; it must not be cached or used after + /// the callback returns. reflects the elapsed time at the moment it is read. + /// + [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + public sealed class SocketsHttpConnectionEvictionContext + { + private readonly long _creationTickCount; + + internal SocketsHttpConnectionEvictionContext( + DnsEndPoint dnsEndPoint, + IPEndPoint? remoteEndPoint, + long connectionId, + Version httpVersion, + long creationTickCount) + { + DnsEndPoint = dnsEndPoint; + RemoteEndPoint = remoteEndPoint; + ConnectionId = connectionId; + HttpVersion = httpVersion; + _creationTickCount = creationTickCount; + } + + /// + /// Gets the identifying the origin (host and port) the connection targets. + /// + /// + /// This is the logical destination the connection was created for, not necessarily the host the + /// transport is physically connected to (for example, when a proxy is in use). Use it together with + /// to decide whether the connection still points at a desired address. + /// + public DnsEndPoint DnsEndPoint { get; } + + /// + /// Gets the remote the connection's transport is connected to, when available. + /// + /// + /// This is when the remote endpoint is not known, for example when a custom + /// returned a stream that is not backed by a socket. + /// + public IPEndPoint? RemoteEndPoint { get; } + + /// + /// Gets the identifier assigned to the connection. This matches the connection id reported through + /// telemetry, and the + /// seen by a custom + /// , allowing the decision to be correlated with state the + /// caller associated with the connection at creation time. + /// + public long ConnectionId { get; } + + /// + /// Gets the HTTP version negotiated for the connection (for example, 1.1, 2.0, or 3.0). + /// + public Version HttpVersion { get; } + + /// + /// Gets the amount of time that has elapsed since the connection was established. + /// + public TimeSpan Age => TimeSpan.FromMilliseconds(Environment.TickCount64 - _creationTickCount); + } +} 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 94a3043e913aeb..77b4b812355efc 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 @@ -426,6 +426,41 @@ public Func + /// Gets or sets a callback that decides whether a pooled connection should be evicted. + /// + /// + /// + /// When set, the callback is invoked for pooled connections with a + /// describing the connection. Returning + /// marks the connection for eviction: it will not be used to serve new requests and is retired once it + /// becomes idle (an in-flight request on the connection is allowed to complete first). + /// + /// + /// The callback is not guaranteed to run for every request, and it may run concurrently with the connection + /// serving requests as well as concurrently for different connections. Because it is asynchronous, a caller + /// may perform work such as a name resolution inside it; however, it is invoked for each pooled connection, so + /// keeping it inexpensive (for example, consulting a cached resolution result) is recommended. The supplied + /// is canceled if the connection is disposed while the callback is running. + /// + /// + /// This callback complements . Because a caller can use it to evict + /// connections in response to their own DNS resolution, it makes it possible to set + /// to and + /// retain otherwise healthy connections rather than recycling them purely to observe address changes. + /// + /// + [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + public Func>? ShouldEvictConnection + { + get => _settings._shouldEvictConnection; + set + { + CheckDisposedOrStarted(); + _settings._shouldEvictConnection = value; + } + } + /// /// Gets a writable dictionary (that is, a map) of custom properties for the HttpClient requests. The dictionary is initialized empty; you can insert and query key-value pairs for your custom handlers and special processing. /// diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index e0758a3704dcf9..629c3791fcdf9b 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -2045,6 +2045,355 @@ public async Task MultipleIterativeRequests_SameConnectionReused() } } + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ShouldEvictConnection_CallbackReturnsTrue_ConnectionEvictedAndReplaced(bool useAsyncCallback) + { + var firstConnectionEvicted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstConnectionDisposalTokenCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + SocketsHttpConnectionEvictionContext capturedContext = null; + Uri serverUri = null; + var connectCallbackIds = new System.Collections.Concurrent.ConcurrentBag(); + + using var handler = new SocketsHttpHandler + { + // Drive the pool maintenance timer (which invokes the eviction callback) to fire roughly once a + // second, while keeping the idle timeout long enough that idle expiry can't be what removes the + // connection during the test. Disable lifetime entirely so only eviction can retire a connection. + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4), + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + }; + + // The connection id surfaced to ConnectCallback is the same one passed to the eviction callback, + // so the caller can associate connect-time state with the connection and recover it on eviction. + handler.ConnectCallback = async (context, ct) => + { + connectCallbackIds.Add(context.ConnectionId); + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new NetworkStream(socket, ownsSocket: true); + }; + + handler.ShouldEvictConnection = async (context, cancellationToken) => + { + // Only one connection is evaluated at a time in this test, so no synchronization is needed here. + if (useAsyncCallback) + { + await Task.Yield(); // Exercise the path where the callback completes asynchronously. + } + + capturedContext ??= context; + if (context.ConnectionId == capturedContext.ConnectionId) + { + // The token is canceled when the connection is disposed (i.e. once it has been retired). + cancellationToken.Register(static s => ((TaskCompletionSource)s).TrySetResult(), firstConnectionDisposalTokenCanceled); + firstConnectionEvicted.TrySetResult(); + return true; // Evict only the first connection we observe. + } + + return false; + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + serverUri = uri; + + // Connection A: serve request 1 with a keep-alive response, then block reading until the client + // tears the connection down. If anything other than eviction were to remove it, the read below + // would observe request 2 instead of EOF and the test's second accept would not be a new connection. + Task serverTaskA = server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "1"); + await connection.ReadLineAsync(); // Returns null (EOF) once the evicted connection is disposed. + }); + + Assert.Equal("1", await client.GetStringAsync(uri)); + + // Wait for the maintenance timer to invoke the eviction callback for connection A, then for the + // pool to actually retire it (serverTaskA's read completes at EOF once the client disposes it). + await firstConnectionEvicted.Task.WaitAsync(TimeSpan.FromSeconds(30)); + await serverTaskA.WaitAsync(TimeSpan.FromSeconds(30)); + + // Disposing the retired connection cancels the token that was passed to the eviction callback. + await firstConnectionDisposalTokenCanceled.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + // Request 2 must land on a brand new connection B, because A has been evicted. + Task request2 = client.GetStringAsync(uri); + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "2"); + }); + + Assert.Equal("2", await request2); + }); + + Assert.NotNull(capturedContext); + Assert.Equal(System.Net.HttpVersion.Version11, capturedContext.HttpVersion); + Assert.NotNull(capturedContext.RemoteEndPoint); + Assert.Equal(serverUri.Port, capturedContext.DnsEndPoint.Port); + Assert.Equal(serverUri.IdnHost, capturedContext.DnsEndPoint.Host); + // The evicted connection's id was observed by ConnectCallback when it was established. + Assert.Contains(capturedContext.ConnectionId, connectCallbackIds); + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_ConnectionKeptBusy_StillEvaluatedWhenReturnedToPool() + { + var connectionEvaluated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var handler = new SocketsHttpHandler + { + // A single pooled connection means concurrent requests queue behind it, so the connection is handed + // straight from one request to the next and never sits idle on the pool's idle stack where the + // maintenance pass could evaluate it. Eviction therefore has to be triggered from the connection-return + // path. Drive the maintenance timer to ~1s while disabling lifetime/idle expiry as a cause of retirement. + MaxConnectionsPerServer = 1, + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4), + }; + + handler.ShouldEvictConnection = (context, _) => + { + connectionEvaluated.TrySetResult(); + return Task.FromResult(false); // Don't evict; we only need to observe that it gets evaluated. + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + using var stop = new CancellationTokenSource(); + + // Keep the single connection continuously busy with overlapping requests so there is always one queued + // when the connection is returned, leaving it no opportunity to become idle. + Task[] clientLoops = Enumerable.Range(0, 3).Select(_ => Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try { await client.GetStringAsync(uri, stop.Token); } + catch { } + } + })).ToArray(); + + Task serverLoop = server.AcceptConnectionAsync(async connection => + { + try + { + while (!stop.IsCancellationRequested) + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "hello"); + } + } + catch { } // The connection is torn down during cleanup. + }); + + // The connection is never idle during a maintenance pass, so it can only be evaluated when it is + // returned to the pool between requests. Without that path the callback would never run here. + await connectionEvaluated.Task.WaitAsync(TimeSpan.FromSeconds(60)); + + stop.Cancel(); + await Task.WhenAny(Task.WhenAll(clientLoops), Task.Delay(TimeSpan.FromSeconds(10))); + await Task.WhenAny(serverLoop, Task.Delay(TimeSpan.FromSeconds(10))); + }); + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_ProxyConnection_DnsEndPointHasProxyHostAndPort() + { + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + SocketsHttpConnectionEvictionContext capturedContext = null; + var callbackInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var handler = new SocketsHttpHandler + { + Proxy = new WebProxy(proxyServer.Uri), + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4), + }; + + handler.ShouldEvictConnection = (context, _) => + { + capturedContext ??= context; + callbackInvoked.TrySetResult(); + return Task.FromResult(false); + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + // A plain (non-secure, non-tunneled) HTTP request through a proxy uses HttpConnectionKind.Proxy, whose + // pooled connection targets the proxy itself. The proxy forwards the request to the loopback server. + Task request = client.GetStringAsync(uri); + await server.AcceptConnectionAsync(async connection => + { + // Keep-alive response (no "Connection: close") so the client pools the connection to the proxy. + await connection.ReadRequestHeaderAndSendResponseAsync(content: "hello"); + }); + Assert.Equal("hello", await request); + + // The idle proxy connection is evaluated by the maintenance pass. + await callbackInvoked.Task.WaitAsync(TimeSpan.FromSeconds(30)); + }); + + Assert.NotNull(capturedContext); + // A forwarding proxy connection targets the proxy itself, so the context reports the proxy's host and port. + Assert.Equal(proxyServer.Uri.IdnHost, capturedContext.DnsEndPoint.Host); + Assert.Equal(proxyServer.Uri.Port, capturedContext.DnsEndPoint.Port); + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Theory] + [InlineData(false)] // The callback returns false. + [InlineData(true)] // The callback throws. + public async Task ShouldEvictConnection_CallbackDoesNotEvict_ConnectionReused(bool callbackThrows) + { + int callbackInvocations = 0; + var callbackInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var handler = new SocketsHttpHandler + { + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4), + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + }; + + handler.ShouldEvictConnection = (context, _) => + { + Interlocked.Increment(ref callbackInvocations); + callbackInvoked.TrySetResult(); + + // A throwing callback must be treated the same as one that declined to evict: the exception is + // swallowed and the connection is left in the pool. + if (callbackThrows) + { + throw new InvalidOperationException("Eviction callback failure should not affect the pool."); + } + + return Task.FromResult(false); + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + Task request1 = client.GetStringAsync(uri); + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "1"); + Assert.Equal("1", await request1); + + // Wait until the callback has been invoked at least once, proving the maintenance timer ran. + await callbackInvoked.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + // Because nothing was evicted, the second request must be served on the same connection. + Task request2 = client.GetStringAsync(uri); + await connection.ReadRequestHeaderAndSendResponseAsync(content: "2"); + Assert.Equal("2", await request2); + }); + }); + + Assert.True(callbackInvocations > 0); + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_Http2Connection_EvictedAndReplaced() + { + var firstConnectionEvicted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + SocketsHttpConnectionEvictionContext capturedContext = null; + + await Http2LoopbackServerFactory.CreateServerAsync(async (server, url) => + { + HttpClientHandler handler = CreateHttpClientHandler(HttpVersion.Version20); + SocketsHttpHandler s = (SocketsHttpHandler)GetUnderlyingSocketsHttpHandler(handler); + s.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4); + s.PooledConnectionLifetime = Timeout.InfiniteTimeSpan; + s.ShouldEvictConnection = (context, _) => + { + // Only one connection is evaluated at a time in this test, so no synchronization is needed here. + capturedContext ??= context; + if (context.ConnectionId == capturedContext.ConnectionId) + { + firstConnectionEvicted.TrySetResult(); + return Task.FromResult(true); // Evict only the first connection we observe. + } + + return Task.FromResult(false); + }; + + using HttpClient client = CreateHttpClient(handler); + client.DefaultRequestVersion = HttpVersion.Version20; + + Task request1 = client.GetStringAsync(url); + Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + int streamId = await connection.ReadRequestHeaderAsync(); + await connection.SendDefaultResponseAsync(streamId); + await request1; + + // Wait for the maintenance timer to mark the HTTP/2 connection for eviction. + await firstConnectionEvicted.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + // Detach the first connection's socket so its eviction-triggered teardown doesn't interfere. + (SocketWrapper socket, Stream stream) = connection.ResetNetwork(); + + // The second request must be served from a brand new connection because the first was evicted. + Task request2 = client.GetStringAsync(url); + connection = await server.EstablishConnectionAsync(); + streamId = await connection.ReadRequestHeaderAsync(); + await connection.SendDefaultResponseAsync(streamId); + await request2; + + await socket.CloseAsync(); + }); + + Assert.NotNull(capturedContext); + Assert.Equal(System.Net.HttpVersion.Version20, capturedContext.HttpVersion); + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_InfiniteIdleTimeout_CallbackInvokedOnMinimumCadence() + { + var callbackInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var handler = new SocketsHttpHandler + { + // With both timeouts infinite, the maintenance timer would otherwise run only on its 30s default. + // Setting the eviction callback must shorten the timer period so the callback still runs promptly. + PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + }; + + handler.ShouldEvictConnection = (context, _) => + { + callbackInvoked.TrySetResult(); + return Task.FromResult(false); // Don't evict; we only care that the callback runs. + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + Task request1 = client.GetStringAsync(uri); + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "1"); + Assert.Equal("1", await request1); + + // The connection is now pooled. The callback must be invoked well before the 30s default that + // an infinite idle timeout would otherwise impose. + await callbackInvoked.Task.WaitAsync(TimeSpan.FromSeconds(20)); + }); + }); + } + [OuterLoop("Incurs a delay")] [Fact] public async Task ServerDisconnectsAfterInitialRequest_SubsequentRequestUsesDifferentConnection() @@ -2546,6 +2895,22 @@ public void PooledConnectionLifetime_GetSet_Roundtrips() } } + [Fact] + public void ShouldEvictConnection_GetSet_Roundtrips() + { + using (var handler = new SocketsHttpHandler()) + { + Assert.Null(handler.ShouldEvictConnection); + + Func> callback = static (_, _) => Task.FromResult(false); + handler.ShouldEvictConnection = callback; + Assert.Same(callback, handler.ShouldEvictConnection); + + handler.ShouldEvictConnection = null; + Assert.Null(handler.ShouldEvictConnection); + } + } + [Fact] public void Properties_Roundtrips() { 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 28306cba768a64..86247e1f6c01ea 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 @@ -9,6 +9,8 @@ true $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-linux;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent)-osx true + + $(NoWarn);SYSLIB5007 true false From 729801d26623199f8ea4cb8a898d62e8a8093269 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 8 Jul 2026 22:26:51 +0200 Subject: [PATCH 02/11] Add HttpRequestMessage.ConnectionId --- docs/project/list-of-diagnostics.md | 2 +- .../Common/src/System/Experimentals.cs | 4 +- .../System.Net.Http/ref/System.Net.Http.cs | 8 +- .../src/System.Net.Http.csproj | 4 +- .../BrowserHttpHandler/SocketsHttpHandler.cs | 2 +- .../src/System/Net/Http/HttpRequestMessage.cs | 19 ++++ .../ConnectionPool/HttpConnectionPool.cs | 5 + .../SocketsHttpHandler/Http2Connection.cs | 2 + .../SocketsHttpHandler/Http3Connection.cs | 2 + .../Http/SocketsHttpHandler/HttpConnection.cs | 2 + .../SocketsHttpConnectionContext.cs | 2 +- .../SocketsHttpConnectionEvictionContext.cs | 2 +- .../SocketsHttpHandler/SocketsHttpHandler.cs | 2 +- .../FunctionalTests/SocketsHttpHandlerTest.cs | 97 +++++++++++++++++++ .../System.Net.Http.Functional.Tests.csproj | 4 +- 15 files changed, 143 insertions(+), 14 deletions(-) diff --git a/docs/project/list-of-diagnostics.md b/docs/project/list-of-diagnostics.md index 1804440f4a510d..818d690a52fec9 100644 --- a/docs/project/list-of-diagnostics.md +++ b/docs/project/list-of-diagnostics.md @@ -320,4 +320,4 @@ Diagnostic id values for experimental APIs must not be recycled, as that could s | __`SYSLIB5004`__ | .NET 9 | TBD | `X86Base.DivRem` is experimental since performance is not as optimized as `T.DivRem` | | __`SYSLIB5005`__ | .NET 9 | .NET 10 | `System.Formats.Nrbf` is experimental | | __`SYSLIB5006`__ | .NET 10 | TBD | Types for Post-Quantum Cryptography (PQC) are experimental. | -| __`SYSLIB5007`__ | .NET 11 | TBD | `SocketsHttpHandler` connection eviction control APIs are experimental. | +| __`SYSLIB5008`__ | .NET 11 | TBD | `SocketsHttpHandler` connection eviction control and `HttpRequestMessage.ConnectionId` APIs are experimental. | diff --git a/src/libraries/Common/src/System/Experimentals.cs b/src/libraries/Common/src/System/Experimentals.cs index a009bd665825c6..6b0b593a728e62 100644 --- a/src/libraries/Common/src/System/Experimentals.cs +++ b/src/libraries/Common/src/System/Experimentals.cs @@ -33,8 +33,8 @@ internal static class Experimentals // Types for Post-Quantum Cryptography (PQC) are experimental. internal const string PostQuantumCryptographyDiagId = "SYSLIB5006"; - // SocketsHttpHandler connection eviction control APIs are experimental. - internal const string SocketsHttpHandlerConnectionEvictionDiagId = "SYSLIB5007"; + // SocketsHttpHandler connection eviction control and HttpRequestMessage.ConnectionId APIs are experimental. + internal const string SocketsHttpHandlerExperimentalDiagId = "SYSLIB5008"; // When adding a new diagnostic ID, add it to the table in docs\project\list-of-diagnostics.md as well. // Keep new const identifiers above this comment. 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 49bddaee2bf464..47ac75ef7c62ba 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -303,6 +303,8 @@ public partial class HttpRequestMessage : System.IDisposable public HttpRequestMessage() { } public HttpRequestMessage(System.Net.Http.HttpMethod method, [System.Diagnostics.CodeAnalysis.StringSyntaxAttribute("Uri")] string? requestUri) { } public HttpRequestMessage(System.Net.Http.HttpMethod method, System.Uri? requestUri) { } + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + public long? ConnectionId { get { throw null; } set { } } public System.Net.Http.HttpContent? Content { get { throw null; } set { } } public System.Net.Http.Headers.HttpRequestHeaders Headers { get { throw null; } } public System.Net.Http.HttpMethod Method { get { throw null; } set { } } @@ -424,12 +426,12 @@ protected override void SerializeToStream(System.IO.Stream stream, System.Net.Tr public sealed partial class SocketsHttpConnectionContext { internal SocketsHttpConnectionContext() { } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] public long ConnectionId { get { throw null; } } public System.Net.DnsEndPoint DnsEndPoint { get { throw null; } } public System.Net.Http.HttpRequestMessage InitialRequestMessage { get { throw null; } } } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] public sealed partial class SocketsHttpConnectionEvictionContext { internal SocketsHttpConnectionEvictionContext() { } @@ -477,7 +479,7 @@ public SocketsHttpHandler() { } public System.Net.Http.HeaderEncodingSelector? RequestHeaderEncodingSelector { get { throw null; } set { } } public System.TimeSpan ResponseDrainTimeout { get { throw null; } set { } } public System.Net.Http.HeaderEncodingSelector? ResponseHeaderEncodingSelector { get { throw null; } set { } } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] public System.Func>? ShouldEvictConnection { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public System.Net.Security.SslClientAuthenticationOptions SslOptions { get { throw null; } set { } } 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 415ae07228e70a..429e0e700c08a8 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -4,8 +4,8 @@ $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-linux;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-freebsd;$(NetCoreAppCurrent)-openbsd;$(NetCoreAppCurrent)-maccatalyst;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-tvos;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent)-illumos;$(NetCoreAppCurrent)-solaris;$(NetCoreAppCurrent)-haiku;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent) true $(DefineConstants);HTTP_DLL - - $(NoWarn);SYSLIB5007 + + $(NoWarn);SYSLIB5008 false 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 b75dd007e2a055..d0a0698464672f 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 @@ -214,7 +214,7 @@ public Func throw new PlatformNotSupportedException(); } - [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public Func>? ShouldEvictConnection { get => throw new PlatformNotSupportedException(); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs index 31ae0db4fc8a62..578eb0c4f5ad1a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs @@ -122,6 +122,25 @@ public Uri? RequestUri /// public HttpRequestOptions Options => _options ??= new HttpRequestOptions(); + /// + /// Gets or sets the identifier of the connection that this request was most recently sent on, or + /// if it has not been sent over a connection. + /// + /// + /// The value matches the connection id reported through EventSource telemetry, and the id surfaced to + /// and , + /// allowing a caller to correlate a request with the connection that served it. When a request is sent over + /// multiple connections (for example after a redirect or a retry), the value reflects the most recent attempt. + /// + /// This property is intended to be read after the request has been sent. Assigning a value before the request + /// is sent has no effect on how the request is handled: it does not request or influence the use of a particular + /// connection, and any value set by the caller is overwritten with the id of the connection that actually serves + /// the request. + /// + /// + [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + public long? ConnectionId { get; set; } + public HttpRequestMessage() : this(HttpMethod.Get, (Uri?)null) { diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs index 334727065e1637..8ed0c25eea241d 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs @@ -403,6 +403,11 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn int retryCount = 0; while (true) { + // Reset any connection id stamped by a previous attempt. Each connection sets it again in its + // SendAsync, so if this attempt is abandoned (e.g. we time out while waiting for the next + // connection after a graceful retry) the request won't point at a connection that didn't serve it. + request.ConnectionId = null; + HttpConnectionWaiter? http11ConnectionWaiter = null; HttpConnectionWaiter? http2ConnectionWaiter = null; try 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 5ce339e37d3a5c..d09ee5bb75191a 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 @@ -1999,6 +1999,8 @@ private static TaskCompletionSourceWithCancellation CreateSuccessfullyComp public async Task SendAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken) { + request.ConnectionId = Id; + Debug.Assert(async); Debug.Assert(!_pool.HasSyncObjLock); if (NetEventSource.Log.IsEnabled()) Trace($"Sending request: {request}"); diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs index 58c275e8a668fa..42111cae73421a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs @@ -263,6 +263,8 @@ public Task WaitForAvailableStreamsAsync() public async Task SendAsync(HttpRequestMessage request, WaitForHttp3ConnectionActivity waitForConnectionActivity, bool streamAvailable, CancellationToken cancellationToken) { + request.ConnectionId = Id; + // Allocate an active request QuicStream? quicStream = null; Http3RequestStream? requestStream = null; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs index 7a002057649262..8f07f9d946359f 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs @@ -532,6 +532,8 @@ static void ThrowForInvalidCharEncoding() => public async Task SendAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken) { + request.ConnectionId = Id; + Debug.Assert(_currentRequest == null, $"Expected null {nameof(_currentRequest)}."); Debug.Assert(_readBuffer.ActiveLength == 0, "Unexpected data in read buffer"); Debug.Assert(_readAheadTaskStatus != ReadAheadTask_Started, diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs index 4e3dfb4be32690..42d68942b00315 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs @@ -39,7 +39,7 @@ internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessag /// . It can be used to associate caller state (for /// example, the resolved address used) with the connection so it can be recovered when deciding on eviction. /// - [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public long ConnectionId => _connectionId; } } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs index 90ab5c26940143..e102d731e91468 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs @@ -14,7 +14,7 @@ namespace System.Net.Http /// The instance is only valid for the duration of the callback invocation; it must not be cached or used after /// the callback returns. reflects the elapsed time at the moment it is read. /// - [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public sealed class SocketsHttpConnectionEvictionContext { private readonly long _creationTickCount; 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 77b4b812355efc..15cb77f9dd01d1 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 @@ -450,7 +450,7 @@ public Func /// - [Experimental(Experimentals.SocketsHttpHandlerConnectionEvictionDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public Func>? ShouldEvictConnection { get => _settings._shouldEvictConnection; diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 629c3791fcdf9b..648b6092a688d1 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -2911,6 +2911,103 @@ public void ShouldEvictConnection_GetSet_Roundtrips() } } + [Theory] + [InlineData(false)] // Successful response + [InlineData(true)] // Malformed response produces an HttpRequestException + public async Task ConnectionId_SetOnRequest_MatchesConnectCallbackId(bool requestFails) + { + long connectCallbackId = -1; + + using var handler = new SocketsHttpHandler(); + handler.ConnectCallback = async (context, ct) => + { + connectCallbackId = context.ConnectionId; + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new NetworkStream(socket, ownsSocket: true); + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + + Assert.Null(request.ConnectionId); + + Task requestTask = client.SendAsync(request); + + if (requestFails) + { + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAsync(); + // A malformed status line makes the client throw a (non-retryable) HttpRequestException. + await connection.SendResponseAsync("INVALID RESPONSE LINE\r\n\r\n"); + }); + + await Assert.ThrowsAsync(() => requestTask); + } + else + { + await server.AcceptConnectionSendResponseAndCloseAsync(content: "hello"); + using HttpResponseMessage response = await requestTask; + } + + // The connection id is stamped on the request regardless of whether the send succeeded. + Assert.NotNull(request.ConnectionId); + Assert.Equal(connectCallbackId, request.ConnectionId.Value); + }); + } + + [Fact] + public async Task ConnectionId_GracefulRetryTimesOutWhileConnecting_ConnectionIdCleared() + { + var retryConnecting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int connectAttempts = 0; + using var cts = new CancellationTokenSource(); + + using var handler = new SocketsHttpHandler(); + handler.ConnectCallback = async (context, ct) => + { + if (Interlocked.Increment(ref connectAttempts) == 1) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new NetworkStream(socket, ownsSocket: true); + } + + // The retry's connection never completes: signal the test and block until the request is canceled. + retryConnecting.TrySetResult(); + await Task.Delay(Timeout.Infinite, ct); + throw new UnreachableException(); + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + Task requestTask = client.SendAsync(request, cts.Token); + + // First attempt: read the request, then close the connection so the request is gracefully retried. + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAsync(); + }); + + // Wait until the request is stuck establishing the next connection, then cancel it. + await retryConnecting.Task; + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => requestTask); + + // The first connection was abandoned by the graceful retry and the retry never produced a + // connection, so the request must not still point at the first connection. + Assert.Null(request.ConnectionId); + }); + } + [Fact] public void Properties_Roundtrips() { 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 86247e1f6c01ea..7c00db50893a7b 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 @@ -9,8 +9,8 @@ true $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-linux;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent)-osx true - - $(NoWarn);SYSLIB5007 + + $(NoWarn);SYSLIB5008 true false From 389cdf699045136b5d79dcc3c930a63feaae0c2d Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Thu, 9 Jul 2026 14:31:09 +0200 Subject: [PATCH 03/11] Expose the ConnectionId on the plaintext filter as well --- .../System.Net.Http/ref/System.Net.Http.cs | 2 ++ .../ConnectionPool/HttpConnectionPool.Http1.cs | 2 +- .../ConnectionPool/HttpConnectionPool.Http2.cs | 2 +- .../ConnectionPool/HttpConnectionPool.cs | 4 ++-- .../SocketsHttpConnectionContext.cs | 8 +++++--- .../SocketsHttpConnectionEvictionContext.cs | 7 ++++--- .../SocketsHttpPlaintextStreamFilterContext.cs | 18 +++++++++++++++++- .../FunctionalTests/SocketsHttpHandlerTest.cs | 12 ++++++++++++ 8 files changed, 44 insertions(+), 11 deletions(-) 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 47ac75ef7c62ba..4e9a9af79e8e15 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -492,6 +492,8 @@ protected override void Dispose(bool disposing) { } public sealed partial class SocketsHttpPlaintextStreamFilterContext { internal SocketsHttpPlaintextStreamFilterContext() { } + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + public long ConnectionId { get { throw null; } } public System.Net.Http.HttpRequestMessage InitialRequestMessage { get { throw null; } } public System.Version NegotiatedHttpVersion { get { throw null; } } public System.IO.Stream PlaintextStream { get { throw null; } } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs index 27a4234c190c02..a8a1637a62efd1 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs @@ -299,7 +299,7 @@ internal async ValueTask CreateHttp11ConnectionAsync(HttpRequest private async ValueTask ConstructHttp11ConnectionAsync(bool async, Stream stream, TransportContext? transportContext, HttpRequestMessage request, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId, CancellationToken cancellationToken) { - Stream newStream = await ApplyPlaintextFilterAsync(async, stream, HttpVersion.Version11, request, cancellationToken).ConfigureAwait(false); + Stream newStream = await ApplyPlaintextFilterAsync(async, stream, HttpVersion.Version11, request, connectionId, cancellationToken).ConfigureAwait(false); return new HttpConnection(this, newStream, transportContext, activity, remoteEndPoint, connectionId); } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs index 9c37ddebb562b4..fbc800e2d013b5 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs @@ -246,7 +246,7 @@ private async Task InjectNewHttp2ConnectionAsync(RequestQueue. private async ValueTask ConstructHttp2ConnectionAsync(Stream stream, HttpRequestMessage request, Activity? activity, IPEndPoint? remoteEndPoint, long connectionId, CancellationToken cancellationToken) { - stream = await ApplyPlaintextFilterAsync(async: true, stream, HttpVersion.Version20, request, cancellationToken).ConfigureAwait(false); + stream = await ApplyPlaintextFilterAsync(async: true, stream, HttpVersion.Version20, request, connectionId, cancellationToken).ConfigureAwait(false); Http2Connection http2Connection = new Http2Connection(this, stream, activity, remoteEndPoint, connectionId); try diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs index 8ed0c25eea241d..ec5616d2fb2560 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs @@ -745,7 +745,7 @@ private SslClientAuthenticationOptions GetSslOptionsForRequest(HttpRequestMessag return _sslOptionsHttp11!; } - private async ValueTask ApplyPlaintextFilterAsync(bool async, Stream stream, Version httpVersion, HttpRequestMessage request, CancellationToken cancellationToken) + private async ValueTask ApplyPlaintextFilterAsync(bool async, Stream stream, Version httpVersion, HttpRequestMessage request, long connectionId, CancellationToken cancellationToken) { if (Settings._plaintextStreamFilter is null) { @@ -755,7 +755,7 @@ private async ValueTask ApplyPlaintextFilterAsync(bool async, Stream str Stream newStream; try { - ValueTask streamTask = Settings._plaintextStreamFilter(new SocketsHttpPlaintextStreamFilterContext(stream, httpVersion, request), cancellationToken); + ValueTask streamTask = Settings._plaintextStreamFilter(new SocketsHttpPlaintextStreamFilterContext(stream, httpVersion, request, connectionId), cancellationToken); if (!async && !streamTask.IsCompleted) { diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs index 42d68942b00315..62102e1ef5f3f7 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs @@ -34,10 +34,12 @@ internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessag /// /// The identifier that will be assigned to the connection being established. This matches the connection id - /// reported through telemetry, and the + /// reported through telemetry, the /// passed to - /// . It can be used to associate caller state (for - /// example, the resolved address used) with the connection so it can be recovered when deciding on eviction. + /// , and the + /// stamped on requests sent over the connection. It can be used + /// to associate caller state (for example, the resolved address used) with the connection and to correlate it + /// with the requests it serves, so that state can be recovered later (for example when deciding on eviction). /// [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public long ConnectionId => _connectionId; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs index e102d731e91468..7df2a6ab17f446 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs @@ -54,10 +54,11 @@ internal SocketsHttpConnectionEvictionContext( /// /// Gets the identifier assigned to the connection. This matches the connection id reported through - /// telemetry, and the + /// telemetry, the /// seen by a custom - /// , allowing the decision to be correlated with state the - /// caller associated with the connection at creation time. + /// , and the + /// stamped on requests sent over the connection. It allows the eviction decision to be correlated both with + /// state the caller associated with the connection at creation time and with the requests it served. /// public long ConnectionId { get; } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs index 8611943b1847f0..26f325b0279f7a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs @@ -1,6 +1,8 @@ // 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.CodeAnalysis; +using System.Diagnostics.Tracing; using System.IO; namespace System.Net.Http @@ -13,12 +15,14 @@ public sealed class SocketsHttpPlaintextStreamFilterContext private readonly Stream _plaintextStream; private readonly Version _negotiatedHttpVersion; private readonly HttpRequestMessage _initialRequestMessage; + private readonly long _connectionId; - internal SocketsHttpPlaintextStreamFilterContext(Stream plaintextStream, Version negotiatedHttpVersion, HttpRequestMessage initialRequestMessage) + internal SocketsHttpPlaintextStreamFilterContext(Stream plaintextStream, Version negotiatedHttpVersion, HttpRequestMessage initialRequestMessage, long connectionId) { _plaintextStream = plaintextStream; _negotiatedHttpVersion = negotiatedHttpVersion; _initialRequestMessage = initialRequestMessage; + _connectionId = connectionId; } /// @@ -35,5 +39,17 @@ internal SocketsHttpPlaintextStreamFilterContext(Stream plaintextStream, Version /// The initial HttpRequestMessage that is causing the stream to be used. /// public HttpRequestMessage InitialRequestMessage => _initialRequestMessage; + + /// + /// The identifier of the connection whose stream is being filtered. This matches the connection id reported + /// through telemetry, the + /// surfaced to , the + /// passed to + /// , and the + /// stamped on requests sent over the connection. It can be used to associate caller state with the connection + /// and to correlate it with the requests it serves. + /// + [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] + public long ConnectionId => _connectionId; } } diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 648b6092a688d1..eb01a3817444ac 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -4435,10 +4435,22 @@ await LoopbackServerFactory.CreateClientAndServerAsync( using HttpClientHandler handler = CreateHttpClientHandler(allowAllCertificates: true); var socketsHandler = (SocketsHttpHandler)GetUnderlyingSocketsHttpHandler(handler); + + long connectCallbackConnectionId = -1; + socketsHandler.ConnectCallback = (context, token) => + { + connectCallbackConnectionId = context.ConnectionId; + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + socket.Connect(context.DnsEndPoint); + return ValueTask.FromResult(new NetworkStream(socket, ownsSocket: true)); + }; + socketsHandler.PlaintextStreamFilter = async (context, token) => { Assert.Equal(UseVersion, context.NegotiatedHttpVersion); Assert.Equal(requestMessage, context.InitialRequestMessage); + // The filter observes the same connection id surfaced to the ConnectCallback. + Assert.Equal(connectCallbackConnectionId, context.ConnectionId); if (!syncCallback) { From 572cf5990e6dd5a9174aa3cc89b92fe781fb4df8 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Thu, 9 Jul 2026 14:48:10 +0200 Subject: [PATCH 04/11] Small tweaks --- .../SocketsHttpHandler/HttpConnectionBase.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs index c87ce0af12f13f..cfee213b3759f0 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs @@ -27,7 +27,7 @@ internal abstract class HttpConnectionBase : IDisposable, IHttpTrace // avoid decrementing the counter once it's closed in case telemetry was enabled in between. private bool _httpTelemetryMarkedConnectionAsOpened; - private readonly long _creationTickCount = Environment.TickCount64; + private readonly long _creationTickCount = Environment.TickCount64; // milliseconds from Environment.TickCount64, not TimeSpan ticks private long? _idleSinceTickCount; /// @@ -90,14 +90,14 @@ protected void MarkConnectionAsEstablished(Activity? connectionSetupActivity, IP { ConnectionSetupActivity = connectionSetupActivity; - // Baseline the eviction generation to the pool's current value so a freshly established connection isn't - // immediately re-evaluated; it becomes eligible once the next maintenance pass advances the generation. - _lastEvictionGeneration = _pool.EvictionGeneration; - - // Only the ShouldEvictConnection callback consumes the disposal token, so the source is created only when - // such a callback is configured. + // The eviction generation baseline and disposal token are only relevant when a ShouldEvictConnection + // callback is configured, so they're only set up in that case. if (_pool.Settings._shouldEvictConnection is not null) { + // Baseline the eviction generation to the pool's current value so a freshly established connection isn't + // immediately re-evaluated; it becomes eligible once the next maintenance pass advances the generation. + _lastEvictionGeneration = _pool.EvictionGeneration; + _connectionDisposalCts = new CancellationTokenSource(); HttpAuthority authority = _pool.OriginAuthority; @@ -233,6 +233,12 @@ protected void TraceConnection(Stream stream) public long GetIdleTicks(long nowTicks) => _idleSinceTickCount is long idleSinceTickCount ? nowTicks - idleSinceTickCount : 0; + /// + /// Called when a connection is returned to the pool to run the eviction evaluation that may have been skipped + /// for it during a background pass. HTTP/1.1 connections that were in use at the time weren't visible in the + /// available list (they were checked out as pending), so the pass couldn't evaluate them. Comparing the + /// connection's last evaluated generation against the pool's current one detects that case and evaluates now. + /// public void RunEvictionEvaluationIfNeeded() { if (_pool.EvictionGeneration != _lastEvictionGeneration) From 394f68de1a04bce7ef1b7217b468b8215d7a3df5 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 10 Jul 2026 12:21:20 +0200 Subject: [PATCH 05/11] Tweak H3 authority, expand tests --- .../HttpConnectionPool.Http3.cs | 5 +- .../SocketsHttpHandler/Http3Connection.cs | 6 +- .../SocketsHttpHandler/HttpConnectionBase.cs | 12 +- .../HttpClientHandlerTest.AltSvc.cs | 144 +++++++ .../FunctionalTests/SocketsHttpHandlerTest.cs | 378 ++++++++++++++---- 5 files changed, 461 insertions(+), 84 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http3.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http3.cs index 140e3ea2c03604..369365611164f2 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http3.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http3.cs @@ -266,14 +266,15 @@ private async Task InjectNewHttp3ConnectionAsync(RequestQueue. connectionSetupActivity = ConnectionSetupDistributedTracing.StartConnectionSetupActivity(isSecure: true, _telemetryServerAddress, authority.Port); // If the authority was sent as an option through alt-svc then include alt-used header. connection = new Http3Connection(this, authority, includeAltUsedHeader: _http3Authority == authority); - QuicConnection quicConnection = await ConnectHelper.ConnectQuicAsync(queueItem.Request, new DnsEndPoint(authority.IdnHost, authority.Port), _poolManager.Settings._pooledConnectionIdleTimeout, _sslOptionsHttp3!, connection.StreamCapacityCallback, cts.Token).ConfigureAwait(false); + var connectEndPoint = new DnsEndPoint(authority.IdnHost, authority.Port); + QuicConnection quicConnection = await ConnectHelper.ConnectQuicAsync(queueItem.Request, connectEndPoint, _poolManager.Settings._pooledConnectionIdleTimeout, _sslOptionsHttp3!, connection.StreamCapacityCallback, cts.Token).ConfigureAwait(false); if (quicConnection.NegotiatedApplicationProtocol != SslApplicationProtocol.Http3) { await quicConnection.DisposeAsync().ConfigureAwait(false); throw new HttpRequestException(HttpRequestError.ConnectionError, "QUIC connected but no HTTP/3 indicated via ALPN.", null, RequestRetryType.RetryOnConnectionFailure); } if (connectionSetupActivity is not null) ConnectionSetupDistributedTracing.StopConnectionSetupActivity(connectionSetupActivity, null, quicConnection.RemoteEndPoint); - connection.InitQuicConnection(quicConnection, connectionSetupActivity); + connection.InitQuicConnection(quicConnection, connectionSetupActivity, connectEndPoint); } else if (reasonException is not null) { diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs index 42111cae73421a..237dba78176e0a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs @@ -90,9 +90,11 @@ public Http3Connection(HttpConnectionPool pool, HttpAuthority authority, bool in } } - public void InitQuicConnection(QuicConnection connection, Activity? connectionSetupActivity) + public void InitQuicConnection(QuicConnection connection, Activity? connectionSetupActivity, DnsEndPoint connectedEndPoint) { - MarkConnectionAsEstablished(connectionSetupActivity: connectionSetupActivity, remoteEndPoint: connection.RemoteEndPoint); + // Report the exact DnsEndPoint used to establish the QUIC connection (Alt-Svc may point it at an authority + // distinct from the pool's origin), consistent with the connection's RemoteEndPoint. + MarkConnectionAsEstablished(connectionSetupActivity: connectionSetupActivity, remoteEndPoint: connection.RemoteEndPoint, authority: _authority, connectedEndPoint: connectedEndPoint); _connection = connection; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs index cfee213b3759f0..2aeaeb0349ca9f 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs @@ -79,14 +79,15 @@ public HttpConnectionBase(HttpConnectionPool pool, long connectionId) public HttpConnectionBase(HttpConnectionPool pool, long connectionId, Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint) : this(pool, connectionId) { - MarkConnectionAsEstablished(connectionSetupActivity, remoteEndPoint); + // HTTP/1.1 and HTTP/2 connections always target the pool's origin authority. + MarkConnectionAsEstablished(connectionSetupActivity, remoteEndPoint, pool.OriginAuthority); } /// Allocates the next unique connection id. Generated before the connection object exists so that /// the same id can be surfaced to and telemetry. internal static long GetNextConnectionId() => Interlocked.Increment(ref s_connectionCounter); - protected void MarkConnectionAsEstablished(Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint) + protected void MarkConnectionAsEstablished(Activity? connectionSetupActivity, IPEndPoint? remoteEndPoint, HttpAuthority authority, DnsEndPoint? connectedEndPoint = null) { ConnectionSetupActivity = connectionSetupActivity; @@ -100,10 +101,11 @@ protected void MarkConnectionAsEstablished(Activity? connectionSetupActivity, IP _connectionDisposalCts = new CancellationTokenSource(); - HttpAuthority authority = _pool.OriginAuthority; - + // Report the endpoint this connection actually targets, consistent with remoteEndPoint. HTTP/3 passes + // the exact DnsEndPoint it used to establish the QuicConnection (which Alt-Svc may point at an authority + // distinct from the origin); HTTP/1.1 and HTTP/2 fall back to the pool's origin authority. _evictionContext = new SocketsHttpConnectionEvictionContext( - new DnsEndPoint(authority.IdnHost, authority.Port), + connectedEndPoint ?? new DnsEndPoint(authority.IdnHost, authority.Port), remoteEndPoint, Id, this is HttpConnection ? HttpVersion.Version11 : this is Http2Connection ? HttpVersion.Version20 : HttpVersion.Version30, diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.AltSvc.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.AltSvc.cs index ed5fecd13f49b4..ce2726f5f867ad 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.AltSvc.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.AltSvc.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Xunit; using Xunit.Abstractions; @@ -156,6 +157,149 @@ public async Task AltSvc_ResponseFrame_UpgradeFrom20_Success() await AltSvc_Upgrade_Success(firstServer, secondServer, client); } + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_Http3AltSvcConnection_ContextReportsAltAuthority() + { + using Http2LoopbackServer firstServer = Http2LoopbackServer.CreateServer(); + using Http3LoopbackServer secondServer = CreateHttp3LoopbackServer(); + + SocketsHttpConnectionEvictionContext capturedContext = null; + var callbackInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using HttpClientHandler handler = CreateHttpClientHandler(); + SocketsHttpHandler socketsHandler = GetUnderlyingSocketsHttpHandler(handler); + socketsHandler.PooledConnectionLifetime = Timeout.InfiniteTimeSpan; + socketsHandler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4); + socketsHandler.ShouldEvictConnection = (context, _) => + { + // The origin HTTP/2 connection is also evaluated; capture only the HTTP/3 (Alt-Svc) connection. + if (context.HttpVersion == HttpVersion.Version30) + { + capturedContext ??= context; + callbackInvoked.TrySetResult(); + } + return Task.FromResult(false); // Never evict; we only want to observe the context. + }; + + using HttpClient client = CreateHttpClient(handler); + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; + client.DefaultRequestVersion = HttpVersion.Version20; + + // First request over HTTP/2 advertises an HTTP/3 alternative on secondServer, whose authority (port) differs + // from the origin. + Task firstResponseTask = client.GetAsync(firstServer.Address); + Task firstServerTask = firstServer.HandleRequestAsync(headers: new[] + { + new HttpHeaderData("Alt-Svc", $"h3=\"{secondServer.Address.IdnHost}:{secondServer.Address.Port}\"") + }); + await new Task[] { firstResponseTask, firstServerTask }.WhenAllOrAnyFailed(TestHelper.PassingTestTimeoutMilliseconds); + using (HttpResponseMessage firstResponse = firstResponseTask.Result) + { + Assert.True(firstResponse.IsSuccessStatusCode); + } + + // Second request upgrades to HTTP/3 on the alt authority. Handle it at the stream level (no GOAWAY) so the + // connection stays open, pooled and idle, making it eligible for the eviction maintenance pass. + Task secondResponseTask = client.GetAsync(firstServer.Address); + await using (GenericLoopbackConnection genericConnection = await secondServer.EstablishGenericConnectionAsync()) + { + var h3Connection = (Http3LoopbackConnection)genericConnection; + Http3LoopbackStream stream = await h3Connection.AcceptRequestStreamAsync(); + await stream.HandleRequestAsync(); + + using (HttpResponseMessage secondResponse = await secondResponseTask.WaitAsync(TestHelper.PassingTestTimeout)) + { + Assert.True(secondResponse.IsSuccessStatusCode); + } + + await callbackInvoked.Task.WaitAsync(TestHelper.PassingTestTimeout); + + Assert.NotNull(capturedContext); + // The connection targets the Alt-Svc authority, so the eviction context must report that authority + // (consistent with RemoteEndPoint), not the pool's origin authority. The alt authority uses a different port. + Assert.Equal(secondServer.Address.IdnHost, capturedContext.DnsEndPoint.Host); + Assert.Equal(secondServer.Address.Port, capturedContext.DnsEndPoint.Port); + Assert.NotEqual(firstServer.Address.Port, capturedContext.DnsEndPoint.Port); + + // HTTP/3 always reports the remote endpoint (from the QUIC connection). It targets the alt authority, so + // its port matches the reported DnsEndPoint. + IPEndPoint remoteEndPoint = Assert.IsType(capturedContext.RemoteEndPoint); + Assert.Equal(secondServer.Address.Port, remoteEndPoint.Port); + } + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_Http3AltSvcChangesAfterConnect_ContextReportsOriginalEndpoint() + { + using Http2LoopbackServer firstServer = Http2LoopbackServer.CreateServer(); + using Http3LoopbackServer secondServer = CreateHttp3LoopbackServer(); + // The Alt-Svc will later be changed to point at this authority; we never actually connect to it. + using Http3LoopbackServer thirdServer = CreateHttp3LoopbackServer(); + + SocketsHttpConnectionEvictionContext capturedContext = null; + var callbackInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using HttpClientHandler handler = CreateHttpClientHandler(); + SocketsHttpHandler socketsHandler = GetUnderlyingSocketsHttpHandler(handler); + socketsHandler.PooledConnectionLifetime = Timeout.InfiniteTimeSpan; + socketsHandler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4); + socketsHandler.ShouldEvictConnection = (context, _) => + { + if (context.HttpVersion == HttpVersion.Version30) + { + capturedContext ??= context; + callbackInvoked.TrySetResult(); + } + return Task.FromResult(false); + }; + + using HttpClient client = CreateHttpClient(handler); + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; + client.DefaultRequestVersion = HttpVersion.Version20; + + // First request over HTTP/2 advertises the HTTP/3 alternative on secondServer. + Task firstResponseTask = client.GetAsync(firstServer.Address); + Task firstServerTask = firstServer.HandleRequestAsync(headers: new[] + { + new HttpHeaderData("Alt-Svc", $"h3=\"{secondServer.Address.IdnHost}:{secondServer.Address.Port}\"") + }); + await new Task[] { firstResponseTask, firstServerTask }.WhenAllOrAnyFailed(TestHelper.PassingTestTimeoutMilliseconds); + using (HttpResponseMessage firstResponse = firstResponseTask.Result) + { + Assert.True(firstResponse.IsSuccessStatusCode); + } + + // Second request upgrades to HTTP/3 on secondServer. Its response changes the advertised Alt-Svc to point at + // thirdServer (a different authority/port). The connection stays open, pooled and idle. + Task secondResponseTask = client.GetAsync(firstServer.Address); + await using (GenericLoopbackConnection genericConnection = await secondServer.EstablishGenericConnectionAsync()) + { + var h3Connection = (Http3LoopbackConnection)genericConnection; + Http3LoopbackStream stream = await h3Connection.AcceptRequestStreamAsync(); + await stream.HandleRequestAsync(headers: new[] + { + new HttpHeaderData("Alt-Svc", $"h3=\"{thirdServer.Address.IdnHost}:{thirdServer.Address.Port}\"") + }); + + using (HttpResponseMessage secondResponse = await secondResponseTask.WaitAsync(TestHelper.PassingTestTimeout)) + { + Assert.True(secondResponse.IsSuccessStatusCode); + } + + await callbackInvoked.Task.WaitAsync(TestHelper.PassingTestTimeout); + + Assert.NotNull(capturedContext); + // Even though Alt-Svc now points at thirdServer, the existing connection still reports the endpoint it + // was actually established to (secondServer) - captured at connection setup, not the pool's current alt + // authority. + Assert.Equal(secondServer.Address.IdnHost, capturedContext.DnsEndPoint.Host); + Assert.Equal(secondServer.Address.Port, capturedContext.DnsEndPoint.Port); + Assert.NotEqual(thirdServer.Address.Port, capturedContext.DnsEndPoint.Port); + } + } + private async Task AltSvc_Upgrade_Success(GenericLoopbackServer firstServer, Http3LoopbackServer secondServer, HttpClient client) { Task secondResponseTask = client.GetAsync(firstServer.Address); diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index eb01a3817444ac..9df0bd0b9661d4 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -2054,7 +2054,6 @@ public async Task ShouldEvictConnection_CallbackReturnsTrue_ConnectionEvictedAnd var firstConnectionEvicted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var firstConnectionDisposalTokenCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); SocketsHttpConnectionEvictionContext capturedContext = null; - Uri serverUri = null; var connectCallbackIds = new System.Collections.Concurrent.ConcurrentBag(); using var handler = new SocketsHttpHandler @@ -2081,7 +2080,7 @@ public async Task ShouldEvictConnection_CallbackReturnsTrue_ConnectionEvictedAnd // Only one connection is evaluated at a time in this test, so no synchronization is needed here. if (useAsyncCallback) { - await Task.Yield(); // Exercise the path where the callback completes asynchronously. + await Task.Delay(10); // Exercise the path where the callback completes asynchronously. } capturedContext ??= context; @@ -2100,8 +2099,6 @@ public async Task ShouldEvictConnection_CallbackReturnsTrue_ConnectionEvictedAnd await LoopbackServer.CreateServerAsync(async (server, uri) => { - serverUri = uri; - // Connection A: serve request 1 with a keep-alive response, then block reading until the client // tears the connection down. If anything other than eviction were to remove it, the read below // would observe request 2 instead of EOF and the test's second accept would not be a new connection. @@ -2115,11 +2112,11 @@ await LoopbackServer.CreateServerAsync(async (server, uri) => // Wait for the maintenance timer to invoke the eviction callback for connection A, then for the // pool to actually retire it (serverTaskA's read completes at EOF once the client disposes it). - await firstConnectionEvicted.Task.WaitAsync(TimeSpan.FromSeconds(30)); - await serverTaskA.WaitAsync(TimeSpan.FromSeconds(30)); + await firstConnectionEvicted.Task.WaitAsync(TestHelper.PassingTestTimeout); + await serverTaskA.WaitAsync(TestHelper.PassingTestTimeout); // Disposing the retired connection cancels the token that was passed to the eviction callback. - await firstConnectionDisposalTokenCanceled.Task.WaitAsync(TimeSpan.FromSeconds(30)); + await firstConnectionDisposalTokenCanceled.Task.WaitAsync(TestHelper.PassingTestTimeout); // Request 2 must land on a brand new connection B, because A has been evicted. Task request2 = client.GetStringAsync(uri); @@ -2129,15 +2126,15 @@ await server.AcceptConnectionAsync(async connection => }); Assert.Equal("2", await request2); - }); - Assert.NotNull(capturedContext); - Assert.Equal(System.Net.HttpVersion.Version11, capturedContext.HttpVersion); - Assert.NotNull(capturedContext.RemoteEndPoint); - Assert.Equal(serverUri.Port, capturedContext.DnsEndPoint.Port); - Assert.Equal(serverUri.IdnHost, capturedContext.DnsEndPoint.Host); - // The evicted connection's id was observed by ConnectCallback when it was established. - Assert.Contains(capturedContext.ConnectionId, connectCallbackIds); + Assert.NotNull(capturedContext); + Assert.Equal(System.Net.HttpVersion.Version11, capturedContext.HttpVersion); + Assert.NotNull(capturedContext.RemoteEndPoint); + Assert.Equal(uri.Port, capturedContext.DnsEndPoint.Port); + Assert.Equal(uri.IdnHost, capturedContext.DnsEndPoint.Host); + // The evicted connection's id was observed by ConnectCallback when it was established. + Assert.Contains(capturedContext.ConnectionId, connectCallbackIds); + }); } [OuterLoop("Waits for the connection pool maintenance timer to fire.")] @@ -2187,6 +2184,7 @@ await LoopbackServer.CreateServerAsync(async (server, uri) => while (!stop.IsCancellationRequested) { await connection.ReadRequestHeaderAndSendResponseAsync(content: "hello"); + await Task.Delay(10, stop.Token); // Throttle the loop so the test doesn't spin the CPU while waiting for the callback. } } catch { } // The connection is torn down during cleanup. @@ -2194,7 +2192,7 @@ await LoopbackServer.CreateServerAsync(async (server, uri) => // The connection is never idle during a maintenance pass, so it can only be evaluated when it is // returned to the pool between requests. Without that path the callback would never run here. - await connectionEvaluated.Task.WaitAsync(TimeSpan.FromSeconds(60)); + await connectionEvaluated.Task.WaitAsync(TestHelper.PassingTestTimeout); stop.Cancel(); await Task.WhenAny(Task.WhenAll(clientLoops), Task.Delay(TimeSpan.FromSeconds(10))); @@ -2240,13 +2238,16 @@ await server.AcceptConnectionAsync(async connection => Assert.Equal("hello", await request); // The idle proxy connection is evaluated by the maintenance pass. - await callbackInvoked.Task.WaitAsync(TimeSpan.FromSeconds(30)); + await callbackInvoked.Task.WaitAsync(TestHelper.PassingTestTimeout); + + Assert.NotNull(capturedContext); + // A forwarding proxy connection targets the proxy itself, so the context reports the proxy's host and port. + Assert.Equal(proxyServer.Uri.IdnHost, capturedContext.DnsEndPoint.Host); + Assert.Equal(proxyServer.Uri.Port, capturedContext.DnsEndPoint.Port); + // The reported version reflects the request that used the connection, not the proxy hop. A plain + // forwarding proxy connection speaks HTTP/1.1, which is also what the request used here. + Assert.Equal(HttpVersion.Version11, capturedContext.HttpVersion); }); - - Assert.NotNull(capturedContext); - // A forwarding proxy connection targets the proxy itself, so the context reports the proxy's host and port. - Assert.Equal(proxyServer.Uri.IdnHost, capturedContext.DnsEndPoint.Host); - Assert.Equal(proxyServer.Uri.Port, capturedContext.DnsEndPoint.Port); } [OuterLoop("Waits for the connection pool maintenance timer to fire.")] @@ -2255,22 +2256,23 @@ await server.AcceptConnectionAsync(async connection => [InlineData(true)] // The callback throws. public async Task ShouldEvictConnection_CallbackDoesNotEvict_ConnectionReused(bool callbackThrows) { - int callbackInvocations = 0; var callbackInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var handler = new SocketsHttpHandler { - PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4), + // Use an infinite idle timeout so a slow/busy machine can't scavenge the connection for idleness in the + // window between the two requests. The eviction callback still runs promptly on the maintenance timer's + // minimum cadence (see ShouldEvictConnection_InfiniteIdleTimeout_CallbackInvokedOnMinimumCadence). + PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, PooledConnectionLifetime = Timeout.InfiniteTimeSpan, }; handler.ShouldEvictConnection = (context, _) => { - Interlocked.Increment(ref callbackInvocations); callbackInvoked.TrySetResult(); - // A throwing callback must be treated the same as one that declined to evict: the exception is - // swallowed and the connection is left in the pool. + // A throwing callback is treated the same as one that declined to evict: the exception is + // swallowed and the connection is left in the pool. This is an implementation choice, not a guarantee. if (callbackThrows) { throw new InvalidOperationException("Eviction callback failure should not affect the pool."); @@ -2290,7 +2292,7 @@ await server.AcceptConnectionAsync(async connection => Assert.Equal("1", await request1); // Wait until the callback has been invoked at least once, proving the maintenance timer ran. - await callbackInvoked.Task.WaitAsync(TimeSpan.FromSeconds(30)); + await callbackInvoked.Task.WaitAsync(TestHelper.PassingTestTimeout); // Because nothing was evicted, the second request must be served on the same connection. Task request2 = client.GetStringAsync(uri); @@ -2298,8 +2300,6 @@ await server.AcceptConnectionAsync(async connection => Assert.Equal("2", await request2); }); }); - - Assert.True(callbackInvocations > 0); } [OuterLoop("Waits for the connection pool maintenance timer to fire.")] @@ -2338,7 +2338,7 @@ await Http2LoopbackServerFactory.CreateServerAsync(async (server, url) => await request1; // Wait for the maintenance timer to mark the HTTP/2 connection for eviction. - await firstConnectionEvicted.Task.WaitAsync(TimeSpan.FromSeconds(30)); + await firstConnectionEvicted.Task.WaitAsync(TestHelper.PassingTestTimeout); // Detach the first connection's socket so its eviction-triggered teardown doesn't interfere. (SocketWrapper socket, Stream stream) = connection.ResetNetwork(); @@ -2351,10 +2351,10 @@ await Http2LoopbackServerFactory.CreateServerAsync(async (server, url) => await request2; await socket.CloseAsync(); - }); - Assert.NotNull(capturedContext); - Assert.Equal(System.Net.HttpVersion.Version20, capturedContext.HttpVersion); + Assert.NotNull(capturedContext); + Assert.Equal(System.Net.HttpVersion.Version20, capturedContext.HttpVersion); + }); } [OuterLoop("Waits for the connection pool maintenance timer to fire.")] @@ -2684,6 +2684,170 @@ await proxyServer.AcceptConnectionAsync(async connection => } } + // Exercises the SocketsHttpHandler.ShouldEvictConnection eviction context across HTTP versions. The reported + // properties (version, endpoints, connection id) must reflect the actual connection, independent of protocol. + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + [SkipOnPlatform(TestPlatforms.Wasi, "SocketsHttpHandler is not supported on WASI")] + public abstract class SocketsHttpHandler_ConnectionEviction_Test : HttpClientHandlerTestBase + { + public SocketsHttpHandler_ConnectionEviction_Test(ITestOutputHelper output) : base(output) { } + + // Serves a single request and holds the connection open (idle and pooled on the client) until releaseSignal + // completes, so the pool's maintenance pass can evaluate it for eviction. Keep-alive handling is protocol + // specific, so each version supplies its own implementation. + protected abstract Task ServeRequestAndHoldConnectionAsync(GenericLoopbackServer server, Task releaseSignal); + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_Context_ReportsRequestVersionAndEndpoint() + { + var evictionObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + SocketsHttpConnectionEvictionContext capturedContext = null; + + using HttpClientHandler handler = CreateHttpClientHandler(); + SocketsHttpHandler socketsHandler = GetUnderlyingSocketsHttpHandler(handler); + socketsHandler.PooledConnectionLifetime = Timeout.InfiniteTimeSpan; + socketsHandler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4); + socketsHandler.ShouldEvictConnection = (context, _) => + { + // Capture the context here (exceptions thrown from this callback are swallowed by the pool, so we + // assert on the client side instead). + capturedContext ??= context; + evictionObserved.TrySetResult(); + return Task.FromResult(false); // Never evict; we only observe the context. + }; + + using HttpClient client = CreateHttpClient(handler); + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + using (HttpResponseMessage response = await client.SendAsync(TestAsync, request)) + { + Assert.True(response.IsSuccessStatusCode); + } + + // The idle connection is evaluated by the pool maintenance pass. + await evictionObserved.Task.WaitAsync(TestHelper.PassingTestTimeout); + + Assert.NotNull(capturedContext); + // The context reports the version the request actually used and the endpoint it targeted. + Assert.Equal(UseVersion, capturedContext.HttpVersion); + Assert.Equal(uri.IdnHost, capturedContext.DnsEndPoint.Host); + Assert.Equal(uri.Port, capturedContext.DnsEndPoint.Port); + Assert.NotNull(capturedContext.RemoteEndPoint); + // The id stamped on the request matches the eviction context's id: both refer to the same connection. + Assert.Equal(request.ConnectionId, capturedContext.ConnectionId); + }, + server => ServeRequestAndHoldConnectionAsync(server, evictionObserved.Task), + options: new GenericLoopbackOptions()); + } + + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [Fact] + public async Task ShouldEvictConnection_ThroughHttpsProxyTunnel_ReportsOriginAndRequestVersion() + { + if (UseVersion == HttpVersion.Version30) + { + return; // HTTP/3 (QUIC) cannot be tunneled through an HTTP CONNECT proxy. + } + + if (UseVersion == HttpVersion.Version20 && !PlatformDetection.SupportsAlpn) + { + return; // HTTP/2 over TLS requires ALPN to negotiate. + } + + var evictionObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + SocketsHttpConnectionEvictionContext capturedContext = null; + + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + using HttpClientHandler handler = CreateHttpClientHandler(); + SocketsHttpHandler socketsHandler = GetUnderlyingSocketsHttpHandler(handler); + socketsHandler.PooledConnectionLifetime = Timeout.InfiniteTimeSpan; + socketsHandler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4); + handler.Proxy = new WebProxy(proxyServer.Uri); + socketsHandler.ShouldEvictConnection = (context, _) => + { + capturedContext ??= context; + evictionObserved.TrySetResult(); + return Task.FromResult(false); + }; + + using HttpClient client = CreateHttpClient(handler); + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using (HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true)) + using (HttpResponseMessage response = await client.SendAsync(TestAsync, request)) + { + Assert.True(response.IsSuccessStatusCode); + } + + await evictionObserved.Task.WaitAsync(TestHelper.PassingTestTimeout); + + Assert.NotNull(capturedContext); + // The CONNECT tunnel to the proxy is always HTTP/1, but the reported version is the end-to-end + // request version, and the DnsEndPoint is the tunneled origin (not the proxy). + Assert.Equal(UseVersion, capturedContext.HttpVersion); + Assert.Equal(uri.IdnHost, capturedContext.DnsEndPoint.Host); + Assert.Equal(uri.Port, capturedContext.DnsEndPoint.Port); + }, + server => ServeRequestAndHoldConnectionAsync(server, evictionObserved.Task), + // HTTPS origin forces the proxy hop to be an HTTP/1 CONNECT tunnel regardless of the request version. + options: new GenericLoopbackOptions { UseSsl = true }); + } + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + [SkipOnPlatform(TestPlatforms.Wasi, "SocketsHttpHandler is not supported on WASI")] + public sealed class SocketsHttpHandler_ConnectionEviction_Test_Http1 : SocketsHttpHandler_ConnectionEviction_Test + { + public SocketsHttpHandler_ConnectionEviction_Test_Http1(ITestOutputHelper output) : base(output) { } + protected override Version UseVersion => HttpVersion.Version11; + + protected override Task ServeRequestAndHoldConnectionAsync(GenericLoopbackServer server, Task releaseSignal) => + ((LoopbackServer)server).AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "hello"); + await releaseSignal; + }); + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + [SkipOnPlatform(TestPlatforms.Wasi, "SocketsHttpHandler is not supported on WASI")] + public sealed class SocketsHttpHandler_ConnectionEviction_Test_Http2 : SocketsHttpHandler_ConnectionEviction_Test + { + public SocketsHttpHandler_ConnectionEviction_Test_Http2(ITestOutputHelper output) : base(output) { } + protected override Version UseVersion => HttpVersion.Version20; + + protected override async Task ServeRequestAndHoldConnectionAsync(GenericLoopbackServer server, Task releaseSignal) + { + await using Http2LoopbackConnection connection = await ((Http2LoopbackServer)server).EstablishConnectionAsync(); + int streamId = await connection.ReadRequestHeaderAsync(); + await connection.SendDefaultResponseAsync(streamId); + await releaseSignal; + } + } + + [ConditionalClass(typeof(HttpClientHandlerTestBase), nameof(IsHttp3Supported))] + [SkipOnPlatform(TestPlatforms.Wasi, "SocketsHttpHandler is not supported on WASI")] + public sealed class SocketsHttpHandler_ConnectionEviction_Test_Http3 : SocketsHttpHandler_ConnectionEviction_Test + { + public SocketsHttpHandler_ConnectionEviction_Test_Http3(ITestOutputHelper output) : base(output) { } + protected override Version UseVersion => HttpVersion.Version30; + + protected override async Task ServeRequestAndHoldConnectionAsync(GenericLoopbackServer server, Task releaseSignal) + { + await using GenericLoopbackConnection connection = await server.EstablishGenericConnectionAsync(); + Http3LoopbackStream stream = await ((Http3LoopbackConnection)connection).AcceptRequestStreamAsync(); + await stream.HandleRequestAsync(); + await releaseSignal; + } + } + // System.Net.Sockets is not supported on this platform [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] [SkipOnPlatform(TestPlatforms.Wasi, "SocketsHttpHandler is not supported on WASI")] @@ -2929,35 +3093,42 @@ public async Task ConnectionId_SetOnRequest_MatchesConnectCallbackId(bool reques using HttpClient client = new HttpClient(handler); - await LoopbackServer.CreateServerAsync(async (server, uri) => - { - using var request = new HttpRequestMessage(HttpMethod.Get, uri); - - Assert.Null(request.ConnectionId); + await LoopbackServer.CreateClientAndServerAsync( + async uri => + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); - Task requestTask = client.SendAsync(request); + Assert.Null(request.ConnectionId); - if (requestFails) - { - await server.AcceptConnectionAsync(async connection => + if (requestFails) { - await connection.ReadRequestHeaderAsync(); - // A malformed status line makes the client throw a (non-retryable) HttpRequestException. - await connection.SendResponseAsync("INVALID RESPONSE LINE\r\n\r\n"); - }); + await Assert.ThrowsAsync(() => client.SendAsync(request)); + } + else + { + using HttpResponseMessage response = await client.SendAsync(request); + } - await Assert.ThrowsAsync(() => requestTask); - } - else + // The connection id is stamped on the request regardless of whether the send succeeded. + Assert.NotNull(request.ConnectionId); + Assert.Equal(connectCallbackId, request.ConnectionId.Value); + }, + async server => { - await server.AcceptConnectionSendResponseAndCloseAsync(content: "hello"); - using HttpResponseMessage response = await requestTask; - } - - // The connection id is stamped on the request regardless of whether the send succeeded. - Assert.NotNull(request.ConnectionId); - Assert.Equal(connectCallbackId, request.ConnectionId.Value); - }); + if (requestFails) + { + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAsync(); + // A malformed status line makes the client throw a (non-retryable) HttpRequestException. + await connection.SendResponseAsync("INVALID RESPONSE LINE\r\n\r\n"); + }); + } + else + { + await server.AcceptConnectionSendResponseAndCloseAsync(content: "hello"); + } + }); } [Fact] @@ -2985,27 +3156,30 @@ public async Task ConnectionId_GracefulRetryTimesOutWhileConnecting_ConnectionId using HttpClient client = new HttpClient(handler); - await LoopbackServer.CreateServerAsync(async (server, uri) => - { - using var request = new HttpRequestMessage(HttpMethod.Get, uri); - Task requestTask = client.SendAsync(request, cts.Token); - - // First attempt: read the request, then close the connection so the request is gracefully retried. - await server.AcceptConnectionAsync(async connection => + await LoopbackServer.CreateClientAndServerAsync( + async uri => { - await connection.ReadRequestHeaderAsync(); - }); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + Task requestTask = client.SendAsync(request, cts.Token); - // Wait until the request is stuck establishing the next connection, then cancel it. - await retryConnecting.Task; - cts.Cancel(); + // Wait until the request is stuck establishing the next connection, then cancel it. + await retryConnecting.Task; + cts.Cancel(); - await Assert.ThrowsAnyAsync(() => requestTask); + await Assert.ThrowsAnyAsync(() => requestTask); - // The first connection was abandoned by the graceful retry and the retry never produced a - // connection, so the request must not still point at the first connection. - Assert.Null(request.ConnectionId); - }); + // The first connection was abandoned by the graceful retry and the retry never produced a + // connection, so the request must not still point at the first connection. + Assert.Null(request.ConnectionId); + }, + async server => + { + // First attempt: read the request, then close the connection so the request is gracefully retried. + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAsync(); + }); + }); } [Fact] @@ -3281,6 +3455,60 @@ public sealed class SocketsHttpHandlerTest_Http2 : HttpClientHandlerTest_Http2 { public SocketsHttpHandlerTest_Http2(ITestOutputHelper output) : base(output) { } + [OuterLoop("Waits for the connection pool maintenance timer to fire.")] + [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] + public async Task ShouldEvictConnection_Http2ConnectionAtStreamLimit_EvictedWhileInFlightRequestsComplete() + { + const int MaxConcurrentStreams = 2; + + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + server.AllowMultipleConnections = true; + + var saturatedConnectionEvaluated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + long firstConnectionId = -1; + + using SocketsHttpHandler handler = CreateHandler(); + handler.ShouldEvictConnection = (context, _) => + { + // Only one connection exists at this point, saturated with in-flight requests, so the first id we + // observe is it. Evicting it verifies the callback runs for a connection that has reached its stream + // limit and has active requests in flight (such a connection can no longer accept new streams, so the + // pool routes new requests elsewhere). + Interlocked.CompareExchange(ref firstConnectionId, context.ConnectionId, -1); + bool evict = context.ConnectionId == Interlocked.Read(ref firstConnectionId); + if (evict) + { + saturatedConnectionEvaluated.TrySetResult(); + } + return Task.FromResult(evict); + }; + + using HttpClient client = CreateHttpClient(handler); + + // Establish a connection and fill all of its stream slots with in-flight (unanswered) requests. + var sendTasks = new List>(); + Http2LoopbackConnection connection0 = await PrepareConnection(server, client, MaxConcurrentStreams).ConfigureAwait(false); + AcquireAllStreamSlots(server, client, sendTasks, MaxConcurrentStreams); + int[] streamIds0 = await AcceptRequests(connection0, MaxConcurrentStreams).ConfigureAwait(false); + + // The connection is at its stream limit with active requests, yet the maintenance pass must still evaluate + // and evict it. + await saturatedConnectionEvaluated.Task.WaitAsync(TestHelper.PassingTestTimeout); + + // Even though the connection was evicted, its in-flight requests must complete normally. + await SendResponses(connection0, streamIds0); + await VerifySendTasks(sendTasks); + + // A subsequent request must be served on a brand new connection, because the first was evicted. + Task nextTask = client.GetAsync(server.Address); + Http2LoopbackConnection connection1 = await server.EstablishConnectionAsync(timeout: null, ackTimeout: TimeSpan.FromSeconds(10), + new SettingsEntry { SettingId = SettingId.MaxConcurrentStreams, Value = MaxConcurrentStreams }).WaitAsync(TestHelper.PassingTestTimeout); + (int nextStreamId, _) = await connection1.ReadAndParseRequestHeaderAsync().WaitAsync(TestHelper.PassingTestTimeout); + await connection1.SendDefaultResponseAsync(nextStreamId).WaitAsync(TestHelper.PassingTestTimeout); + using HttpResponseMessage nextResponse = await nextTask.WaitAsync(TestHelper.PassingTestTimeout); + Assert.True(nextResponse.IsSuccessStatusCode); + } + [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] public async Task Http2_MultipleConnectionsEnabled_ConnectionLimitNotReached_ConcurrentRequestsSuccessfullyHandled() { From 970b9c439f9430fc125563f49530b9e5f937355d Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 10 Jul 2026 12:51:41 +0200 Subject: [PATCH 06/11] Rebuild ref --- src/libraries/System.Net.Http/ref/System.Net.Http.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 4e9a9af79e8e15..3262d2ba2b55a8 100644 --- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs +++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs @@ -303,7 +303,7 @@ public partial class HttpRequestMessage : System.IDisposable public HttpRequestMessage() { } public HttpRequestMessage(System.Net.Http.HttpMethod method, [System.Diagnostics.CodeAnalysis.StringSyntaxAttribute("Uri")] string? requestUri) { } public HttpRequestMessage(System.Net.Http.HttpMethod method, System.Uri? requestUri) { } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat="https://aka.ms/dotnet-warnings/{0}")] public long? ConnectionId { get { throw null; } set { } } public System.Net.Http.HttpContent? Content { get { throw null; } set { } } public System.Net.Http.Headers.HttpRequestHeaders Headers { get { throw null; } } @@ -426,12 +426,12 @@ protected override void SerializeToStream(System.IO.Stream stream, System.Net.Tr public sealed partial class SocketsHttpConnectionContext { internal SocketsHttpConnectionContext() { } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat="https://aka.ms/dotnet-warnings/{0}")] public long ConnectionId { get { throw null; } } public System.Net.DnsEndPoint DnsEndPoint { get { throw null; } } public System.Net.Http.HttpRequestMessage InitialRequestMessage { get { throw null; } } } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat="https://aka.ms/dotnet-warnings/{0}")] public sealed partial class SocketsHttpConnectionEvictionContext { internal SocketsHttpConnectionEvictionContext() { } @@ -479,7 +479,7 @@ public SocketsHttpHandler() { } public System.Net.Http.HeaderEncodingSelector? RequestHeaderEncodingSelector { get { throw null; } set { } } public System.TimeSpan ResponseDrainTimeout { get { throw null; } set { } } public System.Net.Http.HeaderEncodingSelector? ResponseHeaderEncodingSelector { get { throw null; } set { } } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat="https://aka.ms/dotnet-warnings/{0}")] public System.Func>? ShouldEvictConnection { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public System.Net.Security.SslClientAuthenticationOptions SslOptions { get { throw null; } set { } } @@ -492,7 +492,7 @@ protected override void Dispose(bool disposing) { } public sealed partial class SocketsHttpPlaintextStreamFilterContext { internal SocketsHttpPlaintextStreamFilterContext() { } - [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] + [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5008", UrlFormat="https://aka.ms/dotnet-warnings/{0}")] public long ConnectionId { get { throw null; } } public System.Net.Http.HttpRequestMessage InitialRequestMessage { get { throw null; } } public System.Version NegotiatedHttpVersion { get { throw null; } } From 64aa111b5ca2f67e1a30aed49c00e07c6e31668f Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 10 Jul 2026 13:08:20 +0200 Subject: [PATCH 07/11] Fix UnitTests build --- .../tests/UnitTests/System.Net.Http.Unit.Tests.csproj | 2 ++ 1 file changed, 2 insertions(+) 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 be119816ef4532..43167dc48c6007 100755 --- 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 @@ -415,6 +415,8 @@ Link="Common\System\Text\ValueStringBuilder.AppendSpanFormattable.cs" /> + Date: Mon, 13 Jul 2026 18:07:09 +0200 Subject: [PATCH 08/11] Move the comment to the correct location --- .../System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs | 2 +- .../SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs index 2aeaeb0349ca9f..ed47243401c552 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs @@ -27,7 +27,7 @@ internal abstract class HttpConnectionBase : IDisposable, IHttpTrace // avoid decrementing the counter once it's closed in case telemetry was enabled in between. private bool _httpTelemetryMarkedConnectionAsOpened; - private readonly long _creationTickCount = Environment.TickCount64; // milliseconds from Environment.TickCount64, not TimeSpan ticks + private readonly long _creationTickCount = Environment.TickCount64; private long? _idleSinceTickCount; /// diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs index 7df2a6ab17f446..254d53742d4d74 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs @@ -17,7 +17,7 @@ namespace System.Net.Http [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public sealed class SocketsHttpConnectionEvictionContext { - private readonly long _creationTickCount; + private readonly long _creationTickCount; // milliseconds from Environment.TickCount64, not TimeSpan ticks internal SocketsHttpConnectionEvictionContext( DnsEndPoint dnsEndPoint, From 990d6c014cae0ef6e74ffd54824aeb8be4f7bd38 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 15 Jul 2026 20:05:41 +0200 Subject: [PATCH 09/11] More behavior comments around HTTP CONNECT tunnels --- .../src/System/Net/Http/HttpRequestMessage.cs | 24 ++- .../SocketsHttpConnectionContext.cs | 5 + .../SocketsHttpConnectionEvictionContext.cs | 12 +- ...SocketsHttpPlaintextStreamFilterContext.cs | 12 +- .../FunctionalTests/SocketsHttpHandlerTest.cs | 189 +++++++++++++++++- .../tests/FunctionalTests/TelemetryTest.cs | 77 +++++++ 6 files changed, 304 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs index 578eb0c4f5ad1a..ed6573b5aa8f3c 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs @@ -123,14 +123,26 @@ public Uri? RequestUri public HttpRequestOptions Options => _options ??= new HttpRequestOptions(); /// - /// Gets or sets the identifier of the connection that this request was most recently sent on, or - /// if it has not been sent over a connection. + /// Gets or sets the identifier of the connection that this request was most recently sent on. The value is not + /// guaranteed to be set: it remains when the request was not handled by a connection, for + /// example because it timed out before a connection could be obtained. /// /// - /// The value matches the connection id reported through EventSource telemetry, and the id surfaced to - /// and , - /// allowing a caller to correlate a request with the connection that served it. When a request is sent over - /// multiple connections (for example after a redirect or a retry), the value reflects the most recent attempt. + /// When the request is sent through a , the value matches the connection id + /// reported through EventSource telemetry and the id passed to + /// for the connection that served the request, allowing + /// a caller to correlate a request with that connection. It also matches the id surfaced to a custom + /// , except when the request is served over an HTTP CONNECT + /// proxy tunnel: there the callback observes the tunnel's underlying transport connection to the proxy, whose + /// id differs from this one (which identifies the tunneled connection that carried the request). Both ids remain + /// observable through a , which runs once per hop and + /// reports the transport connection's id for the CONNECT hop and this id for the tunneled hop. When a request + /// is sent over multiple connections (for example after a redirect or a retry), the value reflects the most + /// recent attempt. + /// + /// These correlations apply only when the request is handled by . Another + /// may never set this value, or may assign it a different meaning. + /// /// /// This property is intended to be read after the request has been sent. Assigning a value before the request /// is sent has no effect on how the request is handled: it does not request or influence the use of a particular diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs index 62102e1ef5f3f7..7673fa17c85d22 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs @@ -40,6 +40,11 @@ internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessag /// stamped on requests sent over the connection. It can be used /// to associate caller state (for example, the resolved address used) with the connection and to correlate it /// with the requests it serves, so that state can be recovered later (for example when deciding on eviction). + /// When establishing the transport for an HTTP CONNECT proxy tunnel, this id identifies that transport + /// connection to the proxy; the tunneled connection layered over it serves the requests and carries a distinct + /// id, which is the one reported to and stamped on those + /// requests. A runs on each hop and surfaces both: this + /// (transport) id on the CONNECT hop and the tunneled connection's id on the subsequent hop. /// [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public long ConnectionId => _connectionId; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs index 254d53742d4d74..85d025dc3906e4 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs @@ -54,11 +54,15 @@ internal SocketsHttpConnectionEvictionContext( /// /// Gets the identifier assigned to the connection. This matches the connection id reported through - /// telemetry, the + /// telemetry and the stamped on + /// requests sent over the connection. It also matches the /// seen by a custom - /// , and the - /// stamped on requests sent over the connection. It allows the eviction decision to be correlated both with - /// state the caller associated with the connection at creation time and with the requests it served. + /// , except for an HTTP CONNECT proxy tunnel, where the + /// callback observes the underlying transport connection to the proxy while this id identifies the tunneled + /// connection that served the requests. Both ids remain observable through a + /// , which runs on each hop and reports the transport + /// id for the CONNECT hop and this id for the tunneled hop. It allows the eviction decision to be correlated + /// with the requests the connection served. /// public long ConnectionId { get; } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs index 26f325b0279f7a..23fcc610643f82 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs @@ -42,12 +42,16 @@ internal SocketsHttpPlaintextStreamFilterContext(Stream plaintextStream, Version /// /// The identifier of the connection whose stream is being filtered. This matches the connection id reported - /// through telemetry, the - /// surfaced to , the + /// through telemetry for that connection. For a direct connection it also matches the + /// surfaced to + /// , the /// passed to /// , and the - /// stamped on requests sent over the connection. It can be used to associate caller state with the connection - /// and to correlate it with the requests it serves. + /// stamped on requests sent over the connection. For an HTTP CONNECT proxy tunnel the filter runs once per hop: + /// on the CONNECT hop this is the transport connection's id (the one the ConnectCallback observed), and on the + /// tunneled hop it is the tunneled connection's id (the one passed to ShouldEvictConnection and stamped on + /// requests). It can be used to associate caller state with the connection and to correlate it with the + /// requests it serves. /// [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public long ConnectionId => _connectionId; diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 9df0bd0b9661d4..7ba06d49e0ad42 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -2746,7 +2746,7 @@ await LoopbackServerFactory.CreateClientAndServerAsync( [OuterLoop("Waits for the connection pool maintenance timer to fire.")] [Fact] - public async Task ShouldEvictConnection_ThroughHttpsProxyTunnel_ReportsOriginAndRequestVersion() + public async Task ConnectTunnel_EndToEnd_ConnectionIdFlowsThroughCallbacksAndEviction() { if (UseVersion == HttpVersion.Version30) { @@ -2761,6 +2761,13 @@ public async Task ShouldEvictConnection_ThroughHttpsProxyTunnel_ReportsOriginAnd var evictionObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); SocketsHttpConnectionEvictionContext capturedContext = null; + // A CONNECT tunnel uses two connections: the transport to the proxy (the "tunnel", always HTTP/1.1 for the + // CONNECT) and a distinct connection layered over it that actually serves the request (the "inner" + // connection, e.g. HTTP/2). Capture each callback's connection id to verify how they flow end to end. + long connectCallbackId = -1; + var plaintextInvocations = new List<(Version Version, long ConnectionId)>(); + long? requestConnectionId = null; + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); using HttpClientHandler handler = CreateHttpClientHandler(); @@ -2768,6 +2775,25 @@ public async Task ShouldEvictConnection_ThroughHttpsProxyTunnel_ReportsOriginAnd socketsHandler.PooledConnectionLifetime = Timeout.InfiniteTimeSpan; socketsHandler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(4); handler.Proxy = new WebProxy(proxyServer.Uri); + socketsHandler.ConnectCallback = async (context, ct) => + { + // The only transport a ConnectCallback establishes here is the tunnel to the proxy. + if (connectCallbackId == -1) + { + connectCallbackId = context.ConnectionId; + } + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new NetworkStream(socket, ownsSocket: true); + }; + socketsHandler.PlaintextStreamFilter = (context, _) => + { + lock (plaintextInvocations) + { + plaintextInvocations.Add((context.NegotiatedHttpVersion, context.ConnectionId)); + } + return ValueTask.FromResult(context.PlaintextStream); + }; socketsHandler.ShouldEvictConnection = (context, _) => { capturedContext ??= context; @@ -2784,11 +2810,30 @@ await LoopbackServerFactory.CreateClientAndServerAsync( using (HttpResponseMessage response = await client.SendAsync(TestAsync, request)) { Assert.True(response.IsSuccessStatusCode); + requestConnectionId = request.ConnectionId; } await evictionObserved.Task.WaitAsync(TestHelper.PassingTestTimeout); + // The ConnectCallback saw the tunnel's transport connection to the proxy. + Assert.NotEqual(-1, connectCallbackId); + + // The plaintext filter runs once per hop: first on the HTTP/1.1 CONNECT connection (the tunnel), then + // on the connection negotiated with the origin over it (the inner connection, at the request version). + Assert.Equal(2, plaintextInvocations.Count); + Assert.Equal(HttpVersion.Version11, plaintextInvocations[0].Version); + Assert.Equal(UseVersion, plaintextInvocations[1].Version); + + // The first filter hop and the ConnectCallback observe the same (tunnel) connection id... + Assert.Equal(connectCallbackId, plaintextInvocations[0].ConnectionId); + // ...while the second filter hop, the request, and the eviction context all observe the distinct + // inner connection id that actually served the request. + Assert.NotNull(requestConnectionId); + Assert.Equal(requestConnectionId.Value, plaintextInvocations[1].ConnectionId); + Assert.NotEqual(connectCallbackId, requestConnectionId.Value); + Assert.NotNull(capturedContext); + Assert.Equal(requestConnectionId.Value, capturedContext.ConnectionId); // The CONNECT tunnel to the proxy is always HTTP/1, but the reported version is the end-to-end // request version, and the DnsEndPoint is the tunneled origin (not the proxy). Assert.Equal(UseVersion, capturedContext.HttpVersion); @@ -3182,6 +3227,100 @@ await server.AcceptConnectionAsync(async connection => }); } + [Fact] + public async Task ConnectionId_ForwardingProxy_MatchesConnectCallbackId() + { + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + long connectCallbackId = -1; + + using var handler = new SocketsHttpHandler + { + Proxy = new WebProxy(proxyServer.Uri), + }; + handler.ConnectCallback = async (context, ct) => + { + // A plain (forwarding) proxy uses a single connection that targets the proxy itself and also serves + // the request, so the id observed here is the one that ends up on the request. + connectCallbackId = context.ConnectionId; + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new NetworkStream(socket, ownsSocket: true); + }; + + using HttpClient client = new HttpClient(handler); + + await LoopbackServer.CreateServerAsync(async (server, uri) => + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + Assert.Null(request.ConnectionId); + + Task requestTask = client.SendAsync(request); + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(content: "hello"); + }); + using HttpResponseMessage response = await requestTask; + + // The connection to the proxy is the one the ConnectCallback established and the one that serves the + // request, so the id reported on the request is exactly the one observable in the ConnectCallback. + Assert.NotEqual(-1, connectCallbackId); + Assert.NotNull(request.ConnectionId); + Assert.Equal(connectCallbackId, request.ConnectionId.Value); + }); + } + + [Fact] + public async Task ConnectionId_HttpsProxyTunnel_RequestReportsTunneledConnectionNotProxyTransport() + { + long connectCallbackId = -1; + + await LoopbackServer.CreateClientAndServerAsync( + async proxyUri => + { + using var handler = new SocketsHttpHandler + { + Proxy = new WebProxy(proxyUri), + }; + handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; + handler.ConnectCallback = async (context, ct) => + { + // For an HTTPS origin the proxy hop is a CONNECT tunnel: the ConnectCallback establishes the + // transport to the proxy, which carries the tunneled connection that actually serves the request. + connectCallbackId = context.ConnectionId; + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new NetworkStream(socket, ownsSocket: true); + }; + + using HttpClient client = new HttpClient(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://foo.bar/"); + Assert.Null(request.ConnectionId); + + using HttpResponseMessage response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The transport the ConnectCallback established is the CONNECT tunnel to the proxy; the request is + // served by a distinct connection layered over that tunnel, so the request reports a different id. + Assert.NotEqual(-1, connectCallbackId); + Assert.NotNull(request.ConnectionId); + Assert.NotEqual(connectCallbackId, request.ConnectionId.Value); + }, + async server => + { + await server.AcceptConnectionAsync(async connection => + { + // Read the plaintext CONNECT request and answer 200 to open the tunnel, then negotiate TLS + // with the client over the tunnel and serve the actual request. + await connection.ReadRequestHeaderAndSendResponseAsync(); + await using LoopbackServer.Connection sslConnection = await LoopbackServer.Connection.CreateAsync( + null, connection.Stream, new LoopbackServer.Options { UseSsl = true }); + await sslConnection.ReadRequestHeaderAndSendResponseAsync(); + }); + }); + } + [Fact] public void Properties_Roundtrips() { @@ -4743,6 +4882,54 @@ await LoopbackServerFactory.CreateClientAndServerAsync( }, options: options); } + [Fact] + public async Task PlaintextStreamFilter_HttpsProxyTunnel_RunsPerHopWithDistinctConnectionIds() + { + if (UseVersion == HttpVersion.Version30) + { + return; // HTTP/3 (QUIC) cannot be tunneled through an HTTP CONNECT proxy. + } + + var invocations = new List<(Version Version, long ConnectionId)>(); + + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + using HttpClientHandler handler = CreateHttpClientHandler(allowAllCertificates: true); + var socketsHandler = (SocketsHttpHandler)GetUnderlyingSocketsHttpHandler(handler); + handler.Proxy = new WebProxy(proxyServer.Uri); + socketsHandler.PlaintextStreamFilter = (context, token) => + { + lock (invocations) + { + invocations.Add((context.NegotiatedHttpVersion, context.ConnectionId)); + } + return ValueTask.FromResult(context.PlaintextStream); + }; + + using HttpClient client = CreateHttpClient(handler); + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + Assert.True(response.IsSuccessStatusCode); + + // The filter runs once per hop, each hop being a distinct connection: first the HTTP/1.1 CONNECT + // connection to the proxy, then the connection negotiated with the origin over the tunnel (e.g. + // HTTP/2). Only the latter serves the request, so only its id ends up on the request. + Assert.Equal(2, invocations.Count); + Assert.Equal(HttpVersion.Version11, invocations[0].Version); + Assert.Equal(UseVersion, invocations[1].Version); + Assert.NotEqual(invocations[0].ConnectionId, invocations[1].ConnectionId); + Assert.NotNull(request.ConnectionId); + Assert.Equal(request.ConnectionId.Value, invocations[1].ConnectionId); + }, + server => server.HandleRequestAsync(), + // HTTPS origin forces an HTTP/1 CONNECT tunnel through the proxy. + options: new GenericLoopbackOptions() { UseSsl = true }); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs index 1637790a463782..10f486ea91e8db 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/TelemetryTest.cs @@ -1039,6 +1039,83 @@ await LoopbackServer.CreateClientAndServerAsync(async uri => }, UseVersion.ToString(), useSsl.ToString()).DisposeAsync(); } + [OuterLoop("Disposes the handler to force the connection closed.")] + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public async Task EventSource_ConnectTunnel_LogsBothTransportAndTunnelConnections() + { + if (UseVersion.Major == 3) + { + return; // HTTP/3 (QUIC) cannot be tunneled through an HTTP CONNECT proxy. + } + + await RemoteExecutor.Invoke(static async (string useVersionString) => + { + Version version = Version.Parse(useVersionString); + using var listener = new TestEventListener("System.Net.Http", EventLevel.Verbose, eventCounterInterval: 0.1d); + + var events = new ConcurrentQueue<(EventWrittenEventArgs Event, Guid ActivityId)>(); + long stampedConnectionId = -1; + Version requestVersion = null; + + await listener.RunWithCallbackAsync(e => events.Enqueue((e, e.ActivityId)), async () => + { + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + await GetFactoryForVersion(version).CreateClientAndServerAsync( + async uri => + { + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + handler.Proxy = new WebProxy(proxyServer.Uri); + using HttpClient client = CreateHttpClient(handler, useVersionString); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri) + { + Version = version, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + (await client.SendAsync(request)).Dispose(); + + Assert.NotNull(request.ConnectionId); + stampedConnectionId = request.ConnectionId.Value; + requestVersion = request.Version; + // Disposing the handler (end of this scope) closes the tunnel connection, emitting + // ConnectionClosed while the listener is still capturing. + }, + server => server.HandleRequestAsync(), + // HTTPS origin forces an HTTP/1 CONNECT tunnel through the proxy. + options: new GenericLoopbackOptions() { UseSsl = true }); + }); + + EventWrittenEventArgs[] established = events.Select(e => e.Event).Where(e => e.EventName == "ConnectionEstablished").ToArray(); + EventWrittenEventArgs[] closed = events.Select(e => e.Event).Where(e => e.EventName == "ConnectionClosed").ToArray(); + + // A CONNECT tunnel uses two connection objects over one transport: the HTTP/1.1 connection to the proxy + // that carries the CONNECT (the tunnel) and the connection negotiated with the origin over it (the inner + // connection) that serves the request. Both report their lifecycle, so two ConnectionEstablished and two + // ConnectionClosed events are logged, with distinct ids. + Assert.Equal(2, established.Length); + Assert.Equal(2, closed.Length); + + long[] establishedIds = established.Select(e => (long)e.Payload[2]).ToArray(); + Assert.Equal(2, establishedIds.Distinct().Count()); + Assert.Equal(establishedIds.OrderBy(id => id).ToArray(), closed.Select(e => (long)e.Payload[2]).OrderBy(id => id).ToArray()); + + // The inner connection served the request: it carries the id stamped on the request, at the negotiated + // end-to-end version (e.g. HTTP/2). + EventWrittenEventArgs innerEstablished = Assert.Single(established, e => (long)e.Payload[2] == stampedConnectionId); + Assert.Equal((byte)requestVersion.Major, (byte)innerEstablished.Payload[0]); // versionMajor + Assert.Equal((byte)requestVersion.Minor, (byte)innerEstablished.Payload[1]); // versionMinor + + // The other is the tunnel's transport connection to the proxy, always logged as HTTP/1.1. + EventWrittenEventArgs tunnelEstablished = Assert.Single(established, e => (long)e.Payload[2] != stampedConnectionId); + Assert.Equal((byte)1, (byte)tunnelEstablished.Payload[0]); // versionMajor + Assert.Equal((byte)1, (byte)tunnelEstablished.Payload[1]); // versionMinor + + // The request itself uses the negotiated end-to-end version (e.g. HTTP/2). + Assert.Equal(version, requestVersion); + }, UseVersion.ToString()).DisposeAsync(); + } + protected static async Task WaitForEventCountersAsync(ConcurrentQueue<(EventWrittenEventArgs Event, Guid ActivityId)> events) { DateTime startTime = DateTime.UtcNow; From eab115a63dd765da48e54de2f5aa9e2fdb772f3e Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 15 Jul 2026 20:28:57 +0200 Subject: [PATCH 10/11] Store a flag for null ConnectionIds to reduce object size --- .../src/System/Net/Http/HttpRequestMessage.cs | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs index ed6573b5aa8f3c..fcf170ed596b6b 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs @@ -6,7 +6,6 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Text; -using System.Threading; namespace System.Net.Http { @@ -15,15 +14,19 @@ public class HttpRequestMessage : IDisposable internal static Version DefaultRequestVersion => HttpVersion.Version11; internal static HttpVersionPolicy DefaultVersionPolicy => HttpVersionPolicy.RequestVersionOrLower; - private const int MessageNotYetSent = 0; - private const int MessageAlreadySent = 1; - private const int PropagatorStateInjectedByDiagnosticsHandler = 2; - private const int MessageDisposed = 4; - private const int AuthDisabled = 8; + [Flags] + private enum MessageFlags + { + AlreadySent = 1, + PropagatorStateInjectedByDiagnosticsHandler = 2, + Disposed = 4, + AuthDisabled = 8, + ConnectionIdSet = 16, + } - // Track whether the message has been sent. - // The message shouldn't be sent again if this field is equal to MessageAlreadySent. - private int _sendStatus = MessageNotYetSent; + private MessageFlags _flags; + + private long _connectionId; private HttpMethod _method; private Uri? _requestUri; @@ -151,7 +154,23 @@ public Uri? RequestUri /// /// [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] - public long? ConnectionId { get; set; } + public long? ConnectionId + { + // ConnectionIdSet is stored separately to avoid the extra bytes needed for a nullable 'long?' field. + get => _flags.HasFlag(MessageFlags.ConnectionIdSet) ? _connectionId : null; + set + { + if (value is null) + { + _flags &= ~MessageFlags.ConnectionIdSet; + } + else + { + _connectionId = value.Value; + _flags |= MessageFlags.ConnectionIdSet; + } + } + } public HttpRequestMessage() : this(HttpMethod.Get, (Uri?)null) @@ -206,25 +225,30 @@ public override string ToString() return sb.ToString(); } - internal bool MarkAsSent() => Interlocked.CompareExchange(ref _sendStatus, MessageAlreadySent, MessageNotYetSent) == MessageNotYetSent; + internal bool MarkAsSent() + { + MessageFlags previousFlags = _flags; + _flags = previousFlags | MessageFlags.AlreadySent; + return !previousFlags.HasFlag(MessageFlags.AlreadySent); + } - internal bool WasSentByHttpClient() => (_sendStatus & MessageAlreadySent) != 0; + internal bool WasSentByHttpClient() => _flags.HasFlag(MessageFlags.AlreadySent); - internal void MarkPropagatorStateInjectedByDiagnosticsHandler() => _sendStatus |= PropagatorStateInjectedByDiagnosticsHandler; + internal void MarkPropagatorStateInjectedByDiagnosticsHandler() => _flags |= MessageFlags.PropagatorStateInjectedByDiagnosticsHandler; - internal bool WasPropagatorStateInjectedByDiagnosticsHandler() => (_sendStatus & PropagatorStateInjectedByDiagnosticsHandler) != 0; + internal bool WasPropagatorStateInjectedByDiagnosticsHandler() => _flags.HasFlag(MessageFlags.PropagatorStateInjectedByDiagnosticsHandler); - internal void DisableAuth() => _sendStatus |= AuthDisabled; + internal void DisableAuth() => _flags |= MessageFlags.AuthDisabled; - internal bool IsAuthDisabled() => (_sendStatus & AuthDisabled) != 0; + internal bool IsAuthDisabled() => _flags.HasFlag(MessageFlags.AuthDisabled); private bool Disposed { - get => (_sendStatus & MessageDisposed) != 0; + get => _flags.HasFlag(MessageFlags.Disposed); set { Debug.Assert(value); - _sendStatus |= MessageDisposed; + _flags |= MessageFlags.Disposed; } } From 8be3be0246abdbddae29313435dedb293a184761 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Thu, 16 Jul 2026 17:17:06 +0200 Subject: [PATCH 11/11] Tweak comments around CONNECT more --- .../src/System/Net/Http/HttpRequestMessage.cs | 17 ++++++++++------- .../SocketsHttpConnectionContext.cs | 4 +++- .../SocketsHttpConnectionEvictionContext.cs | 13 ++++++++----- .../SocketsHttpPlaintextStreamFilterContext.cs | 13 ++++++++----- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs index fcf170ed596b6b..0f966c18079f1b 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpRequestMessage.cs @@ -135,13 +135,16 @@ public Uri? RequestUri /// reported through EventSource telemetry and the id passed to /// for the connection that served the request, allowing /// a caller to correlate a request with that connection. It also matches the id surfaced to a custom - /// , except when the request is served over an HTTP CONNECT - /// proxy tunnel: there the callback observes the tunnel's underlying transport connection to the proxy, whose - /// id differs from this one (which identifies the tunneled connection that carried the request). Both ids remain - /// observable through a , which runs once per hop and - /// reports the transport connection's id for the CONNECT hop and this id for the tunneled hop. When a request - /// is sent over multiple connections (for example after a redirect or a retry), the value reflects the most - /// recent attempt. + /// . When a request is sent over multiple connections (for + /// example after a redirect or a retry), the value reflects the most recent attempt. + /// + /// HTTP CONNECT proxy tunnels are an exception to the correlation with a custom + /// : when the request is served over such a tunnel, the callback + /// observes the tunnel's underlying transport connection to the proxy, whose id differs from this one (which + /// identifies the tunneled connection that carried the request). Both ids remain observable through a + /// , which runs once per hop and reports the transport + /// connection's id for the CONNECT hop and this id for the tunneled hop. + /// /// /// These correlations apply only when the request is handled by . Another /// may never set this value, or may assign it a different meaning. diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs index 7673fa17c85d22..b01aa75aff5043 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionContext.cs @@ -40,12 +40,14 @@ internal SocketsHttpConnectionContext(DnsEndPoint dnsEndPoint, HttpRequestMessag /// stamped on requests sent over the connection. It can be used /// to associate caller state (for example, the resolved address used) with the connection and to correlate it /// with the requests it serves, so that state can be recovered later (for example when deciding on eviction). + /// + /// /// When establishing the transport for an HTTP CONNECT proxy tunnel, this id identifies that transport /// connection to the proxy; the tunneled connection layered over it serves the requests and carries a distinct /// id, which is the one reported to and stamped on those /// requests. A runs on each hop and surfaces both: this /// (transport) id on the CONNECT hop and the tunneled connection's id on the subsequent hop. - /// + /// [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public long ConnectionId => _connectionId; } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs index 85d025dc3906e4..09d2454ca0e21a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpConnectionEvictionContext.cs @@ -57,13 +57,16 @@ internal SocketsHttpConnectionEvictionContext( /// telemetry and the stamped on /// requests sent over the connection. It also matches the /// seen by a custom - /// , except for an HTTP CONNECT proxy tunnel, where the - /// callback observes the underlying transport connection to the proxy while this id identifies the tunneled - /// connection that served the requests. Both ids remain observable through a - /// , which runs on each hop and reports the transport - /// id for the CONNECT hop and this id for the tunneled hop. It allows the eviction decision to be correlated + /// . It allows the eviction decision to be correlated /// with the requests the connection served. /// + /// + /// For an HTTP CONNECT proxy tunnel the id seen by a custom + /// differs from this one: the callback observes the underlying transport connection to the proxy while this id + /// identifies the tunneled connection that served the requests. Both ids remain observable through a + /// , which runs on each hop and reports the transport + /// id for the CONNECT hop and this id for the tunneled hop. + /// public long ConnectionId { get; } /// diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs index 23fcc610643f82..3cc66f45d80ede 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpPlaintextStreamFilterContext.cs @@ -47,12 +47,15 @@ internal SocketsHttpPlaintextStreamFilterContext(Stream plaintextStream, Version /// , the /// passed to /// , and the - /// stamped on requests sent over the connection. For an HTTP CONNECT proxy tunnel the filter runs once per hop: - /// on the CONNECT hop this is the transport connection's id (the one the ConnectCallback observed), and on the - /// tunneled hop it is the tunneled connection's id (the one passed to ShouldEvictConnection and stamped on - /// requests). It can be used to associate caller state with the connection and to correlate it with the - /// requests it serves. + /// stamped on requests sent over the connection. It can be used to associate caller state with the connection + /// and to correlate it with the requests it serves. /// + /// + /// For an HTTP CONNECT proxy tunnel the filter runs once per hop: on the CONNECT hop this is the transport + /// connection's id (the one the observed), and on the + /// tunneled hop it is the tunneled connection's id (the one passed to + /// and stamped on requests). + /// [Experimental(Experimentals.SocketsHttpHandlerExperimentalDiagId, UrlFormat = Experimentals.SharedUrlFormat)] public long ConnectionId => _connectionId; }