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(); + } }