Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ finally
}
```

`ReadOnlySequenceReadBuffer` can also be constructed over a single `ReadOnlySpan<byte>`, 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<byte>)` 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
Expand Down
26 changes: 26 additions & 0 deletions src/SerializerFoundation/ReadBuffers/ReadOnlySequenceReadBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace SerializerFoundation;
/// An <see cref="IReadBuffer"/> over a <see cref="ReadOnlySequence{T}"/>, 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 <see cref="ReadOnlySpan{T}"/>, so one reader type covers both inputs.
/// Dispose returns any rented buffer and must be called.
/// </summary>
public ref struct ReadOnlySequenceReadBuffer : IReadBuffer
Expand Down Expand Up @@ -59,6 +60,19 @@ public ReadOnlySequenceReadBuffer(scoped in ReadOnlySequence<byte> sequence, Spa
this.length = sequence.Length;
}

/// <summary>
/// Creates a read buffer over a single contiguous <paramref name="span"/>,
/// 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.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ReadOnlySequenceReadBuffer(ReadOnlySpan<byte> 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<byte> GetUnreadSpan()
{
Expand Down Expand Up @@ -111,6 +125,17 @@ ref Unsafe.Add(ref MemoryMarshal.GetReference(currentSpan), (nint)currentConsume
[MethodImpl(MethodImplOptions.NoInlining)]
ReadOnlySpan<byte> 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;
Expand Down Expand Up @@ -222,6 +247,7 @@ void CopyToSlow(Span<byte> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte>(data));
Expand Down Expand Up @@ -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<byte>(data));
Expand Down
13 changes: 13 additions & 0 deletions tests/SerializerFoundation.Tests/ReadBufferCopyToTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -126,6 +132,13 @@ public void CopyTo_PastTheData_Throws_OnEveryReadBuffer()
finally { buffer.Dispose(); }
});

Assert.Throws<InvalidOperationException>(() =>
{
var buffer = new ReadOnlySequenceReadBuffer(data.AsSpan());
try { buffer.CopyTo(tooLong); }
finally { buffer.Dispose(); }
});

Assert.Throws<InvalidOperationException>(() =>
{
var buffer = new CompatibleReadOnlySequenceReadBuffer(Split(data, 5));
Expand Down
147 changes: 147 additions & 0 deletions tests/SerializerFoundation.Tests/ReadOnlySequenceReadBufferSpanTest.cs
Original file line number Diff line number Diff line change
@@ -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<byte>.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<byte> sequence) => buffer = new ReadOnlySequenceReadBuffer(in sequence);

public SingleFieldReader(ReadOnlySpan<byte> 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<byte> stack = stackalloc byte[] { 1, 2, 3 };
var fromSpan = new SingleFieldReader(stack);
var fromSequence = new SingleFieldReader(new ReadOnlySequence<byte>(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();
}
}
}