private (bool wait, int bytesRead) TryReadFromBuffer(Span<byte> buffer)
{
- Debug.Assert(buffer.Length > 0);
-
Debug.Assert(!Monitor.IsEntered(SyncObject));
lock (SyncObject)
{
public int Read(Span<byte> buffer)
{
- if (buffer.Length == 0)
- {
- return 0;
- }
-
(bool wait, int bytesRead) = TryReadFromBuffer(buffer);
if (wait)
{
{
cancellationToken.ThrowIfCancellationRequested();
- if (buffer.Length == 0)
- {
- return 0;
- }
-
(bool wait, int bytesRead) = TryReadFromBuffer(buffer.Span);
if (wait)
{
from mode in Enum.GetValues<SeekMode>()
select new object[] { mode, value };
- protected async Task<int> ReadAsync(ReadWriteMode mode, Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
+ public static async Task<int> ReadAsync(ReadWriteMode mode, Stream stream, byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
{
if (mode == ReadWriteMode.SyncByte)
{
{
protected override int BufferedSize => StreamBuffer.DefaultMaxBufferSize;
protected override bool FlushRequiredToWriteData => false;
+ protected override bool BlocksOnZeroByteReads => true;
protected override Task<StreamPair> CreateConnectedStreamsAsync() =>
Task.FromResult<StreamPair>(ConnectedStreams.CreateUnidirectional());
{
protected override int BufferedSize => StreamBuffer.DefaultMaxBufferSize;
protected override bool FlushRequiredToWriteData => false;
+ protected override bool BlocksOnZeroByteReads => true;
protected override Task<StreamPair> CreateConnectedStreamsAsync() =>
Task.FromResult<StreamPair>(ConnectedStreams.CreateBidirectional());
public override int Read(Span<byte> buffer)
{
- if (_connection == null || buffer.Length == 0)
+ if (_connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data.
+ // Response body fully consumed
return 0;
}
- // Try to consume from data we already have in the buffer.
- int bytesRead = ReadChunksFromConnectionBuffer(buffer, cancellationRegistration: default);
- if (bytesRead > 0)
+ if (buffer.Length == 0)
+ {
+ if (PeekChunkFromConnectionBuffer())
+ {
+ return 0;
+ }
+ }
+ else
{
- return bytesRead;
+ // Try to consume from data we already have in the buffer.
+ int bytesRead = ReadChunksFromConnectionBuffer(buffer, cancellationRegistration: default);
+ if (bytesRead > 0)
+ {
+ return bytesRead;
+ }
}
// Nothing available to consume. Fall back to I/O.
// as the connection buffer. That avoids an unnecessary copy while still reading
// the maximum amount we'd otherwise read at a time.
Debug.Assert(_connection.RemainingBuffer.Length == 0);
- bytesRead = _connection.Read(buffer.Slice(0, (int)Math.Min((ulong)buffer.Length, _chunkBytesRemaining)));
+ Debug.Assert(buffer.Length != 0);
+ int bytesRead = _connection.Read(buffer.Slice(0, (int)Math.Min((ulong)buffer.Length, _chunkBytesRemaining)));
if (bytesRead == 0)
{
throw new IOException(SR.Format(SR.net_http_invalid_response_premature_eof_bytecount, _chunkBytesRemaining));
return bytesRead;
}
+ if (buffer.Length == 0)
+ {
+ // User requested a zero-byte read, and we have no data available in the buffer for processing.
+ // This zero-byte read indicates their desire to trade off the extra cost of a zero-byte read
+ // for reduced memory consumption when data is not immediately available.
+ // So, we will issue our own zero-byte read against the underlying stream to allow it to make use of
+ // optimizations, such as deferring buffer allocation until data is actually available.
+ _connection.Read(buffer);
+ }
+
// We're only here if we need more data to make forward progress.
_connection.Fill();
// Now that we have more, see if we can get any response data, and if
// we can we're done.
- int bytesCopied = ReadChunksFromConnectionBuffer(buffer, cancellationRegistration: default);
- if (bytesCopied > 0)
+ if (buffer.Length == 0)
{
- return bytesCopied;
+ if (PeekChunkFromConnectionBuffer())
+ {
+ return 0;
+ }
+ }
+ else
+ {
+ int bytesCopied = ReadChunksFromConnectionBuffer(buffer, cancellationRegistration: default);
+ if (bytesCopied > 0)
+ {
+ return bytesCopied;
+ }
}
}
}
return ValueTask.FromCanceled<int>(cancellationToken);
}
- if (_connection == null || buffer.Length == 0)
+ if (_connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data.
+ // Response body fully consumed
return new ValueTask<int>(0);
}
- // Try to consume from data we already have in the buffer.
- int bytesRead = ReadChunksFromConnectionBuffer(buffer.Span, cancellationRegistration: default);
- if (bytesRead > 0)
+ if (buffer.Length == 0)
{
- return new ValueTask<int>(bytesRead);
+ if (PeekChunkFromConnectionBuffer())
+ {
+ return new ValueTask<int>(0);
+ }
+ }
+ else
+ {
+ // Try to consume from data we already have in the buffer.
+ int bytesRead = ReadChunksFromConnectionBuffer(buffer.Span, cancellationRegistration: default);
+ if (bytesRead > 0)
+ {
+ return new ValueTask<int>(bytesRead);
+ }
}
// We may have just consumed the remainder of the response (with no actual data
// Should only be called if ReadChunksFromConnectionBuffer returned 0.
Debug.Assert(_connection != null);
- Debug.Assert(buffer.Length > 0);
CancellationTokenRegistration ctr = _connection.RegisterCancellation(cancellationToken);
try
// as the connection buffer. That avoids an unnecessary copy while still reading
// the maximum amount we'd otherwise read at a time.
Debug.Assert(_connection.RemainingBuffer.Length == 0);
+ Debug.Assert(buffer.Length != 0);
int bytesRead = await _connection.ReadAsync(buffer.Slice(0, (int)Math.Min((ulong)buffer.Length, _chunkBytesRemaining))).ConfigureAwait(false);
if (bytesRead == 0)
{
return bytesRead;
}
+ if (buffer.Length == 0)
+ {
+ // User requested a zero-byte read, and we have no data available in the buffer for processing.
+ // This zero-byte read indicates their desire to trade off the extra cost of a zero-byte read
+ // for reduced memory consumption when data is not immediately available.
+ // So, we will issue our own zero-byte read against the underlying stream to allow it to make use of
+ // optimizations, such as deferring buffer allocation until data is actually available.
+ await _connection.ReadAsync(buffer).ConfigureAwait(false);
+ }
+
// We're only here if we need more data to make forward progress.
await _connection.FillAsync(async: true).ConfigureAwait(false);
// Now that we have more, see if we can get any response data, and if
// we can we're done.
- int bytesCopied = ReadChunksFromConnectionBuffer(buffer.Span, ctr);
- if (bytesCopied > 0)
+ if (buffer.Length == 0)
{
- return bytesCopied;
+ if (PeekChunkFromConnectionBuffer())
+ {
+ return 0;
+ }
+ }
+ else
+ {
+ int bytesCopied = ReadChunksFromConnectionBuffer(buffer.Span, ctr);
+ if (bytesCopied > 0)
+ {
+ return bytesCopied;
+ }
}
}
}
{
while (true)
{
- ReadOnlyMemory<byte> bytesRead = ReadChunkFromConnectionBuffer(int.MaxValue, ctr);
- if (bytesRead.Length == 0)
+ if (ReadChunkFromConnectionBuffer(int.MaxValue, ctr) is not ReadOnlyMemory<byte> bytesRead || bytesRead.Length == 0)
{
break;
}
}
}
+ private bool PeekChunkFromConnectionBuffer()
+ {
+ return ReadChunkFromConnectionBuffer(maxBytesToRead: 0, cancellationRegistration: default).HasValue;
+ }
+
private int ReadChunksFromConnectionBuffer(Span<byte> buffer, CancellationTokenRegistration cancellationRegistration)
{
+ Debug.Assert(buffer.Length > 0);
int totalBytesRead = 0;
while (buffer.Length > 0)
{
- ReadOnlyMemory<byte> bytesRead = ReadChunkFromConnectionBuffer(buffer.Length, cancellationRegistration);
- Debug.Assert(bytesRead.Length <= buffer.Length);
- if (bytesRead.Length == 0)
+ if (ReadChunkFromConnectionBuffer(buffer.Length, cancellationRegistration) is not ReadOnlyMemory<byte> bytesRead || bytesRead.Length == 0)
{
break;
}
+ Debug.Assert(bytesRead.Length <= buffer.Length);
totalBytesRead += bytesRead.Length;
bytesRead.Span.CopyTo(buffer);
buffer = buffer.Slice(bytesRead.Length);
return totalBytesRead;
}
- private ReadOnlyMemory<byte> ReadChunkFromConnectionBuffer(int maxBytesToRead, CancellationTokenRegistration cancellationRegistration)
+ private ReadOnlyMemory<byte>? ReadChunkFromConnectionBuffer(int maxBytesToRead, CancellationTokenRegistration cancellationRegistration)
{
- Debug.Assert(maxBytesToRead > 0 && _connection != null);
+ Debug.Assert(_connection != null);
try
{
}
int bytesToConsume = Math.Min(maxBytesToRead, (int)Math.Min((ulong)connectionBuffer.Length, _chunkBytesRemaining));
- Debug.Assert(bytesToConsume > 0);
+ Debug.Assert(bytesToConsume > 0 || maxBytesToRead == 0);
_connection.ConsumeFromRemainingBuffer(bytesToConsume);
_chunkBytesRemaining -= (ulong)bytesToConsume;
drainedBytes += _connection.RemainingBuffer.Length;
while (true)
{
- ReadOnlyMemory<byte> bytesRead = ReadChunkFromConnectionBuffer(int.MaxValue, ctr);
- if (bytesRead.Length == 0)
+ if (ReadChunkFromConnectionBuffer(int.MaxValue, ctr) is not ReadOnlyMemory<byte> bytesRead || bytesRead.Length == 0)
{
break;
}
public override int Read(Span<byte> buffer)
{
HttpConnection? connection = _connection;
- if (connection == null || buffer.Length == 0)
+ if (connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data
+ // Response body fully consumed
return 0;
}
int bytesRead = connection.Read(buffer);
- if (bytesRead == 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
// We cannot reuse this connection, so close it.
_connection = null;
CancellationHelper.ThrowIfCancellationRequested(cancellationToken);
HttpConnection? connection = _connection;
- if (connection == null || buffer.Length == 0)
+ if (connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data
+ // Response body fully consumed
return 0;
}
}
}
- if (bytesRead == 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
// If cancellation is requested and tears down the connection, it could cause the read
// to return 0, which would otherwise signal the end of the data, but that would lead
public override int Read(Span<byte> buffer)
{
- if (_connection == null || buffer.Length == 0)
+ if (_connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data.
+ // Response body fully consumed
return 0;
}
}
int bytesRead = _connection.Read(buffer);
- if (bytesRead <= 0)
+ if (bytesRead <= 0 && buffer.Length != 0)
{
// Unexpected end of response stream.
throw new IOException(SR.Format(SR.net_http_invalid_response_premature_eof_bytecount, _contentBytesRemaining));
{
CancellationHelper.ThrowIfCancellationRequested(cancellationToken);
- if (_connection == null || buffer.Length == 0)
+ if (_connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data
+ // Response body fully consumed
return 0;
}
}
}
- if (bytesRead <= 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
// A cancellation request may have caused the EOF.
CancellationHelper.ThrowIfCancellationRequested(cancellationToken);
private (bool wait, int bytesRead) TryReadFromBuffer(Span<byte> buffer, bool partOfSyncRead = false)
{
- Debug.Assert(buffer.Length > 0);
-
Debug.Assert(!Monitor.IsEntered(SyncObject));
lock (SyncObject)
{
public int ReadData(Span<byte> buffer, HttpResponseMessage responseMessage)
{
- if (buffer.Length == 0)
- {
- return 0;
- }
-
(bool wait, int bytesRead) = TryReadFromBuffer(buffer, partOfSyncRead: true);
if (wait)
{
{
_windowManager.AdjustWindow(bytesRead, this);
}
- else
+ else if (buffer.Length != 0)
{
// We've hit EOF. Pull in from the Http2Stream any trailers that were temporarily stored there.
MoveTrailersToResponseMessage(responseMessage);
public async ValueTask<int> ReadDataAsync(Memory<byte> buffer, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
{
- if (buffer.Length == 0)
- {
- return 0;
- }
-
(bool wait, int bytesRead) = TryReadFromBuffer(buffer.Span);
if (wait)
{
{
_windowManager.AdjustWindow(bytesRead, this);
}
- else
+ else if (buffer.Length != 0)
{
// We've hit EOF. Pull in from the Http2Stream any trailers that were temporarily stored there.
MoveTrailersToResponseMessage(responseMessage);
{
int totalBytesRead = 0;
- while (buffer.Length != 0)
+ do
{
// Sync over async here -- QUIC implementation does it per-I/O already; this is at least more coarse-grained.
if (_responseDataPayloadRemaining <= 0 && !ReadNextDataFrameAsync(response, CancellationToken.None).AsTask().GetAwaiter().GetResult())
int copyLen = (int)Math.Min(buffer.Length, _responseDataPayloadRemaining);
int bytesRead = _stream.Read(buffer.Slice(0, copyLen));
- if (bytesRead == 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
throw new HttpRequestException(SR.Format(SR.net_http_invalid_response_premature_eof_bytecount, _responseDataPayloadRemaining));
}
break;
}
}
+ while (buffer.Length != 0);
return totalBytesRead;
}
{
int totalBytesRead = 0;
- while (buffer.Length != 0)
+ do
{
if (_responseDataPayloadRemaining <= 0 && !await ReadNextDataFrameAsync(response, cancellationToken).ConfigureAwait(false))
{
int copyLen = (int)Math.Min(buffer.Length, _responseDataPayloadRemaining);
int bytesRead = await _stream.ReadAsync(buffer.Slice(0, copyLen), cancellationToken).ConfigureAwait(false);
- if (bytesRead == 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
throw new HttpRequestException(SR.Format(SR.net_http_invalid_response_premature_eof_bytecount, _responseDataPayloadRemaining));
}
break;
}
}
+ while (buffer.Length != 0);
return totalBytesRead;
}
private int ReadBuffered(Span<byte> destination)
{
// This is called when reading the response body.
- Debug.Assert(destination.Length != 0);
-
int remaining = _readLength - _readOffset;
if (remaining > 0)
{
// Do a buffered read directly against the underlying stream.
Debug.Assert(_readAheadTask == null, "Read ahead task should have been consumed as part of the headers.");
- int bytesRead = _stream.Read(_readBuffer, 0, _readBuffer.Length);
+ int bytesRead = _stream.Read(_readBuffer, 0, destination.Length == 0 ? 0 : _readBuffer.Length);
if (NetEventSource.Log.IsEnabled()) Trace($"Received {bytesRead} bytes.");
_readLength = bytesRead;
// If the caller provided buffer, and thus the amount of data desired to be read,
// is larger than the internal buffer, there's no point going through the internal
// buffer, so just do an unbuffered read.
- return destination.Length >= _readBuffer.Length ?
+ // Also avoid avoid using the internal buffer if the user requested a zero-byte read to allow
+ // underlying streams to efficiently handle such a read (e.g. SslStream defering buffer allocation).
+ return destination.Length >= _readBuffer.Length || destination.Length == 0 ?
ReadAsync(destination) :
ReadBufferedAsyncCore(destination);
}
public override int Read(Span<byte> buffer)
{
HttpConnection? connection = _connection;
- if (connection == null || buffer.Length == 0)
+ if (connection == null)
{
// Response body fully consumed or the caller didn't ask for any data
return 0;
}
int bytesRead = connection.ReadBuffered(buffer);
- if (bytesRead == 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
// We cannot reuse this connection, so close it.
_connection = null;
CancellationHelper.ThrowIfCancellationRequested(cancellationToken);
HttpConnection? connection = _connection;
- if (connection == null || buffer.Length == 0)
+ if (connection == null)
{
- // Response body fully consumed or the caller didn't ask for any data
+ // Response body fully consumed
return 0;
}
}
}
- if (bytesRead == 0)
+ if (bytesRead == 0 && buffer.Length != 0)
{
// A cancellation request may have caused the EOF.
CancellationHelper.ThrowIfCancellationRequested(cancellationToken);
{
protected override Type UnsupportedConcurrentExceptionType => null;
protected override bool UsableAfterCanceledReads => false;
+ protected override bool BlocksOnZeroByteReads => true;
protected abstract string GetResponseHeaders();
--- /dev/null
+// 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.IO;
+using System.IO.Tests;
+using System.Linq;
+using System.Net.Quic;
+using System.Net.Quic.Implementations;
+using System.Net.Security;
+using System.Net.Test.Common;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace System.Net.Http.Functional.Tests
+{
+ public sealed class Http1CloseResponseStreamZeroByteReadTest : Http1ResponseStreamZeroByteReadTestBase
+ {
+ protected override string GetResponseHeaders() => "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n";
+
+ protected override async Task WriteAsync(Stream stream, byte[] data) => await stream.WriteAsync(data);
+ }
+
+ public sealed class Http1RawResponseStreamZeroByteReadTest : Http1ResponseStreamZeroByteReadTestBase
+ {
+ protected override string GetResponseHeaders() => "HTTP/1.1 101 Switching Protocols\r\n\r\n";
+
+ protected override async Task WriteAsync(Stream stream, byte[] data) => await stream.WriteAsync(data);
+ }
+
+ public sealed class Http1ContentLengthResponseStreamZeroByteReadTest : Http1ResponseStreamZeroByteReadTestBase
+ {
+ protected override string GetResponseHeaders() => "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n";
+
+ protected override async Task WriteAsync(Stream stream, byte[] data) => await stream.WriteAsync(data);
+ }
+
+ public sealed class Http1SingleChunkResponseStreamZeroByteReadTest : Http1ResponseStreamZeroByteReadTestBase
+ {
+ protected override string GetResponseHeaders() => "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n";
+
+ protected override async Task WriteAsync(Stream stream, byte[] data)
+ {
+ await stream.WriteAsync(Encoding.ASCII.GetBytes($"{data.Length:X}\r\n"));
+ await stream.WriteAsync(data);
+ await stream.WriteAsync(Encoding.ASCII.GetBytes("\r\n"));
+ }
+ }
+
+ public sealed class Http1MultiChunkResponseStreamZeroByteReadTest : Http1ResponseStreamZeroByteReadTestBase
+ {
+ protected override string GetResponseHeaders() => "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n";
+
+ protected override async Task WriteAsync(Stream stream, byte[] data)
+ {
+ for (int i = 0; i < data.Length; i++)
+ {
+ await stream.WriteAsync(Encoding.ASCII.GetBytes($"1\r\n"));
+ await stream.WriteAsync(data.AsMemory(i, 1));
+ await stream.WriteAsync(Encoding.ASCII.GetBytes("\r\n"));
+ }
+ }
+ }
+
+ [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))]
+ public abstract class Http1ResponseStreamZeroByteReadTestBase
+ {
+ protected abstract string GetResponseHeaders();
+
+ protected abstract Task WriteAsync(Stream stream, byte[] data);
+
+ public static IEnumerable<object[]> ZeroByteRead_IssuesZeroByteReadOnUnderlyingStream_MemberData() =>
+ from readMode in Enum.GetValues<StreamConformanceTests.ReadWriteMode>()
+ .Where(mode => mode != StreamConformanceTests.ReadWriteMode.SyncByte) // Can't test zero-byte reads with ReadByte
+ from useSsl in new[] { true, false }
+ select new object[] { readMode, useSsl };
+
+ [Theory]
+ [MemberData(nameof(ZeroByteRead_IssuesZeroByteReadOnUnderlyingStream_MemberData))]
+ public async Task ZeroByteRead_IssuesZeroByteReadOnUnderlyingStream(StreamConformanceTests.ReadWriteMode readMode, bool useSsl)
+ {
+ (Stream httpConnection, Stream server) = ConnectedStreams.CreateBidirectional(4096, int.MaxValue);
+ try
+ {
+ var sawZeroByteRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ httpConnection = new ReadInterceptStream(httpConnection, read =>
+ {
+ if (read == 0)
+ {
+ sawZeroByteRead.TrySetResult();
+ }
+ });
+
+ using var handler = new SocketsHttpHandler
+ {
+ ConnectCallback = delegate { return ValueTask.FromResult(httpConnection); }
+ };
+ handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; };
+
+ using var client = new HttpClient(handler);
+
+ Task<HttpResponseMessage> clientTask = client.GetAsync($"http{(useSsl ? "s" : "")}://doesntmatter", HttpCompletionOption.ResponseHeadersRead);
+
+ if (useSsl)
+ {
+ var sslStream = new SslStream(server, false, delegate { return true; });
+ server = sslStream;
+
+ using (X509Certificate2 cert = Test.Common.Configuration.Certificates.GetServerCertificate())
+ {
+ await ((SslStream)server).AuthenticateAsServerAsync(
+ cert,
+ clientCertificateRequired: true,
+ enabledSslProtocols: SslProtocols.Tls12,
+ checkCertificateRevocation: false).WaitAsync(TimeSpan.FromSeconds(10));
+ }
+ }
+
+ await ResponseConnectedStreamConformanceTests.ReadHeadersAsync(server).WaitAsync(TimeSpan.FromSeconds(10));
+ await server.WriteAsync(Encoding.ASCII.GetBytes(GetResponseHeaders()));
+
+ using HttpResponseMessage response = await clientTask.WaitAsync(TimeSpan.FromSeconds(10));
+ using Stream clientStream = response.Content.ReadAsStream();
+ Assert.False(sawZeroByteRead.Task.IsCompleted);
+
+ Task<int> zeroByteReadTask = Task.Run(() => StreamConformanceTests.ReadAsync(readMode, clientStream, Array.Empty<byte>(), 0, 0, CancellationToken.None) );
+ Assert.False(zeroByteReadTask.IsCompleted);
+
+ // The zero-byte read should block until data is actually available
+ await sawZeroByteRead.Task.WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.False(zeroByteReadTask.IsCompleted);
+
+ byte[] data = Encoding.UTF8.GetBytes("Hello");
+ await WriteAsync(server, data);
+ await server.FlushAsync();
+
+ Assert.Equal(0, await zeroByteReadTask.WaitAsync(TimeSpan.FromSeconds(10)));
+
+ // Now that data is available, a zero-byte read should complete synchronously
+ zeroByteReadTask = StreamConformanceTests.ReadAsync(readMode, clientStream, Array.Empty<byte>(), 0, 0, CancellationToken.None);
+ Assert.True(zeroByteReadTask.IsCompleted);
+ Assert.Equal(0, await zeroByteReadTask);
+
+ var readBuffer = new byte[10];
+ int read = 0;
+ while (read < data.Length)
+ {
+ read += await StreamConformanceTests.ReadAsync(readMode, clientStream, readBuffer, read, readBuffer.Length - read, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
+ }
+
+ Assert.Equal(data.Length, read);
+ Assert.Equal(data, readBuffer.AsSpan(0, read).ToArray());
+ }
+ finally
+ {
+ httpConnection.Dispose();
+ server.Dispose();
+ }
+ }
+
+ private sealed class ReadInterceptStream : DelegatingStream
+ {
+ private readonly Action<int> _readCallback;
+
+ public ReadInterceptStream(Stream innerStream, Action<int> readCallback)
+ : base(innerStream)
+ {
+ _readCallback = readCallback;
+ }
+
+ public override int Read(Span<byte> buffer)
+ {
+ _readCallback(buffer.Length);
+ return base.Read(buffer);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ _readCallback(count);
+ return base.Read(buffer, offset, count);
+ }
+
+ public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
+ {
+ _readCallback(buffer.Length);
+ return base.ReadAsync(buffer, cancellationToken);
+ }
+ }
+ }
+
+ public sealed class Http1ResponseStreamZeroByteReadTest : ResponseStreamZeroByteReadTestBase
+ {
+ public Http1ResponseStreamZeroByteReadTest(ITestOutputHelper output) : base(output) { }
+
+ protected override Version UseVersion => HttpVersion.Version11;
+ }
+
+ [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.SupportsAlpn))]
+ public sealed class Http2ResponseStreamZeroByteReadTest : ResponseStreamZeroByteReadTestBase
+ {
+ public Http2ResponseStreamZeroByteReadTest(ITestOutputHelper output) : base(output) { }
+
+ protected override Version UseVersion => HttpVersion.Version20;
+ }
+
+ [ConditionalClass(typeof(HttpClientHandlerTestBase), nameof(IsMsQuicSupported))]
+ public sealed class Http3ResponseStreamZeroByteReadTest_MsQuic : ResponseStreamZeroByteReadTestBase
+ {
+ public Http3ResponseStreamZeroByteReadTest_MsQuic(ITestOutputHelper output) : base(output) { }
+
+ protected override Version UseVersion => HttpVersion.Version30;
+
+ protected override QuicImplementationProvider UseQuicImplementationProvider => QuicImplementationProviders.MsQuic;
+ }
+
+ [ConditionalClass(typeof(HttpClientHandlerTestBase), nameof(IsMockQuicSupported))]
+ public sealed class Http3ResponseStreamZeroByteReadTest_Mock : ResponseStreamZeroByteReadTestBase
+ {
+ public Http3ResponseStreamZeroByteReadTest_Mock(ITestOutputHelper output) : base(output) { }
+
+ protected override Version UseVersion => HttpVersion.Version30;
+
+ protected override QuicImplementationProvider UseQuicImplementationProvider => QuicImplementationProviders.Mock;
+ }
+
+ [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))]
+ public abstract class ResponseStreamZeroByteReadTestBase : HttpClientHandlerTestBase
+ {
+ public ResponseStreamZeroByteReadTestBase(ITestOutputHelper output) : base(output) { }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ZeroByteRead_BlocksUntilDataIsAvailable(bool async)
+ {
+ var zeroByteReadIssued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
+ {
+ HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true);
+
+ using HttpClient client = CreateHttpClient();
+ using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
+ using Stream responseStream = await response.Content.ReadAsStreamAsync();
+
+ var responseBuffer = new byte[1];
+ Assert.Equal(1, await ReadAsync(async, responseStream, responseBuffer));
+ Assert.Equal(42, responseBuffer[0]);
+
+ Task<int> zeroByteReadTask = ReadAsync(async, responseStream, Array.Empty<byte>());
+ Assert.False(zeroByteReadTask.IsCompleted);
+
+ zeroByteReadIssued.SetResult();
+ Assert.Equal(0, await zeroByteReadTask);
+ Assert.Equal(0, await ReadAsync(async, responseStream, Array.Empty<byte>()));
+
+ Assert.Equal(1, await ReadAsync(async, responseStream, responseBuffer));
+ Assert.Equal(1, responseBuffer[0]);
+
+ Assert.Equal(0, await ReadAsync(async, responseStream, Array.Empty<byte>()));
+
+ Assert.Equal(1, await ReadAsync(async, responseStream, responseBuffer));
+ Assert.Equal(2, responseBuffer[0]);
+
+ zeroByteReadTask = ReadAsync(async, responseStream, Array.Empty<byte>());
+ Assert.False(zeroByteReadTask.IsCompleted);
+
+ zeroByteReadIssued.SetResult();
+ Assert.Equal(0, await zeroByteReadTask);
+ Assert.Equal(0, await ReadAsync(async, responseStream, Array.Empty<byte>()));
+
+ Assert.Equal(1, await ReadAsync(async, responseStream, responseBuffer));
+ Assert.Equal(3, responseBuffer[0]);
+
+ Assert.Equal(0, await ReadAsync(async, responseStream, responseBuffer));
+ },
+ async server =>
+ {
+ await server.AcceptConnectionAsync(async connection =>
+ {
+ await connection.ReadRequestDataAsync();
+
+ await connection.SendResponseAsync(headers: new[] { new HttpHeaderData("Content-Length", "4") }, isFinal: false);
+
+ await connection.SendResponseBodyAsync(new byte[] { 42 }, isFinal: false);
+
+ await zeroByteReadIssued.Task;
+ zeroByteReadIssued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await connection.SendResponseBodyAsync(new byte[] { 1, 2 }, isFinal: false);
+
+ await zeroByteReadIssued.Task;
+
+ await connection.SendResponseBodyAsync(new byte[] { 3 }, isFinal: true);
+ });
+ });
+
+ static Task<int> ReadAsync(bool async, Stream stream, byte[] buffer)
+ {
+ if (async)
+ {
+ return stream.ReadAsync(buffer).AsTask();
+ }
+ else
+ {
+ return Task.Run(() => stream.Read(buffer));
+ }
+ }
+ }
+ }
+}
<Compile Include="StreamContentTest.cs" />
<Compile Include="StringContentTest.cs" />
<Compile Include="ResponseStreamConformanceTests.cs" />
+ <Compile Include="ResponseStreamZeroByteReadTests.cs" />
<Compile Include="$(CommonTestPath)System\Net\Http\SyncBlockingContent.cs"
Link="Common\System\Net\Http\SyncBlockingContent.cs" />
<Compile Include="$(CommonTestPath)System\Net\Http\DefaultCredentialsTest.cs"
public sealed class MockQuicStreamConformanceTests : QuicStreamConformanceTests
{
protected override QuicImplementationProvider Provider => QuicImplementationProviders.Mock;
+ protected override bool BlocksOnZeroByteReads => true;
}
[ConditionalClass(typeof(QuicTestBase<MsQuicProviderFactory>), nameof(QuicTestBase<MsQuicProviderFactory>.IsSupported))]