diff --git a/README.md b/README.md index 75c42b6..00d02c0 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ finally } ``` +`ReadOnlySequenceReadBuffer` can also be constructed over a single `ReadOnlySpan`, so a reader type that stores it as its only buffer field accepts both kinds of input. The span is served as one window, never stitched or copied. When the input is always contiguous, `ReadOnlySpanReadBuffer` is still the smaller and simpler choice. + `CopyTo(Span)` copies the next bytes without consuming them, and a multi-segment implementation copies straight out of its segments without building a contiguous window first. It is the right call for fixed-size payloads such as a `byte[]` body or a blittable struct, where a contiguous window would only be an intermediate copy. Building a Serializer diff --git a/src/SerializerFoundation/ReadBuffers/ReadOnlySequenceReadBuffer.cs b/src/SerializerFoundation/ReadBuffers/ReadOnlySequenceReadBuffer.cs index b4a7714..a6f7829 100644 --- a/src/SerializerFoundation/ReadBuffers/ReadOnlySequenceReadBuffer.cs +++ b/src/SerializerFoundation/ReadBuffers/ReadOnlySequenceReadBuffer.cs @@ -15,6 +15,7 @@ namespace SerializerFoundation; /// An over a , such as one produced by PipeReader. /// Reads operate on each contiguous segment, /// and bytes that straddle a segment seam are copied into a temporary buffer to form a contiguous window. +/// It can also be constructed over a single , so one reader type covers both inputs. /// Dispose returns any rented buffer and must be called. /// public ref struct ReadOnlySequenceReadBuffer : IReadBuffer @@ -59,6 +60,19 @@ public ReadOnlySequenceReadBuffer(scoped in ReadOnlySequence sequence, Spa this.length = sequence.Length; } + /// + /// Creates a read buffer over a single contiguous , + /// so a reader that holds this type can take span input without a second buffer type. + /// The whole span is one window: nothing is stitched or rented. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySequenceReadBuffer(ReadOnlySpan span) + { + // `sequence` stays default (empty), which tells the slow paths the window already holds every remaining byte + this.currentSpan = span; + this.length = span.Length; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public ReadOnlySpan GetUnreadSpan() { @@ -111,6 +125,17 @@ ref Unsafe.Add(ref MemoryMarshal.GetReference(currentSpan), (nint)currentConsume [MethodImpl(MethodImplOptions.NoInlining)] ReadOnlySpan CommitAndReposition() { + if (sequence.IsEmpty) + { + // The window holds every remaining byte: span-constructed, or a sequence read to the end + // (whose window is then empty with nothing pending, since Advance cannot pass the data). + // Commit into the window itself; there is no next segment and nothing to stitch. + committedConsumed += currentConsumed; + currentSpan = currentSpan.Slice((int)currentConsumed); + currentConsumed = 0; + return currentSpan; + } + if (currentConsumed != 0) { committedConsumed += currentConsumed; @@ -222,6 +247,7 @@ void CopyToSlow(Span destination) // stitched windows included (TryGetSpanSlow commits before it stitches and never // moves the sequence afterwards), so currentConsumed indexes into it directly. // One Slice walk over the segment graph, amortized over the whole payload. + // A span-constructed buffer never gets here: its window is all remaining data, so the fast path covers every guarded length. sequence.Slice(currentConsumed, destination.Length).CopyTo(destination); } diff --git a/tests/SerializerFoundation.Tests/ReadBufferAdvanceValidationTest.cs b/tests/SerializerFoundation.Tests/ReadBufferAdvanceValidationTest.cs index 086e242..ef45a9b 100644 --- a/tests/SerializerFoundation.Tests/ReadBufferAdvanceValidationTest.cs +++ b/tests/SerializerFoundation.Tests/ReadBufferAdvanceValidationTest.cs @@ -73,6 +73,18 @@ public void Advance_RejectsNegativeAndPastTheData_OnEveryReadBuffer(int bytesCon } Assert.True(sequenceThrown, "ReadOnlySequenceReadBuffer"); + var spanBackedThrown = false; + { + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try + { + buffer.Advance(bytesConsumed); + } + catch (InvalidOperationException) { spanBackedThrown = true; } + finally { buffer.Dispose(); } + } + Assert.True(spanBackedThrown, "ReadOnlySequenceReadBuffer over a span"); + var compatibleThrown = false; { var buffer = new CompatibleReadOnlySequenceReadBuffer(new ReadOnlySequence(data)); @@ -153,6 +165,18 @@ public void TryGetSpan_RejectsNegativeSizeHint_OnBothSequenceBuffers_FastPathInc } Assert.True(sequenceThrown, "ReadOnlySequenceReadBuffer"); + var spanBackedThrown = false; + { + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try + { + buffer.TryGetSpan(sizeHint, out _); + } + catch (ArgumentOutOfRangeException) { spanBackedThrown = true; } + finally { buffer.Dispose(); } + } + Assert.True(spanBackedThrown, "ReadOnlySequenceReadBuffer over a span"); + var compatibleThrown = false; { var buffer = new CompatibleReadOnlySequenceReadBuffer(new ReadOnlySequence(data)); diff --git a/tests/SerializerFoundation.Tests/ReadBufferCopyToTest.cs b/tests/SerializerFoundation.Tests/ReadBufferCopyToTest.cs index ddf83dc..a84590d 100644 --- a/tests/SerializerFoundation.Tests/ReadBufferCopyToTest.cs +++ b/tests/SerializerFoundation.Tests/ReadBufferCopyToTest.cs @@ -85,6 +85,12 @@ public void CopyTo_ContiguousAndStraddling_OnEveryReadBuffer(int skip, int count finally { buffer.Dispose(); } } + { + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try { AssertCopy(ref buffer, data, skip, count); } + finally { buffer.Dispose(); } + } + { var buffer = new CompatibleReadOnlySequenceReadBuffer(Split(data, 5)); try { AssertCopy(ref buffer, data, skip, count); } @@ -126,6 +132,13 @@ public void CopyTo_PastTheData_Throws_OnEveryReadBuffer() finally { buffer.Dispose(); } }); + Assert.Throws(() => + { + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try { buffer.CopyTo(tooLong); } + finally { buffer.Dispose(); } + }); + Assert.Throws(() => { var buffer = new CompatibleReadOnlySequenceReadBuffer(Split(data, 5)); diff --git a/tests/SerializerFoundation.Tests/ReadOnlySequenceReadBufferSpanTest.cs b/tests/SerializerFoundation.Tests/ReadOnlySequenceReadBufferSpanTest.cs new file mode 100644 index 0000000..2203719 --- /dev/null +++ b/tests/SerializerFoundation.Tests/ReadOnlySequenceReadBufferSpanTest.cs @@ -0,0 +1,147 @@ +using System.Buffers; +using SerializerFoundation.CodeAnalysis; + +namespace SerializerFoundation.Tests; + +// ReadOnlySequenceReadBuffer over a single span, so a reader holding just this type takes both inputs. +// The buffer keeps an empty sequence and the span as its one window; the slow paths read the empty +// sequence as "the window is all remaining data" and commit into the window instead of the sequence. +// A failed demand must not lose that window: there is no sequence to rebuild it from. +public class ReadOnlySequenceReadBufferSpanTest +{ + static byte[] Ramp(int length) + { + var data = new byte[length]; + for (var i = 0; i < length; i++) data[i] = (byte)i; + return data; + } + + [Fact] + public void WholeSpanIsOneWindow() + { + var data = Ramp(16); + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try + { + Assert.Equal(data, buffer.GetUnreadSpan().ToArray()); + Assert.True(buffer.TryGetSpan(16, out var span)); + Assert.Equal(16, span.Length); + Assert.Equal(16, buffer.BytesRemaining); + } + finally { buffer.Dispose(); } + } + + [Fact] + public void SequentialReads_FollowTheCursor() + { + var data = Ramp(16); + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try + { + for (var offset = 0; offset < 16; offset += 4) + { + Assert.True(buffer.TryGetSpan(4, out var span)); + Assert.Equal(data.AsSpan(offset, 4).ToArray(), span.Slice(0, 4).ToArray()); + buffer.Advance(4); + } + Assert.Equal(16, buffer.BytesConsumed); + Assert.Equal(0, buffer.BytesRemaining); + } + finally { buffer.Dispose(); } + } + + [Fact] + public void ShortDemand_ReturnsFalse_AndKeepsTheRemainingBytes() + { + var data = Ramp(16); + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try + { + buffer.Advance(10); + Assert.False(buffer.TryGetSpan(7, out _)); // only 6 remain; this commits the 10 consumed bytes + + Assert.Equal(10, buffer.BytesConsumed); + Assert.Equal(6, buffer.BytesRemaining); + Assert.Equal(data.AsSpan(10).ToArray(), buffer.GetUnreadSpan().ToArray()); + + Assert.True(buffer.TryGetSpan(6, out var span)); + Assert.Equal(data.AsSpan(10).ToArray(), span.ToArray()); + buffer.Advance(2); + var destination = new byte[4]; + buffer.CopyTo(destination); + Assert.Equal(data.AsSpan(12).ToArray(), destination); + } + finally { buffer.Dispose(); } + } + + [Fact] + public void Exhausted_ReportsEmpty() + { + var data = Ramp(16); + var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan()); + try + { + buffer.Advance(16); + Assert.True(buffer.GetUnreadSpan().IsEmpty); + Assert.True(buffer.TryGetSpan(0, out var empty)); + Assert.True(empty.IsEmpty); + Assert.False(buffer.TryGetSpan(1, out _)); + Assert.Equal(16, buffer.BytesConsumed); + } + finally { buffer.Dispose(); } + } + + [Fact] + public void EmptySpan_IsExhaustedFromTheStart() + { + var buffer = new ReadOnlySequenceReadBuffer(ReadOnlySpan.Empty); + try + { + Assert.True(buffer.GetUnreadSpan().IsEmpty); + Assert.False(buffer.TryGetSpan(1, out _)); + Assert.Equal(0, buffer.BytesRemaining); + } + finally { buffer.Dispose(); } + } + + [NonCopyable] + ref struct SingleFieldReader + { + ReadOnlySequenceReadBuffer buffer; + + public SingleFieldReader(ReadOnlySequence sequence) => buffer = new ReadOnlySequenceReadBuffer(in sequence); + + public SingleFieldReader(ReadOnlySpan span) => buffer = new ReadOnlySequenceReadBuffer(span); + + public byte ReadByte() + { + Assert.True(buffer.TryGetSpan(1, out var span)); + var value = span[0]; + buffer.Advance(1); + return value; + } + + public void Dispose() => buffer.Dispose(); + } + + [Fact] + public void OneReaderType_TakesStackallocAndSequenceInput() + { + Span stack = stackalloc byte[] { 1, 2, 3 }; + var fromSpan = new SingleFieldReader(stack); + var fromSequence = new SingleFieldReader(new ReadOnlySequence(new byte[] { 1, 2, 3 })); + try + { + for (byte expected = 1; expected <= 3; expected++) + { + Assert.Equal(expected, fromSpan.ReadByte()); + Assert.Equal(expected, fromSequence.ReadByte()); + } + } + finally + { + fromSpan.Dispose(); + fromSequence.Dispose(); + } + } +}