diff --git a/src/Http/WebUtilities/src/BufferedReadStream.cs b/src/Http/WebUtilities/src/BufferedReadStream.cs
index 826979690d83..fc31156f98db 100644
--- a/src/Http/WebUtilities/src/BufferedReadStream.cs
+++ b/src/Http/WebUtilities/src/BufferedReadStream.cs
@@ -186,6 +186,12 @@ public override void Write(byte[] buffer, int offset, int count)
_inner.Write(buffer, offset, count);
}
+ ///
+ public override void Write(ReadOnlySpan buffer)
+ {
+ _inner.Write(buffer);
+ }
+
///
public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken)
{
@@ -203,19 +209,36 @@ public override int Read(byte[] buffer, int offset, int count)
{
ValidateBufferArguments(buffer, offset, count);
- // Drain buffer
if (_bufferCount > 0)
{
- int toCopy = Math.Min(_bufferCount, count);
- Buffer.BlockCopy(_buffer, _bufferOffset, buffer, offset, toCopy);
- _bufferOffset += toCopy;
- _bufferCount -= toCopy;
- return toCopy;
+ return DrainBuffer(buffer.AsSpan(offset, count));
}
+ // Forward to the matching inner overload. Going through Read(Span) would
+ // fall back to Stream's rent-and-copy shim if the inner stream doesn't override it.
return _inner.Read(buffer, offset, count);
}
+ ///
+ public override int Read(Span buffer)
+ {
+ if (_bufferCount > 0)
+ {
+ return DrainBuffer(buffer);
+ }
+
+ return _inner.Read(buffer);
+ }
+
+ private int DrainBuffer(Span buffer)
+ {
+ var toCopy = Math.Min(_bufferCount, buffer.Length);
+ _buffer.AsSpan(_bufferOffset, toCopy).CopyTo(buffer);
+ _bufferOffset += toCopy;
+ _bufferCount -= toCopy;
+ return toCopy;
+ }
+
///
public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
diff --git a/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt b/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..924eca95f9e1 100644
--- a/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt
+++ b/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+override Microsoft.AspNetCore.WebUtilities.BufferedReadStream.Read(System.Span buffer) -> int
+override Microsoft.AspNetCore.WebUtilities.BufferedReadStream.Write(System.ReadOnlySpan buffer) -> void
diff --git a/src/Http/WebUtilities/test/BufferedReadStreamTests.cs b/src/Http/WebUtilities/test/BufferedReadStreamTests.cs
index 1ff23daf02c3..f2cb7ccca912 100644
--- a/src/Http/WebUtilities/test/BufferedReadStreamTests.cs
+++ b/src/Http/WebUtilities/test/BufferedReadStreamTests.cs
@@ -72,8 +72,209 @@ public void ReadLine_LineSpanningMultipleBuffersWithinLimit_Succeeds()
Assert.Equal(content, line);
}
+ [Fact]
+ public void Read_Span_DrainsBufferedDataBeforeReadingInner()
+ {
+ // The buffer is rented from the pool, so its actual size may exceed the requested size.
+ // The content is long enough that some of it always remains in the inner stream.
+ const string content = "0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
+ var stream = MakeStream(content, bufferSize: 5);
+ Assert.True(stream.EnsureBuffered(5));
+ var buffered = stream.BufferedData.Count;
+ Assert.InRange(buffered, 5, content.Length - 3);
+
+ Span buffer = stackalloc byte[3];
+
+ // A span smaller than the buffered data drains it partially.
+ var read = stream.Read(buffer);
+ Assert.Equal(3, read);
+ Assert.Equal(content.Substring(0, 3), Encoding.UTF8.GetString(buffer.Slice(0, read)));
+ Assert.Equal(buffered - 3, stream.BufferedData.Count);
+
+ // Each read returns only buffered data, so the last one returns the remainder
+ // rather than topping up from the inner stream.
+ var consumed = read;
+ while (stream.BufferedData.Count > 0)
+ {
+ var remaining = stream.BufferedData.Count;
+ read = stream.Read(buffer);
+ Assert.Equal(Math.Min(remaining, buffer.Length), read);
+ Assert.Equal(content.Substring(consumed, read), Encoding.UTF8.GetString(buffer.Slice(0, read)));
+ consumed += read;
+ }
+ Assert.Equal(buffered, consumed);
+
+ // With the buffer drained, the read falls through to the inner stream.
+ read = stream.Read(buffer);
+ Assert.Equal(3, read);
+ Assert.Equal(content.Substring(consumed, 3), Encoding.UTF8.GetString(buffer.Slice(0, read)));
+ Assert.Equal(0, stream.BufferedData.Count);
+ }
+
+ [Fact]
+ public void Read_Array_DrainsBufferedDataBeforeReadingInner()
+ {
+ const string content = "0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
+ var stream = MakeStream(content, bufferSize: 5);
+ Assert.True(stream.EnsureBuffered(5));
+ var buffered = stream.BufferedData.Count;
+ Assert.InRange(buffered, 5, content.Length - 3);
+
+ var buffer = new byte[3];
+
+ var read = stream.Read(buffer, 0, buffer.Length);
+ Assert.Equal(3, read);
+ Assert.Equal(content.Substring(0, 3), Encoding.UTF8.GetString(buffer, 0, read));
+ Assert.Equal(buffered - 3, stream.BufferedData.Count);
+
+ var consumed = read;
+ while (stream.BufferedData.Count > 0)
+ {
+ var remaining = stream.BufferedData.Count;
+ read = stream.Read(buffer, 0, buffer.Length);
+ Assert.Equal(Math.Min(remaining, buffer.Length), read);
+ Assert.Equal(content.Substring(consumed, read), Encoding.UTF8.GetString(buffer, 0, read));
+ consumed += read;
+ }
+ Assert.Equal(buffered, consumed);
+
+ read = stream.Read(buffer, 0, buffer.Length);
+ Assert.Equal(3, read);
+ Assert.Equal(content.Substring(consumed, 3), Encoding.UTF8.GetString(buffer, 0, read));
+ Assert.Equal(0, stream.BufferedData.Count);
+ }
+
+ [Fact]
+ public void Write_Span_WritesToInnerStream()
+ {
+ var inner = new MemoryStream();
+ var stream = new BufferedReadStream(inner, bufferSize: 16);
+
+ stream.Write("hello"u8);
+
+ Assert.Equal("hello", Encoding.UTF8.GetString(inner.ToArray()));
+ }
+
+ [Fact]
+ public void Read_Array_WithEmptyBuffer_ForwardsToInnerArrayOverload()
+ {
+ var inner = new OverloadCountingStream("hello"u8.ToArray());
+ var stream = new BufferedReadStream(inner, bufferSize: 16);
+ var buffer = new byte[5];
+
+ var read = stream.Read(buffer, 0, buffer.Length);
+
+ Assert.Equal(5, read);
+ Assert.Equal("hello", Encoding.UTF8.GetString(buffer, 0, read));
+ Assert.Equal(1, inner.ArrayReads);
+ Assert.Equal(0, inner.SpanReads);
+ }
+
+ [Fact]
+ public void Read_Span_WithEmptyBuffer_ForwardsToInnerSpanOverload()
+ {
+ var inner = new OverloadCountingStream("hello"u8.ToArray());
+ var stream = new BufferedReadStream(inner, bufferSize: 16);
+ Span buffer = stackalloc byte[5];
+
+ var read = stream.Read(buffer);
+
+ Assert.Equal(5, read);
+ Assert.Equal("hello", Encoding.UTF8.GetString(buffer.Slice(0, read)));
+ Assert.Equal(1, inner.SpanReads);
+ Assert.Equal(0, inner.ArrayReads);
+ }
+
+ [Fact]
+ public void Write_Array_ForwardsToInnerArrayOverload()
+ {
+ var inner = new OverloadCountingStream([]);
+ var stream = new BufferedReadStream(inner, bufferSize: 16);
+ var data = "hello"u8.ToArray();
+
+ stream.Write(data, 0, data.Length);
+
+ Assert.Equal("hello", Encoding.UTF8.GetString(inner.ToArray()));
+ Assert.Equal(1, inner.ArrayWrites);
+ Assert.Equal(0, inner.SpanWrites);
+ }
+
+ [Fact]
+ public void Write_Span_ForwardsToInnerSpanOverload()
+ {
+ var inner = new OverloadCountingStream([]);
+ var stream = new BufferedReadStream(inner, bufferSize: 16);
+
+ stream.Write("hello"u8);
+
+ Assert.Equal("hello", Encoding.UTF8.GetString(inner.ToArray()));
+ Assert.Equal(1, inner.SpanWrites);
+ Assert.Equal(0, inner.ArrayWrites);
+ }
+
private static BufferedReadStream MakeStream(string text, int bufferSize)
{
return new BufferedReadStream(new MemoryStream(Encoding.UTF8.GetBytes(text)), bufferSize);
}
+
+ // Overrides both the array and span overloads so a test can tell which one BufferedReadStream
+ // forwarded to. It wraps a MemoryStream rather than deriving from one because MemoryStream's
+ // span overloads defer to Stream's base implementation for derived types, which rents an array
+ // and calls the array overload, hiding the difference.
+ private sealed class OverloadCountingStream : Stream
+ {
+ private readonly MemoryStream _inner;
+
+ public OverloadCountingStream(byte[] data)
+ {
+ _inner = new MemoryStream();
+ _inner.Write(data, 0, data.Length);
+ _inner.Position = 0;
+ }
+
+ public int ArrayReads { get; private set; }
+ public int SpanReads { get; private set; }
+ public int ArrayWrites { get; private set; }
+ public int SpanWrites { get; private set; }
+
+ public byte[] ToArray() => _inner.ToArray();
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => _inner.Length;
+ public override long Position
+ {
+ get => _inner.Position;
+ set => throw new NotSupportedException();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ ArrayReads++;
+ return _inner.Read(buffer, offset, count);
+ }
+
+ public override int Read(Span buffer)
+ {
+ SpanReads++;
+ return _inner.Read(buffer);
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ ArrayWrites++;
+ _inner.Write(buffer, offset, count);
+ }
+
+ public override void Write(ReadOnlySpan buffer)
+ {
+ SpanWrites++;
+ _inner.Write(buffer);
+ }
+
+ public override void Flush() => _inner.Flush();
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ }
}