diff --git a/eng/Versions.props b/eng/Versions.props
index ccd72cc92110e..5d7da41853be3 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -179,7 +179,7 @@
7.0.0-rtm.24115.1
- 2.2.3
+ 2.3.57.0.0-alpha.1.22459.111.1.0-alpha.1.23115.1
diff --git a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs
index 6263483807c76..9863d0adbe9ab 100644
--- a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs
+++ b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs
@@ -402,7 +402,7 @@ public async Task ReadRequestHeaderFrameAsync(bool expectEndOfStre
return (HeadersFrame)frame;
}
- public async Task ReadDataFrameAsync()
+ public async Task ReadDataFrameAsync()
{
// Receive DATA frame for request.
Frame frame = await ReadFrameAsync(_timeout).ConfigureAwait(false);
@@ -412,7 +412,7 @@ public async Task ReadDataFrameAsync()
}
Assert.Equal(FrameType.Data, frame.Type);
- return frame;
+ return (DataFrame)frame;
}
private static (int bytesConsumed, int value) DecodeInteger(ReadOnlySpan headerBlock, byte prefixMask)
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 45dc67d7342c5..00b8f21fa8dd3 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
@@ -1444,6 +1444,14 @@ private int WriteHeaderCollection(HttpRequestMessage request, HttpHeaders header
continue;
}
+ // Extended connect requests will use the response content stream for bidirectional communication.
+ // We will ignore any content set for such requests in Http2Stream.SendRequestBodyAsync, as it has no defined semantics.
+ // Drop the Content-Length header as well in the unlikely case it was set.
+ if (knownHeader == KnownHeaders.ContentLength && request.IsExtendedConnectRequest)
+ {
+ continue;
+ }
+
// For all other known headers, send them via their pre-encoded name and the associated value.
WriteBytes(knownHeader.Http2EncodedName, ref headerBuffer);
string? separator = null;
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs
index 89cacb066ece5..edde62b82a56a 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Stream.cs
@@ -105,7 +105,9 @@ public Http2Stream(HttpRequestMessage request, Http2Connection connection)
_headerBudgetRemaining = connection._pool.Settings.MaxResponseHeadersByteLength;
- if (_request.Content == null)
+ // Extended connect requests will use the response content stream for bidirectional communication.
+ // We will ignore any content set for such requests in SendRequestBodyAsync, as it has no defined semantics.
+ if (_request.Content == null || _request.IsExtendedConnectRequest)
{
_requestCompletionState = StreamCompletionState.Completed;
if (_request.IsExtendedConnectRequest)
@@ -173,7 +175,9 @@ public HttpResponseMessage GetAndClearResponse()
public async Task SendRequestBodyAsync(CancellationToken cancellationToken)
{
- if (_request.Content == null)
+ // Extended connect requests will use the response content stream for bidirectional communication.
+ // Ignore any content set for such requests, as it has no defined semantics.
+ if (_request.Content == null || _request.IsExtendedConnectRequest)
{
Debug.Assert(_requestCompletionState == StreamCompletionState.Completed);
return;
@@ -250,6 +254,7 @@ public async Task SendRequestBodyAsync(CancellationToken cancellationToken)
// and we also don't want to propagate any error to the caller, in particular for non-duplex scenarios.
Debug.Assert(_responseCompletionState == StreamCompletionState.Completed);
_requestCompletionState = StreamCompletionState.Completed;
+ Debug.Assert(!ConnectProtocolEstablished);
Complete();
return;
}
@@ -261,6 +266,7 @@ public async Task SendRequestBodyAsync(CancellationToken cancellationToken)
_requestCompletionState = StreamCompletionState.Failed;
SendReset();
+ Debug.Assert(!ConnectProtocolEstablished);
Complete();
}
@@ -313,6 +319,7 @@ public async Task SendRequestBodyAsync(CancellationToken cancellationToken)
if (complete)
{
+ Debug.Assert(!ConnectProtocolEstablished);
Complete();
}
}
@@ -420,7 +427,17 @@ private void Cancel()
if (sendReset)
{
SendReset();
- Complete();
+
+ // Extended CONNECT notes:
+ //
+ // To prevent from calling it *twice*, Extended CONNECT stream's Complete() is only
+ // called from CloseResponseBody(), as CloseResponseBody() is *always* called
+ // from Extended CONNECT stream's Dispose().
+
+ if (!ConnectProtocolEstablished)
+ {
+ Complete();
+ }
}
}
@@ -810,7 +827,20 @@ public void OnHeadersComplete(bool endStream)
Debug.Assert(_responseCompletionState == StreamCompletionState.InProgress, $"Response already completed with state={_responseCompletionState}");
_responseCompletionState = StreamCompletionState.Completed;
- if (_requestCompletionState == StreamCompletionState.Completed)
+
+ // Extended CONNECT notes:
+ //
+ // To prevent from calling it *prematurely*, Extended CONNECT stream's Complete() is only
+ // called from CloseResponseBody(), as CloseResponseBody() is *only* called
+ // from Extended CONNECT stream's Dispose().
+ //
+ // Due to bidirectional streaming nature of the Extended CONNECT request,
+ // the *write side* of the stream can only be completed by calling Dispose().
+ //
+ // The streaming in both ways happens over the single "response" stream instance, which makes
+ // _requestCompletionState *not indicative* of the actual state of the write side of the stream.
+
+ if (_requestCompletionState == StreamCompletionState.Completed && !ConnectProtocolEstablished)
{
Complete();
}
@@ -871,7 +901,20 @@ public void OnResponseData(ReadOnlySpan buffer, bool endStream)
Debug.Assert(_responseCompletionState == StreamCompletionState.InProgress, $"Response already completed with state={_responseCompletionState}");
_responseCompletionState = StreamCompletionState.Completed;
- if (_requestCompletionState == StreamCompletionState.Completed)
+
+ // Extended CONNECT notes:
+ //
+ // To prevent from calling it *prematurely*, Extended CONNECT stream's Complete() is only
+ // called from CloseResponseBody(), as CloseResponseBody() is *only* called
+ // from Extended CONNECT stream's Dispose().
+ //
+ // Due to bidirectional streaming nature of the Extended CONNECT request,
+ // the *write side* of the stream can only be completed by calling Dispose().
+ //
+ // The streaming in both ways happens over the single "response" stream instance, which makes
+ // _requestCompletionState *not indicative* of the actual state of the write side of the stream.
+
+ if (_requestCompletionState == StreamCompletionState.Completed && !ConnectProtocolEstablished)
{
Complete();
}
@@ -1036,17 +1079,17 @@ public async Task ReadResponseHeadersAsync(CancellationToken cancellationToken)
Debug.Assert(_response != null && _response.Content != null);
// Start to process the response body.
var responseContent = (HttpConnectionResponseContent)_response.Content;
- if (emptyResponse)
+ if (ConnectProtocolEstablished)
+ {
+ responseContent.SetStream(new Http2ReadWriteStream(this, closeResponseBodyOnDispose: true));
+ }
+ else if (emptyResponse)
{
// If there are any trailers, copy them over to the response. Normally this would be handled by
// the response stream hitting EOF, but if there is no response body, we do it here.
MoveTrailersToResponseMessage(_response);
responseContent.SetStream(EmptyReadStream.Instance);
}
- else if (ConnectProtocolEstablished)
- {
- responseContent.SetStream(new Http2ReadWriteStream(this));
- }
else
{
responseContent.SetStream(new Http2ReadStream(this));
@@ -1309,8 +1352,25 @@ private async ValueTask SendDataAsync(ReadOnlyMemory buffer, CancellationT
}
}
+ // This method should only be called from Http2ReadWriteStream.Dispose()
private void CloseResponseBody()
{
+ // Extended CONNECT notes:
+ //
+ // Due to bidirectional streaming nature of the Extended CONNECT request,
+ // the *write side* of the stream can only be completed by calling Dispose()
+ // (which, for Extended CONNECT case, will in turn call CloseResponseBody())
+ //
+ // Similarly to QuicStream, disposal *gracefully* closes the write side of the stream
+ // (unless we've received RST_STREAM before) and *abortively* closes the read side
+ // of the stream (unless we've received EOS before).
+
+ if (ConnectProtocolEstablished && _resetException is null)
+ {
+ // Gracefully close the write side of the Extended CONNECT stream
+ _connection.LogExceptions(_connection.SendEndStreamAsync(StreamId));
+ }
+
// Check if the response body has been fully consumed.
bool fullyConsumed = false;
Debug.Assert(!Monitor.IsEntered(SyncObject));
@@ -1323,6 +1383,7 @@ private void CloseResponseBody()
}
// If the response body isn't completed, cancel it now.
+ // This includes aborting the read side of the Extended CONNECT stream.
if (!fullyConsumed)
{
Cancel();
@@ -1337,6 +1398,12 @@ private void CloseResponseBody()
lock (SyncObject)
{
+ if (ConnectProtocolEstablished)
+ {
+ // This should be the only place where Extended Connect stream is completed
+ Complete();
+ }
+
_responseBuffer.Dispose();
}
}
@@ -1430,10 +1497,7 @@ private enum StreamCompletionState : byte
private sealed class Http2ReadStream : Http2ReadWriteStream
{
- public Http2ReadStream(Http2Stream http2Stream) : base(http2Stream)
- {
- base.CloseResponseBodyOnDispose = true;
- }
+ public Http2ReadStream(Http2Stream http2Stream) : base(http2Stream, closeResponseBodyOnDispose: true) { }
public override bool CanWrite => false;
@@ -1482,12 +1546,13 @@ public class Http2ReadWriteStream : HttpBaseStream
private Http2Stream? _http2Stream;
private readonly HttpResponseMessage _responseMessage;
- public Http2ReadWriteStream(Http2Stream http2Stream)
+ public Http2ReadWriteStream(Http2Stream http2Stream, bool closeResponseBodyOnDispose = false)
{
Debug.Assert(http2Stream != null);
Debug.Assert(http2Stream._response != null);
_http2Stream = http2Stream;
_responseMessage = _http2Stream._response;
+ CloseResponseBodyOnDispose = closeResponseBodyOnDispose;
}
~Http2ReadWriteStream()
@@ -1503,7 +1568,7 @@ public Http2ReadWriteStream(Http2Stream http2Stream)
}
}
- protected bool CloseResponseBodyOnDispose { get; set; }
+ protected bool CloseResponseBodyOnDispose { get; private init; }
protected override void Dispose(bool disposing)
{
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs
index 7b14f99393c28..153cdf9c8c6d6 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Http2.cs
@@ -2516,66 +2516,198 @@ public async Task PostAsyncDuplex_ServerSendsEndStream_Success()
}
}
- [Fact]
- public async Task ConnectAsync_ReadWriteWebSocketStream()
+ [Theory]
+ [MemberData(nameof(UseSsl_MemberData))]
+ public async Task ExtendedConnect_ReadWriteResponseStream(bool useSsl)
{
- var clientMessage = new byte[] { 1, 2, 3 };
- var serverMessage = new byte[] { 4, 5, 6, 7 };
+ const int MessageCount = 3;
+ byte[] clientMessage = new byte[] { 1, 2, 3 };
+ byte[] serverMessage = new byte[] { 4, 5, 6, 7 };
- using Http2LoopbackServer server = Http2LoopbackServer.CreateServer();
- Http2LoopbackConnection connection = null;
+ TaskCompletionSource clientCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);
- Task serverTask = Task.Run(async () =>
+ await Http2LoopbackServerFactory.Singleton.CreateClientAndServerAsync(async uri =>
{
- connection = await server.EstablishConnectionAsync(new SettingsEntry { SettingId = SettingId.EnableConnect, Value = 1 });
+ using HttpClient client = CreateHttpClient();
- // read request headers
- (int streamId, _) = await connection.ReadAndParseRequestHeaderAsync(readBody: false);
+ HttpRequestMessage request = CreateRequest(HttpMethod.Connect, uri, UseVersion, exactVersion: true);
+ request.Headers.Protocol = "foo";
+
+ bool readFromContentStream = false;
+
+ // We won't send the content bytes, but we will send content headers.
+ // Since we're dropping the content, we'll also drop the Content-Length header.
+ request.Content = new StreamContent(new DelegateStream(
+ readAsyncFunc: (_, _, _, _) =>
+ {
+ readFromContentStream = true;
+ throw new UnreachableException();
+ }));
+
+ request.Headers.Add("User-Agent", "foo");
+ request.Content.Headers.Add("Content-Language", "bar");
+ request.Content.Headers.ContentLength = 42;
+
+ using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
+
+ using Stream responseStream = await response.Content.ReadAsStreamAsync();
+
+ for (int i = 0; i < MessageCount; i++)
+ {
+ await responseStream.WriteAsync(clientMessage);
+ await responseStream.FlushAsync();
+
+ byte[] readBuffer = new byte[serverMessage.Length];
+ await responseStream.ReadExactlyAsync(readBuffer);
+ Assert.Equal(serverMessage, readBuffer);
+ }
+
+ // Receive server's EOS
+ Assert.Equal(0, await responseStream.ReadAsync(new byte[1]));
+
+ Assert.False(readFromContentStream);
+
+ clientCompleted.SetResult();
+ },
+ async server =>
+ {
+ await using Http2LoopbackConnection connection = await ((Http2LoopbackServer)server).EstablishConnectionAsync(new SettingsEntry { SettingId = SettingId.EnableConnect, Value = 1 });
+
+ (int streamId, HttpRequestData request) = await connection.ReadAndParseRequestHeaderAsync(readBody: false);
+
+ Assert.Equal("foo", request.GetSingleHeaderValue("User-Agent"));
+ Assert.Equal("bar", request.GetSingleHeaderValue("Content-Language"));
+ Assert.Equal(0, request.GetHeaderValueCount("Content-Length"));
- // send response headers
await connection.SendResponseHeadersAsync(streamId, endStream: false).ConfigureAwait(false);
- // send reply
- await connection.SendResponseDataAsync(streamId, serverMessage, endStream: false);
+ for (int i = 0; i < MessageCount; i++)
+ {
+ DataFrame dataFrame = await connection.ReadDataFrameAsync();
+ Assert.Equal(clientMessage, dataFrame.Data.ToArray());
- // send server EOS
- await connection.SendResponseDataAsync(streamId, Array.Empty(), endStream: true);
- });
+ await connection.SendResponseDataAsync(streamId, serverMessage, endStream: i == MessageCount - 1);
+ }
- StreamingHttpContent requestContent = new StreamingHttpContent();
+ await clientCompleted.Task.WaitAsync(TestHelper.PassingTestTimeout);
+ }, options: new GenericLoopbackOptions { UseSsl = useSsl });
+ }
- using var handler = CreateSocketsHttpHandler(allowAllCertificates: true);
- using HttpClient client = new HttpClient(handler);
+ public static IEnumerable