diff --git a/Directory.Build.props b/Directory.Build.props
index e36f0f7d1..4b45e0f1d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,7 +10,7 @@
true
$(MSBuildThisFileDirectory)Shared.ruleset
NETSDK1069
- $(NoWarn);NU5105;NU1507;SER001;SER002;SER003
+ $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004
https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes
https://stackexchange.github.io/StackExchange.Redis/
MIT
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3fa9e0e3d..9767a0ab1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,6 +10,9 @@
+
+
+
diff --git a/StackExchange.Redis.sln b/StackExchange.Redis.sln
index adb1291de..ca5e3a60d 100644
--- a/StackExchange.Redis.sln
+++ b/StackExchange.Redis.sln
@@ -127,6 +127,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{5FA0958E-6EB
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Build", "eng\StackExchange.Redis.Build\StackExchange.Redis.Build.csproj", "{190742E1-FA50-4E36-A8C4-88AE87654340}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite", "src\RESPite\RESPite.csproj", "{AEA77181-DDD2-4E43-828B-908C7460A12D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.Tests", "tests\RESPite.Tests\RESPite.Tests.csproj", "{1D324077-A15E-4EE2-9AD6-A9045636CEAC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -189,6 +193,14 @@ Global
{190742E1-FA50-4E36-A8C4-88AE87654340}.Debug|Any CPU.Build.0 = Debug|Any CPU
{190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.ActiveCfg = Release|Any CPU
{190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AEA77181-DDD2-4E43-828B-908C7460A12D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AEA77181-DDD2-4E43-828B-908C7460A12D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AEA77181-DDD2-4E43-828B-908C7460A12D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AEA77181-DDD2-4E43-828B-908C7460A12D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1D324077-A15E-4EE2-9AD6-A9045636CEAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1D324077-A15E-4EE2-9AD6-A9045636CEAC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1D324077-A15E-4EE2-9AD6-A9045636CEAC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1D324077-A15E-4EE2-9AD6-A9045636CEAC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -212,6 +224,8 @@ Global
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
{59889284-FFEE-82E7-94CB-3B43E87DA6CF} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
{190742E1-FA50-4E36-A8C4-88AE87654340} = {5FA0958E-6EBD-45F4-808E-3447A293F96F}
+ {AEA77181-DDD2-4E43-828B-908C7460A12D} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
+ {1D324077-A15E-4EE2-9AD6-A9045636CEAC} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {193AA352-6748-47C1-A5FC-C9AA6B5F000B}
diff --git a/docs/exp/SER004.md b/docs/exp/SER004.md
new file mode 100644
index 000000000..91f5d87c4
--- /dev/null
+++ b/docs/exp/SER004.md
@@ -0,0 +1,15 @@
+# RESPite
+
+RESPite is an experimental library that provides high-performance low-level RESP (Redis, etc) parsing and serialization.
+It is used as the IO core for StackExchange.Redis v3+. You should not (yet) use it directly unless you have a very
+good reason to do so.
+
+```xml
+$(NoWarn);SER004
+```
+
+or more granularly / locally in C#:
+
+``` c#
+#pragma warning disable SER004
+```
diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs
new file mode 100644
index 000000000..f9016414c
--- /dev/null
+++ b/src/RESPite/Buffers/CycleBuffer.cs
@@ -0,0 +1,706 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using RESPite.Internal;
+
+namespace RESPite.Buffers;
+
+///
+/// Manages the state for a based IO buffer. Unlike Pipe,
+/// it is not intended for a separate producer-consumer - there is no thread-safety, and no
+/// activation; it just handles the buffers. It is intended to be used as a mutable (non-readonly)
+/// field in a type that performs IO; the internal state mutates - it should not be passed around.
+///
+/// Notionally, there is an uncommitted area (write) and a committed area (read). Process:
+/// - producer loop (*note no concurrency**)
+/// - call to get a new scratch
+/// - (write to that span)
+/// - call to mark complete portions
+/// - consumer loop (*note no concurrency**)
+/// - call to see if there is a single-span chunk; otherwise
+/// - call to get the multi-span chunk
+/// - (process none, some, or all of that data)
+/// - call to indicate how much data is no longer needed
+/// Emphasis: no concurrency! This is intended for a single worker acting as both producer and consumer.
+///
+/// There is a *lot* of validation in debug mode; we want to be super sure that we don't corrupt buffer state.
+///
+[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)]
+public partial struct CycleBuffer
+{
+ // note: if someone uses an uninitialized CycleBuffer (via default): that's a skills issue; git gud
+ public static CycleBuffer Create(MemoryPool? pool = null, int pageSize = DefaultPageSize)
+ {
+ pool ??= MemoryPool.Shared;
+ if (pageSize <= 0) pageSize = DefaultPageSize;
+ if (pageSize > pool.MaxBufferSize) pageSize = pool.MaxBufferSize;
+
+ return new CycleBuffer(pool, pageSize);
+ }
+
+ private CycleBuffer(MemoryPool pool, int pageSize)
+ {
+ Pool = pool;
+ PageSize = pageSize;
+ }
+
+ private const int DefaultPageSize = 8 * 1024;
+
+ public int PageSize { get; }
+ public MemoryPool Pool { get; }
+
+ private Segment? startSegment, endSegment;
+
+ private int endSegmentCommitted, endSegmentLength;
+
+ public bool TryGetCommitted(out ReadOnlySpan span)
+ {
+ DebugAssertValid();
+ if (!ReferenceEquals(startSegment, endSegment))
+ {
+ span = default;
+ return false;
+ }
+
+ span = startSegment is null ? default : startSegment.Memory.Span.Slice(start: 0, length: endSegmentCommitted);
+ return true;
+ }
+
+ ///
+ /// Commits data written to buffers from , making it available for consumption
+ /// via . This compares to .
+ ///
+ public void Commit(int count)
+ {
+ DebugAssertValid();
+ if (count <= 0)
+ {
+ if (count < 0) Throw();
+ return;
+ }
+
+ var available = endSegmentLength - endSegmentCommitted;
+ if (count > available) Throw();
+ endSegmentCommitted += count;
+ DebugAssertValid();
+
+ static void Throw() => throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ public bool CommittedIsEmpty => ReferenceEquals(startSegment, endSegment) & endSegmentCommitted == 0;
+
+ ///
+ /// Marks committed data as fully consumed; it will no longer appear in later calls to .
+ ///
+ public void DiscardCommitted(int count)
+ {
+ DebugAssertValid();
+ // optimize for most common case, where we consume everything
+ if (ReferenceEquals(startSegment, endSegment)
+ & count == endSegmentCommitted
+ & count > 0)
+ {
+ /*
+ we are consuming all the data in the single segment; we can
+ just reset that segment back to full size and re-use as-is;
+ note that we also know that there must *be* a segment
+ for the count check to pass
+ */
+ endSegmentCommitted = 0;
+ endSegmentLength = endSegment!.Untrim(expandBackwards: true);
+ DebugAssertValid(0);
+ DebugCounters.OnDiscardFull(count);
+ }
+ else if (count == 0)
+ {
+ // nothing to do
+ }
+ else
+ {
+ DiscardCommittedSlow(count);
+ }
+ }
+
+ public void DiscardCommitted(long count)
+ {
+ DebugAssertValid();
+ // optimize for most common case, where we consume everything
+ if (ReferenceEquals(startSegment, endSegment)
+ & count == endSegmentCommitted
+ & count > 0) // checks sign *and* non-trimmed
+ {
+ // see for logic
+ endSegmentCommitted = 0;
+ endSegmentLength = endSegment!.Untrim(expandBackwards: true);
+ DebugAssertValid(0);
+ DebugCounters.OnDiscardFull(count);
+ }
+ else if (count == 0)
+ {
+ // nothing to do
+ }
+ else
+ {
+ DiscardCommittedSlow(count);
+ }
+ }
+
+ private void DiscardCommittedSlow(long count)
+ {
+ DebugCounters.OnDiscardPartial(count);
+ DebugAssertValid();
+#if DEBUG
+ var originalLength = GetCommittedLength();
+ var originalCount = count;
+ var expectedLength = originalLength - originalCount;
+ string blame = nameof(DiscardCommittedSlow);
+#endif
+ while (count > 0)
+ {
+ DebugAssertValid();
+ var segment = startSegment;
+ if (segment is null) break;
+ if (ReferenceEquals(segment, endSegment))
+ {
+ // first==final==only segment
+ if (count == endSegmentCommitted)
+ {
+ endSegmentLength = startSegment!.Untrim();
+ endSegmentCommitted = 0; // = untrimmed and unused
+#if DEBUG
+ blame += ",full-final (t)";
+#endif
+ }
+ else
+ {
+ // discard from the start
+ int count32 = checked((int)count);
+ segment.TrimStart(count32);
+ endSegmentLength -= count32;
+ endSegmentCommitted -= count32;
+#if DEBUG
+ blame += ",partial-final";
+#endif
+ }
+
+ count = 0;
+ break;
+ }
+ else if (count < segment.Length)
+ {
+ // multiple, but can take some (not all) of the first buffer
+#if DEBUG
+ var len = segment.Length;
+#endif
+ segment.TrimStart((int)count);
+ Debug.Assert(segment.Length > 0, "parial trim should have left non-empty segment");
+#if DEBUG
+ Debug.Assert(segment.Length == len - count, "trim failure");
+ blame += ",partial-first";
+#endif
+ count = 0;
+ break;
+ }
+ else
+ {
+ // multiple; discard the entire first segment
+ count -= segment.Length;
+ startSegment =
+ segment.ResetAndGetNext(); // we already did a ref-check, so we know this isn't going past endSegment
+ endSegment!.AppendOrRecycle(segment, maxDepth: 2);
+ DebugAssertValid();
+#if DEBUG
+ blame += ",full-first";
+#endif
+ }
+ }
+
+ if (count != 0) ThrowCount();
+#if DEBUG
+ DebugAssertValid(expectedLength, blame);
+ _ = originalLength;
+ _ = originalCount;
+#endif
+
+ [DoesNotReturn]
+ static void ThrowCount() => throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ [Conditional("DEBUG")]
+ private void DebugAssertValid(long expectedCommittedLength, [CallerMemberName] string caller = "")
+ {
+ DebugAssertValid();
+ var actual = GetCommittedLength();
+ Debug.Assert(
+ expectedCommittedLength >= 0,
+ $"Expected committed length is just... wrong: {expectedCommittedLength} (from {caller})");
+ Debug.Assert(
+ expectedCommittedLength == actual,
+ $"Committed length mismatch: expected {expectedCommittedLength}, got {actual} (from {caller})");
+ }
+
+ [Conditional("DEBUG")]
+ private void DebugAssertValid()
+ {
+ if (startSegment is null)
+ {
+ Debug.Assert(
+ endSegmentLength == 0 & endSegmentCommitted == 0,
+ "un-init state should be zero");
+ return;
+ }
+
+ Debug.Assert(endSegment is not null, "end segment must not be null if start segment exists");
+ Debug.Assert(
+ endSegmentLength == endSegment!.Length,
+ $"end segment length is incorrect - expected {endSegmentLength}, got {endSegment.Length}");
+ Debug.Assert(endSegmentCommitted <= endSegmentLength, $"end segment is over-committed - {endSegmentCommitted} of {endSegmentLength}");
+
+ // check running indices
+ startSegment?.DebugAssertValidChain();
+ }
+
+ public long GetCommittedLength()
+ {
+ if (ReferenceEquals(startSegment, endSegment))
+ {
+ return endSegmentCommitted;
+ }
+
+ // note that the start-segment is pre-trimmed; we don't need to account for an offset on the left
+ return (endSegment!.RunningIndex + endSegmentCommitted) - startSegment!.RunningIndex;
+ }
+
+ ///
+ /// When used with , this means "any non-empty buffer".
+ ///
+ public const int GetAnything = 0;
+
+ ///
+ /// When used with , this means "any full buffer".
+ ///
+ public const int GetFullPagesOnly = -1;
+
+ public bool TryGetFirstCommittedSpan(int minBytes, out ReadOnlySpan span)
+ {
+ DebugAssertValid();
+ if (TryGetFirstCommittedMemory(minBytes, out var memory))
+ {
+ span = memory.Span;
+ return true;
+ }
+
+ span = default;
+ return false;
+ }
+
+ ///
+ /// The minLength arg: -ve means "full segments only" (useful when buffering outbound network data to avoid
+ /// packet fragmentation); otherwise, it is the minimum length we want.
+ ///
+ public bool TryGetFirstCommittedMemory(int minBytes, out ReadOnlyMemory memory)
+ {
+ if (minBytes == 0) minBytes = 1; // success always means "at least something"
+ DebugAssertValid();
+ if (ReferenceEquals(startSegment, endSegment))
+ {
+ // single page
+ var available = endSegmentCommitted;
+ if (available == 0)
+ {
+ // empty (includes uninitialized)
+ memory = default;
+ return false;
+ }
+
+ memory = startSegment!.Memory;
+ var memLength = memory.Length;
+ if (available == memLength)
+ {
+ // full segment; is it enough to make the caller happy?
+ return available >= minBytes;
+ }
+
+ // partial segment (and we know it isn't empty)
+ memory = memory.Slice(start: 0, length: available);
+ return available >= minBytes & minBytes > 0; // last check here applies the -ve logic
+ }
+
+ // multi-page; hand out the first page (which is, by definition: full)
+ memory = startSegment!.Memory;
+ return memory.Length >= minBytes;
+ }
+
+ ///
+ /// Note that this chain is invalidated by any other operations; no concurrency.
+ ///
+ public ReadOnlySequence GetAllCommitted()
+ {
+ if (ReferenceEquals(startSegment, endSegment))
+ {
+ // single segment, fine
+ return startSegment is null
+ ? default
+ : new ReadOnlySequence(startSegment.Memory.Slice(start: 0, length: endSegmentCommitted));
+ }
+
+#if PARSE_DETAIL
+ long length = GetCommittedLength();
+#endif
+ ReadOnlySequence ros = new(startSegment!, 0, endSegment!, endSegmentCommitted);
+#if PARSE_DETAIL
+ Debug.Assert(ros.Length == length, $"length mismatch: calculated {length}, actual {ros.Length}");
+#endif
+ return ros;
+ }
+
+ private Segment GetNextSegment()
+ {
+ DebugAssertValid();
+ if (endSegment is not null)
+ {
+ endSegment.TrimEnd(endSegmentCommitted);
+ Debug.Assert(endSegment.Length == endSegmentCommitted, "trim failure");
+ endSegmentLength = endSegmentCommitted;
+ DebugAssertValid();
+
+ var spare = endSegment.Next;
+ if (spare is not null)
+ {
+ // we already have a dangling segment; just update state
+ endSegment.DebugAssertValidChain();
+ endSegment = spare;
+ endSegmentCommitted = 0;
+ endSegmentLength = spare.Length;
+ DebugAssertValid();
+ return spare;
+ }
+ }
+
+ Segment newSegment = Segment.Create(Pool.Rent(PageSize));
+ if (endSegment is null)
+ {
+ // tabula rasa
+ endSegmentLength = newSegment.Length;
+ endSegment = startSegment = newSegment;
+ DebugAssertValid();
+ return newSegment;
+ }
+
+ endSegment.Append(newSegment);
+ endSegmentCommitted = 0;
+ endSegmentLength = newSegment.Length;
+ endSegment = newSegment;
+ DebugAssertValid();
+ return newSegment;
+ }
+
+ ///
+ /// Gets a scratch area for new data; this compares to .
+ ///
+ public Span GetUncommittedSpan(int hint = 0)
+ => GetUncommittedMemory(hint).Span;
+
+ ///
+ /// Gets a scratch area for new data; this compares to .
+ ///
+ public Memory GetUncommittedMemory(int hint = 0)
+ {
+ DebugAssertValid();
+ var segment = endSegment;
+ if (segment is not null)
+ {
+ var memory = segment.Memory;
+ if (endSegmentCommitted != 0) memory = memory.Slice(start: endSegmentCommitted);
+ if (hint <= 0) // allow anything non-empty
+ {
+ if (!memory.IsEmpty) return MemoryMarshal.AsMemory(memory);
+ }
+ else if (memory.Length >= Math.Min(hint, PageSize >> 2)) // respect the hint up to 1/4 of the page size
+ {
+ return MemoryMarshal.AsMemory(memory);
+ }
+ }
+
+ // new segment, will always be entire
+ return MemoryMarshal.AsMemory(GetNextSegment().Memory);
+ }
+
+ ///
+ /// This is the available unused buffer space, commonly used as the IO read-buffer to avoid
+ /// additional buffer-copy operations.
+ ///
+ public int UncommittedAvailable
+ {
+ get
+ {
+ DebugAssertValid();
+ return endSegmentLength - endSegmentCommitted;
+ }
+ }
+
+ private sealed class Segment : ReadOnlySequenceSegment
+ {
+ private Segment() { }
+ private IMemoryOwner _lease = NullLease.Instance;
+ private static Segment? _spare;
+ private Flags _flags;
+
+ [Flags]
+ private enum Flags
+ {
+ None = 0,
+ StartTrim = 1 << 0,
+ EndTrim = 1 << 2,
+ }
+
+ public static Segment Create(IMemoryOwner lease)
+ {
+ Debug.Assert(lease is not null, "null lease");
+ var memory = lease!.Memory;
+ if (memory.IsEmpty) ThrowEmpty();
+
+ var obj = Interlocked.Exchange(ref _spare, null) ?? new();
+ return obj.Init(lease, memory);
+ static void ThrowEmpty() => throw new InvalidOperationException("leased segment is empty");
+ }
+
+ private Segment Init(IMemoryOwner lease, Memory memory)
+ {
+ _lease = lease;
+ Memory = memory;
+ return this;
+ }
+
+ public int Length => Memory.Length;
+
+ public void Append(Segment next)
+ {
+ Debug.Assert(Next is null, "current segment already has a next");
+ Debug.Assert(next.Next is null && next.RunningIndex == 0, "inbound next segment is already in a chain");
+ next.RunningIndex = RunningIndex + Length;
+ Next = next;
+ DebugAssertValidChain();
+ }
+
+ private void ApplyChainDelta(int delta)
+ {
+ if (delta != 0)
+ {
+ var node = Next;
+ while (node is not null)
+ {
+ node.RunningIndex += delta;
+ node = node.Next;
+ }
+ }
+ }
+
+ public void TrimEnd(int newLength)
+ {
+ var delta = Length - newLength;
+ if (delta != 0)
+ {
+ // buffer wasn't fully used; trim
+ _flags |= Flags.EndTrim;
+ Memory = Memory.Slice(0, newLength);
+ ApplyChainDelta(-delta);
+ DebugAssertValidChain();
+ }
+ }
+
+ public void TrimStart(int remove)
+ {
+ if (remove != 0)
+ {
+ _flags |= Flags.StartTrim;
+ Memory = Memory.Slice(start: remove);
+ RunningIndex += remove; // so that ROS length keeps working; note we *don't* need to adjust the chain
+ DebugAssertValidChain();
+ }
+ }
+
+ public new Segment? Next
+ {
+ get => (Segment?)base.Next;
+ private set => base.Next = value;
+ }
+
+ public Segment? ResetAndGetNext()
+ {
+ var next = Next;
+ Next = null;
+ RunningIndex = 0;
+ _flags = Flags.None;
+ Memory = _lease.Memory; // reset, in case we trimmed it
+ DebugAssertValidChain();
+ return next;
+ }
+
+ public void Recycle()
+ {
+ var lease = _lease;
+ _lease = NullLease.Instance;
+ lease.Dispose();
+ Next = null;
+ Memory = default;
+ RunningIndex = 0;
+ _flags = Flags.None;
+ Interlocked.Exchange(ref _spare, this);
+ DebugAssertValidChain();
+ }
+
+ private sealed class NullLease : IMemoryOwner
+ {
+ private NullLease() { }
+ public static readonly NullLease Instance = new NullLease();
+ public void Dispose() { }
+
+ public Memory Memory => default;
+ }
+
+ ///
+ /// Undo any trimming, returning the new full capacity.
+ ///
+ public int Untrim(bool expandBackwards = false)
+ {
+ var fullMemory = _lease.Memory;
+ var fullLength = fullMemory.Length;
+ var delta = fullLength - Length;
+ if (delta != 0)
+ {
+ _flags &= ~(Flags.StartTrim | Flags.EndTrim);
+ Memory = fullMemory;
+ if (expandBackwards & RunningIndex >= delta)
+ {
+ // push our origin earlier; only valid if
+ // we're the first segment, otherwise
+ // we break someone-else's chain
+ RunningIndex -= delta;
+ }
+ else
+ {
+ // push everyone else later
+ ApplyChainDelta(delta);
+ }
+
+ DebugAssertValidChain();
+ }
+ return fullLength;
+ }
+
+ public bool StartTrimmed => (_flags & Flags.StartTrim) != 0;
+ public bool EndTrimmed => (_flags & Flags.EndTrim) != 0;
+
+ [Conditional("DEBUG")]
+ public void DebugAssertValidChain([CallerMemberName] string blame = "")
+ {
+ var node = this;
+ var runningIndex = RunningIndex;
+ int index = 0;
+ while (node.Next is { } next)
+ {
+ index++;
+ var nextRunningIndex = runningIndex + node.Length;
+ if (nextRunningIndex != next.RunningIndex) ThrowRunningIndex(blame, index);
+ node = next;
+ runningIndex = nextRunningIndex;
+ static void ThrowRunningIndex(string blame, int index) => throw new InvalidOperationException(
+ $"Critical running index corruption in dangling chain, from '{blame}', segment {index}");
+ }
+ }
+
+ public void AppendOrRecycle(Segment segment, int maxDepth)
+ {
+ segment.Memory.DebugScramble();
+ var node = this;
+ while (maxDepth-- > 0 && node is not null)
+ {
+ if (node.Next is null) // found somewhere to attach it
+ {
+ if (segment.Untrim() == 0) break; // turned out to be useless
+ segment.RunningIndex = node.RunningIndex + node.Length;
+ node.Next = segment;
+ return;
+ }
+
+ node = node.Next;
+ }
+
+ segment.Recycle();
+ }
+ }
+
+ ///
+ /// Discard all data and buffers.
+ ///
+ public void Release()
+ {
+ var node = startSegment;
+ startSegment = endSegment = null;
+ endSegmentCommitted = endSegmentLength = 0;
+ while (node is not null)
+ {
+ var next = node.Next;
+ node.Recycle();
+ node = next;
+ }
+ }
+
+ ///
+ /// Writes a value to the buffer; comparable to .
+ ///
+ public void Write(ReadOnlySpan value)
+ {
+ int srcLength = value.Length;
+ while (srcLength != 0)
+ {
+ var target = GetUncommittedSpan(hint: srcLength);
+ var tgtLength = target.Length;
+ if (tgtLength >= srcLength)
+ {
+ value.CopyTo(target);
+ Commit(srcLength);
+ return;
+ }
+
+ value.Slice(0, tgtLength).CopyTo(target);
+ Commit(tgtLength);
+ value = value.Slice(tgtLength);
+ srcLength -= tgtLength;
+ }
+ }
+
+ ///
+ /// Writes a value to the buffer; comparable to .
+ ///
+ public void Write(in ReadOnlySequence value)
+ {
+ if (value.IsSingleSegment)
+ {
+#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1
+ Write(value.FirstSpan);
+#else
+ Write(value.First.Span);
+#endif
+ }
+ else
+ {
+ WriteMultiSegment(ref this, in value);
+ }
+
+ static void WriteMultiSegment(ref CycleBuffer @this, in ReadOnlySequence value)
+ {
+ foreach (var segment in value)
+ {
+#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1
+ @this.Write(value.FirstSpan);
+#else
+ @this.Write(value.First.Span);
+#endif
+ }
+ }
+ }
+}
diff --git a/src/RESPite/Internal/BlockBuffer.cs b/src/RESPite/Internal/BlockBuffer.cs
new file mode 100644
index 000000000..752d74c8d
--- /dev/null
+++ b/src/RESPite/Internal/BlockBuffer.cs
@@ -0,0 +1,341 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace RESPite.Internal;
+
+internal abstract partial class BlockBufferSerializer
+{
+ internal sealed class BlockBuffer : MemoryManager
+ {
+ private BlockBuffer(BlockBufferSerializer parent, int minCapacity)
+ {
+ _arrayPool = parent._arrayPool;
+ _array = _arrayPool.Rent(minCapacity);
+ DebugCounters.OnBufferCapacity(_array.Length);
+#if DEBUG
+ _parent = parent;
+ parent.DebugBufferCreated();
+#endif
+ }
+
+ private int _refCount = 1;
+ private int _finalizedOffset, _writeOffset;
+ private readonly ArrayPool _arrayPool;
+ private byte[] _array;
+#if DEBUG
+ private int _finalizedCount;
+ private BlockBufferSerializer _parent;
+#endif
+
+ public override string ToString() =>
+#if DEBUG
+ $"{_finalizedCount} messages; " +
+#endif
+ $"{_finalizedOffset} finalized bytes; writing: {NonFinalizedData.Length} bytes, {Available} available; observers: {_refCount}";
+
+ // only used when filling; _buffer should be non-null
+ private int Available => _array.Length - _writeOffset;
+ public Memory UncommittedMemory => _array.AsMemory(_writeOffset);
+ public Span UncommittedSpan => _array.AsSpan(_writeOffset);
+
+ // decrease ref-count; dispose if necessary
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Release()
+ {
+ if (Interlocked.Decrement(ref _refCount) <= 0) Recycle();
+ }
+
+ public void AddRef()
+ {
+ if (!TryAddRef()) Throw();
+ static void Throw() => throw new ObjectDisposedException(nameof(BlockBuffer));
+ }
+
+ public bool TryAddRef()
+ {
+ int count;
+ do
+ {
+ count = Volatile.Read(ref _refCount);
+ if (count <= 0) return false;
+ }
+ // repeat until we can successfully swap/incr
+ while (Interlocked.CompareExchange(ref _refCount, count + 1, count) != count);
+
+ return true;
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)] // called rarely vs Dispose
+ private void Recycle()
+ {
+ var count = Volatile.Read(ref _refCount);
+ if (count == 0)
+ {
+ _array.DebugScramble();
+#if DEBUG
+ GC.SuppressFinalize(this); // only have a finalizer in debug
+ _parent.DebugBufferRecycled(_array.Length);
+#endif
+ _arrayPool.Return(_array);
+ _array = [];
+ }
+
+ Debug.Assert(count == 0, $"over-disposal? count={count}");
+ }
+
+#if DEBUG
+#pragma warning disable CA2015 // Adding a finalizer to a type derived from MemoryManager may permit memory to be freed while it is still in use by a Span
+ // (the above is fine because we don't actually release anything - just a counter)
+ ~BlockBuffer()
+ {
+ _parent.DebugBufferLeaked();
+ DebugCounters.OnBufferLeaked();
+ }
+#pragma warning restore CA2015
+#endif
+
+ public static BlockBuffer GetBuffer(BlockBufferSerializer parent, int sizeHint)
+ {
+ // note this isn't an actual "max", just a max of what we guarantee; we give the caller
+ // whatever is left in the buffer; the clamped hint just decides whether we need a *new* buffer
+ const int MinSize = 16, MaxSize = 128;
+ sizeHint = Math.Min(Math.Max(sizeHint, MinSize), MaxSize);
+
+ var buffer = parent.Buffer; // most common path is "exists, with enough data"
+ return buffer is not null && buffer.AvailableWithResetIfUseful() >= sizeHint
+ ? buffer
+ : GetBufferSlow(parent, sizeHint);
+ }
+
+ // would it be useful and possible to reset? i.e. if all finalized chunks have been returned,
+ private int AvailableWithResetIfUseful()
+ {
+ if (_finalizedOffset != 0 // at least some chunks have been finalized
+ && Volatile.Read(ref _refCount) == 1 // all finalized chunks returned
+ & _writeOffset == _finalizedOffset) // we're not in the middle of serializing something new
+ {
+ _writeOffset = _finalizedOffset = 0; // swipe left
+ }
+
+ return _array.Length - _writeOffset;
+ }
+
+ private static BlockBuffer GetBufferSlow(BlockBufferSerializer parent, int minBytes)
+ {
+ // note clamp on size hint has already been applied
+ const int DefaultBufferSize = 2048;
+ var buffer = parent.Buffer;
+ if (buffer is null)
+ {
+ // first buffer
+ return parent.Buffer = new BlockBuffer(parent, DefaultBufferSize);
+ }
+
+ Debug.Assert(minBytes > buffer.Available, "existing buffer has capacity - why are we here?");
+
+ if (buffer.TryResizeFor(minBytes))
+ {
+ Debug.Assert(buffer.Available >= minBytes);
+ return buffer;
+ }
+
+ // We've tried reset and resize - no more tricks; we need to move to a new buffer, starting with a
+ // capacity for any existing data in this message, plus the new chunk we're adding.
+ var nonFinalizedBytes = buffer.NonFinalizedData;
+ var newBuffer = new BlockBuffer(parent, Math.Max(nonFinalizedBytes.Length + minBytes, DefaultBufferSize));
+
+ // copy the existing message data, if any (the previous message might have finished near the
+ // boundary, in which case we might not have written anything yet)
+ newBuffer.CopyFrom(nonFinalizedBytes);
+ Debug.Assert(newBuffer.Available >= minBytes, "should have requested extra capacity");
+
+ // the ~emperor~ buffer is dead; long live the ~emperor~ buffer
+ parent.Buffer = newBuffer;
+ buffer.MarkComplete(parent);
+ return newBuffer;
+ }
+
+ // used for elective reset (rather than "because we ran out of space")
+ public static void Clear(BlockBufferSerializer parent)
+ {
+ if (parent.Buffer is { } buffer)
+ {
+ parent.Buffer = null;
+ buffer.MarkComplete(parent);
+ }
+ }
+
+ public static ReadOnlyMemory RetainCurrent(BlockBufferSerializer parent)
+ {
+ if (parent.Buffer is { } buffer && buffer._finalizedOffset != 0)
+ {
+ parent.Buffer = null;
+ buffer.AddRef();
+ return buffer.CreateMemory(0, buffer._finalizedOffset);
+ }
+ // nothing useful to detach!
+ return default;
+ }
+
+ private void MarkComplete(BlockBufferSerializer parent)
+ {
+ // record that the old buffer no longer logically has any non-committed bytes (mostly just for ToString())
+ _writeOffset = _finalizedOffset;
+ Debug.Assert(IsNonCommittedEmpty);
+
+ // see if the caller wants to take ownership of the segment
+ if (_finalizedOffset != 0 && !parent.ClaimSegment(CreateMemory(0, _finalizedOffset)))
+ {
+ Release(); // decrement the observer
+ }
+#if DEBUG
+ DebugCounters.OnBufferCompleted(_finalizedCount, _finalizedOffset);
+#endif
+ }
+
+ private void CopyFrom(Span source)
+ {
+ source.CopyTo(UncommittedSpan);
+ _writeOffset += source.Length;
+ }
+
+ private Span NonFinalizedData => _array.AsSpan(
+ _finalizedOffset, _writeOffset - _finalizedOffset);
+
+ private bool TryResizeFor(int extraBytes)
+ {
+ if (_finalizedOffset == 0 & // we can only do this if there are no other messages in the buffer
+ Volatile.Read(ref _refCount) == 1) // and no-one else is looking (we already tried reset)
+ {
+ // we're already on the boundary - don't scrimp; just do the math from the end of the buffer
+ byte[] newArray = _arrayPool.Rent(_array.Length + extraBytes);
+ DebugCounters.OnBufferCapacity(newArray.Length - _array.Length); // account for extra only
+
+ // copy the existing data (we always expect some, since we've clamped extraBytes to be
+ // much smaller than the default buffer size)
+ NonFinalizedData.CopyTo(newArray);
+ _array.DebugScramble();
+ _arrayPool.Return(_array);
+ _array = newArray;
+ return true;
+ }
+
+ return false;
+ }
+
+ public static void Advance(BlockBufferSerializer parent, int count)
+ {
+ if (count == 0) return;
+ if (count < 0) ThrowOutOfRange();
+ var buffer = parent.Buffer;
+ if (buffer is null || buffer.Available < count) ThrowOutOfRange();
+ buffer._writeOffset += count;
+
+ [DoesNotReturn]
+ static void ThrowOutOfRange() => throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ public void RevertUnfinalized(BlockBufferSerializer parent)
+ {
+ // undo any writes (something went wrong during serialize)
+ _finalizedOffset = _writeOffset;
+ }
+
+ private ReadOnlyMemory FinalizeBlock()
+ {
+ var length = _writeOffset - _finalizedOffset;
+ Debug.Assert(length > 0, "already checked this in FinalizeMessage!");
+ var chunk = CreateMemory(_finalizedOffset, length);
+ _finalizedOffset = _writeOffset; // move the write head
+#if DEBUG
+ _finalizedCount++;
+ _parent.DebugMessageFinalized(length);
+#endif
+ Interlocked.Increment(ref _refCount); // add an observer
+ return chunk;
+ }
+
+ private bool IsNonCommittedEmpty => _finalizedOffset == _writeOffset;
+
+ public static ReadOnlyMemory FinalizeMessage(BlockBufferSerializer parent)
+ {
+ var buffer = parent.Buffer;
+ if (buffer is null || buffer.IsNonCommittedEmpty)
+ {
+#if DEBUG // still count it for logging purposes
+ if (buffer is not null) buffer._finalizedCount++;
+ parent.DebugMessageFinalized(0);
+#endif
+ return default;
+ }
+
+ return buffer.FinalizeBlock();
+ }
+
+ // MemoryManager pieces
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Release();
+ }
+
+ public override Span GetSpan() => _array;
+ public int Length => _array.Length;
+
+ // base version is CreateMemory(GetSpan().Length); avoid that GetSpan()
+ public override Memory Memory => CreateMemory(_array.Length);
+
+ public override unsafe MemoryHandle Pin(int elementIndex = 0)
+ {
+ // We *could* be cute and use a shared pin - but that's a *lot*
+ // of work (synchronization), requires extra storage, and for an
+ // API that is very unlikely; hence: we'll use per-call GC pins.
+ GCHandle handle = GCHandle.Alloc(_array, GCHandleType.Pinned);
+ DebugCounters.OnBufferPinned(); // prove how unlikely this is
+ byte* ptr = (byte*)handle.AddrOfPinnedObject();
+ // note no IPinnable in the MemoryHandle;
+ return new MemoryHandle(ptr + elementIndex, handle);
+ }
+
+ // This would only be called if we passed out a MemoryHandle with ourselves
+ // as IPinnable (in Pin), which: we don't.
+ public override void Unpin() => throw new NotSupportedException();
+
+ protected override bool TryGetArray(out ArraySegment segment)
+ {
+ segment = new ArraySegment(_array);
+ return true;
+ }
+
+ internal static void Release(in ReadOnlySequence request)
+ {
+ if (request.IsSingleSegment)
+ {
+ if (MemoryMarshal.TryGetMemoryManager(
+ request.First, out var block))
+ {
+ block.Release();
+ }
+ }
+ else
+ {
+ ReleaseMultiBlock(in request);
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static void ReleaseMultiBlock(in ReadOnlySequence request)
+ {
+ foreach (var segment in request)
+ {
+ if (MemoryMarshal.TryGetMemoryManager(
+ segment, out var block))
+ {
+ block.Release();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/RESPite/Internal/BlockBufferSerializer.cs b/src/RESPite/Internal/BlockBufferSerializer.cs
new file mode 100644
index 000000000..a74b96472
--- /dev/null
+++ b/src/RESPite/Internal/BlockBufferSerializer.cs
@@ -0,0 +1,97 @@
+using System.Buffers;
+using System.Diagnostics;
+using RESPite.Messages;
+
+namespace RESPite.Internal;
+
+///
+/// Provides abstracted access to a buffer-writing API. Conveniently, we only give the caller
+/// RespWriter - which they cannot export (ref-type), thus we never actually give the
+/// public caller our IBufferWriter{byte}. Likewise, note that serialization is synchronous,
+/// i.e. never switches thread during an operation. This gives us quite a bit of flexibility.
+/// There are two main uses of BlockBufferSerializer:
+/// 1. thread-local: ambient, used for random messages so that each thread is quietly packing
+/// a thread-specific buffer; zero concurrency because of [ThreadStatic] hackery.
+/// 2. batching: RespBatch hosts a serializer that reflects the batch we're building; successive
+/// commands in the same batch are written adjacently in a shared buffer - we explicitly
+/// detect and reject concurrency attempts in a batch (which is fair: a batch has order).
+///
+internal abstract partial class BlockBufferSerializer(ArrayPool? arrayPool = null) : IBufferWriter
+{
+ private readonly ArrayPool _arrayPool = arrayPool ?? ArrayPool.Shared;
+ private protected abstract BlockBuffer? Buffer { get; set; }
+
+ Memory IBufferWriter.GetMemory(int sizeHint) => BlockBuffer.GetBuffer(this, sizeHint).UncommittedMemory;
+
+ Span IBufferWriter.GetSpan(int sizeHint) => BlockBuffer.GetBuffer(this, sizeHint).UncommittedSpan;
+
+ void IBufferWriter.Advance(int count) => BlockBuffer.Advance(this, count);
+
+ public virtual void Clear() => BlockBuffer.Clear(this);
+
+ internal virtual ReadOnlySequence Flush() => throw new NotSupportedException();
+
+ /*
+ public virtual ReadOnlyMemory Serialize(
+ RespCommandMap? commandMap,
+ ReadOnlySpan command,
+ in TRequest request,
+ IRespFormatter formatter)
+#if NET9_0_OR_GREATER
+ where TRequest : allows ref struct
+#endif
+ {
+ try
+ {
+ var writer = new RespWriter(this);
+ writer.CommandMap = commandMap;
+ formatter.Format(command, ref writer, request);
+ writer.Flush();
+ return BlockBuffer.FinalizeMessage(this);
+ }
+ catch
+ {
+ Buffer?.RevertUnfinalized(this);
+ throw;
+ }
+ }
+ */
+
+ internal void Revert() => Buffer?.RevertUnfinalized(this);
+
+ protected virtual bool ClaimSegment(ReadOnlyMemory segment) => false;
+
+#if DEBUG
+ private int _countAdded, _countRecycled, _countLeaked, _countMessages;
+ private long _countMessageBytes;
+ public int CountLeaked => Volatile.Read(ref _countLeaked);
+ public int CountRecycled => Volatile.Read(ref _countRecycled);
+ public int CountAdded => Volatile.Read(ref _countAdded);
+ public int CountMessages => Volatile.Read(ref _countMessages);
+ public long CountMessageBytes => Volatile.Read(ref _countMessageBytes);
+
+ [Conditional("DEBUG")]
+ private void DebugBufferLeaked() => Interlocked.Increment(ref _countLeaked);
+
+ [Conditional("DEBUG")]
+ private void DebugBufferRecycled(int length)
+ {
+ Interlocked.Increment(ref _countRecycled);
+ DebugCounters.OnBufferRecycled(length);
+ }
+
+ [Conditional("DEBUG")]
+ private void DebugBufferCreated()
+ {
+ Interlocked.Increment(ref _countAdded);
+ DebugCounters.OnBufferCreated();
+ }
+
+ [Conditional("DEBUG")]
+ private void DebugMessageFinalized(int bytes)
+ {
+ Interlocked.Increment(ref _countMessages);
+ Interlocked.Add(ref _countMessageBytes, bytes);
+ }
+#endif
+}
diff --git a/src/RESPite/Internal/DebugCounters.cs b/src/RESPite/Internal/DebugCounters.cs
new file mode 100644
index 000000000..d6f3da37a
--- /dev/null
+++ b/src/RESPite/Internal/DebugCounters.cs
@@ -0,0 +1,152 @@
+using System.Diagnostics;
+
+namespace RESPite.Internal;
+
+internal partial class DebugCounters
+{
+#if DEBUG
+ private static int
+ _tallyAsyncReadCount,
+ _tallyAsyncReadInlineCount,
+ _tallyDiscardFullCount,
+ _tallyDiscardPartialCount,
+ _tallyBufferCreatedCount,
+ _tallyBufferRecycledCount,
+ _tallyBufferMessageCount,
+ _tallyBufferPinCount,
+ _tallyBufferLeakCount;
+
+ private static long
+ _tallyReadBytes,
+ _tallyDiscardAverage,
+ _tallyBufferMessageBytes,
+ _tallyBufferRecycledBytes,
+ _tallyBufferMaxOutstandingBytes,
+ _tallyBufferTotalBytes;
+#endif
+
+ [Conditional("DEBUG")]
+ public static void OnDiscardFull(long count)
+ {
+#if DEBUG
+ if (count > 0)
+ {
+ Interlocked.Increment(ref _tallyDiscardFullCount);
+ EstimatedMovingRangeAverage(ref _tallyDiscardAverage, count);
+ }
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnDiscardPartial(long count)
+ {
+#if DEBUG
+ if (count > 0)
+ {
+ Interlocked.Increment(ref _tallyDiscardPartialCount);
+ EstimatedMovingRangeAverage(ref _tallyDiscardAverage, count);
+ }
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ internal static void OnAsyncRead(int bytes, bool inline)
+ {
+#if DEBUG
+ Interlocked.Increment(ref inline ? ref _tallyAsyncReadInlineCount : ref _tallyAsyncReadCount);
+ if (bytes > 0) Interlocked.Add(ref _tallyReadBytes, bytes);
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnBufferCreated()
+ {
+#if DEBUG
+ Interlocked.Increment(ref _tallyBufferCreatedCount);
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnBufferRecycled(int messageBytes)
+ {
+#if DEBUG
+ Interlocked.Increment(ref _tallyBufferRecycledCount);
+ var now = Interlocked.Add(ref _tallyBufferRecycledBytes, messageBytes);
+ var outstanding = Volatile.Read(ref _tallyBufferMessageBytes) - now;
+
+ while (true)
+ {
+ var oldOutstanding = Volatile.Read(ref _tallyBufferMaxOutstandingBytes);
+ // loop until either it isn't an increase, or we successfully perform
+ // the swap
+ if (outstanding <= oldOutstanding
+ || Interlocked.CompareExchange(
+ ref _tallyBufferMaxOutstandingBytes,
+ outstanding,
+ oldOutstanding) == oldOutstanding) break;
+ }
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnBufferCompleted(int messageCount, int messageBytes)
+ {
+#if DEBUG
+ Interlocked.Add(ref _tallyBufferMessageCount, messageCount);
+ Interlocked.Add(ref _tallyBufferMessageBytes, messageBytes);
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnBufferCapacity(int bytes)
+ {
+#if DEBUG
+ Interlocked.Add(ref _tallyBufferTotalBytes, bytes);
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnBufferPinned()
+ {
+#if DEBUG
+ Interlocked.Increment(ref _tallyBufferPinCount);
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ public static void OnBufferLeaked()
+ {
+#if DEBUG
+ Interlocked.Increment(ref _tallyBufferLeakCount);
+#endif
+ }
+
+#if DEBUG
+ private static void EstimatedMovingRangeAverage(ref long field, long value)
+ {
+ var oldValue = Volatile.Read(ref field);
+ var delta = (value - oldValue) >> 3; // is is a 7:1 old:new EMRA, using integer/bit math (alplha=0.125)
+ if (delta != 0) Interlocked.Add(ref field, delta);
+ // note: strictly conflicting concurrent calls can skew the value incorrectly; this is, however,
+ // preferable to getting into a CEX squabble or requiring a lock - it is debug-only and just useful data
+ }
+
+ public int AsyncReadCount { get; } = Interlocked.Exchange(ref _tallyAsyncReadCount, 0);
+ public int AsyncReadInlineCount { get; } = Interlocked.Exchange(ref _tallyAsyncReadInlineCount, 0);
+ public long ReadBytes { get; } = Interlocked.Exchange(ref _tallyReadBytes, 0);
+
+ public long DiscardAverage { get; } = Interlocked.Exchange(ref _tallyDiscardAverage, 32);
+ public int DiscardFullCount { get; } = Interlocked.Exchange(ref _tallyDiscardFullCount, 0);
+ public int DiscardPartialCount { get; } = Interlocked.Exchange(ref _tallyDiscardPartialCount, 0);
+
+ public int BufferCreatedCount { get; } = Interlocked.Exchange(ref _tallyBufferCreatedCount, 0);
+ public int BufferRecycledCount { get; } = Interlocked.Exchange(ref _tallyBufferRecycledCount, 0);
+ public long BufferRecycledBytes { get; } = Interlocked.Exchange(ref _tallyBufferRecycledBytes, 0);
+ public long BufferMaxOutstandingBytes { get; } = Interlocked.Exchange(ref _tallyBufferMaxOutstandingBytes, 0);
+ public int BufferMessageCount { get; } = Interlocked.Exchange(ref _tallyBufferMessageCount, 0);
+ public long BufferMessageBytes { get; } = Interlocked.Exchange(ref _tallyBufferMessageBytes, 0);
+ public long BufferTotalBytes { get; } = Interlocked.Exchange(ref _tallyBufferTotalBytes, 0);
+ public int BufferPinCount { get; } = Interlocked.Exchange(ref _tallyBufferPinCount, 0);
+ public int BufferLeakCount { get; } = Interlocked.Exchange(ref _tallyBufferLeakCount, 0);
+#endif
+}
diff --git a/src/RESPite/Internal/Raw.cs b/src/RESPite/Internal/Raw.cs
new file mode 100644
index 000000000..65d0c5059
--- /dev/null
+++ b/src/RESPite/Internal/Raw.cs
@@ -0,0 +1,138 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+
+#if NETCOREAPP3_0_OR_GREATER
+using System.Runtime.Intrinsics;
+using System.Runtime.Intrinsics.X86;
+#endif
+
+namespace RESPite.Internal;
+
+///
+/// Pre-computed payload fragments, for high-volume scenarios / common values.
+///
+///
+/// CPU-endianness applies here; we can't just use "const" - however, modern JITs treat "static readonly" *almost* the same as "const", so: meh.
+///
+internal static class Raw
+{
+ public static ulong Create64(ReadOnlySpan bytes, int length)
+ {
+ if (length != bytes.Length)
+ {
+ throw new ArgumentException($"Length check failed: {length} vs {bytes.Length}, value: {RespConstants.UTF8.GetString(bytes)}", nameof(length));
+ }
+ if (length < 0 || length > sizeof(ulong))
+ {
+ throw new ArgumentOutOfRangeException(nameof(length), $"Invalid length {length} - must be 0-{sizeof(ulong)}");
+ }
+
+ // this *will* be aligned; this approach intentionally chosen for parity with write
+ Span scratch = stackalloc byte[sizeof(ulong)];
+ if (length != sizeof(ulong)) scratch.Slice(length).Clear();
+ bytes.CopyTo(scratch);
+ return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(scratch));
+ }
+
+ public static uint Create32(ReadOnlySpan bytes, int length)
+ {
+ if (length != bytes.Length)
+ {
+ throw new ArgumentException($"Length check failed: {length} vs {bytes.Length}, value: {RespConstants.UTF8.GetString(bytes)}", nameof(length));
+ }
+ if (length < 0 || length > sizeof(uint))
+ {
+ throw new ArgumentOutOfRangeException(nameof(length), $"Invalid length {length} - must be 0-{sizeof(uint)}");
+ }
+
+ // this *will* be aligned; this approach intentionally chosen for parity with write
+ Span scratch = stackalloc byte[sizeof(uint)];
+ if (length != sizeof(uint)) scratch.Slice(length).Clear();
+ bytes.CopyTo(scratch);
+ return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(scratch));
+ }
+
+ public static ulong BulkStringEmpty_6 = Create64("$0\r\n\r\n"u8, 6);
+
+ public static ulong BulkStringInt32_M1_8 = Create64("$2\r\n-1\r\n"u8, 8);
+ public static ulong BulkStringInt32_0_7 = Create64("$1\r\n0\r\n"u8, 7);
+ public static ulong BulkStringInt32_1_7 = Create64("$1\r\n1\r\n"u8, 7);
+ public static ulong BulkStringInt32_2_7 = Create64("$1\r\n2\r\n"u8, 7);
+ public static ulong BulkStringInt32_3_7 = Create64("$1\r\n3\r\n"u8, 7);
+ public static ulong BulkStringInt32_4_7 = Create64("$1\r\n4\r\n"u8, 7);
+ public static ulong BulkStringInt32_5_7 = Create64("$1\r\n5\r\n"u8, 7);
+ public static ulong BulkStringInt32_6_7 = Create64("$1\r\n6\r\n"u8, 7);
+ public static ulong BulkStringInt32_7_7 = Create64("$1\r\n7\r\n"u8, 7);
+ public static ulong BulkStringInt32_8_7 = Create64("$1\r\n8\r\n"u8, 7);
+ public static ulong BulkStringInt32_9_7 = Create64("$1\r\n9\r\n"u8, 7);
+ public static ulong BulkStringInt32_10_8 = Create64("$2\r\n10\r\n"u8, 8);
+
+ public static ulong BulkStringPrefix_M1_5 = Create64("$-1\r\n"u8, 5);
+ public static uint BulkStringPrefix_0_4 = Create32("$0\r\n"u8, 4);
+ public static uint BulkStringPrefix_1_4 = Create32("$1\r\n"u8, 4);
+ public static uint BulkStringPrefix_2_4 = Create32("$2\r\n"u8, 4);
+ public static uint BulkStringPrefix_3_4 = Create32("$3\r\n"u8, 4);
+ public static uint BulkStringPrefix_4_4 = Create32("$4\r\n"u8, 4);
+ public static uint BulkStringPrefix_5_4 = Create32("$5\r\n"u8, 4);
+ public static uint BulkStringPrefix_6_4 = Create32("$6\r\n"u8, 4);
+ public static uint BulkStringPrefix_7_4 = Create32("$7\r\n"u8, 4);
+ public static uint BulkStringPrefix_8_4 = Create32("$8\r\n"u8, 4);
+ public static uint BulkStringPrefix_9_4 = Create32("$9\r\n"u8, 4);
+ public static ulong BulkStringPrefix_10_5 = Create64("$10\r\n"u8, 5);
+
+ public static ulong ArrayPrefix_M1_5 = Create64("*-1\r\n"u8, 5);
+ public static uint ArrayPrefix_0_4 = Create32("*0\r\n"u8, 4);
+ public static uint ArrayPrefix_1_4 = Create32("*1\r\n"u8, 4);
+ public static uint ArrayPrefix_2_4 = Create32("*2\r\n"u8, 4);
+ public static uint ArrayPrefix_3_4 = Create32("*3\r\n"u8, 4);
+ public static uint ArrayPrefix_4_4 = Create32("*4\r\n"u8, 4);
+ public static uint ArrayPrefix_5_4 = Create32("*5\r\n"u8, 4);
+ public static uint ArrayPrefix_6_4 = Create32("*6\r\n"u8, 4);
+ public static uint ArrayPrefix_7_4 = Create32("*7\r\n"u8, 4);
+ public static uint ArrayPrefix_8_4 = Create32("*8\r\n"u8, 4);
+ public static uint ArrayPrefix_9_4 = Create32("*9\r\n"u8, 4);
+ public static ulong ArrayPrefix_10_5 = Create64("*10\r\n"u8, 5);
+
+#if NETCOREAPP3_0_OR_GREATER
+ private static uint FirstAndLast(char first, char last)
+ {
+ Debug.Assert(first < 128 && last < 128, "ASCII please");
+ Span scratch = [(byte)first, 0, 0, (byte)last];
+ // this *will* be aligned; this approach intentionally chosen for how we read
+ return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(scratch));
+ }
+
+ public const int CommonRespIndex_Success = 0;
+ public const int CommonRespIndex_SingleDigitInteger = 1;
+ public const int CommonRespIndex_DoubleDigitInteger = 2;
+ public const int CommonRespIndex_SingleDigitString = 3;
+ public const int CommonRespIndex_DoubleDigitString = 4;
+ public const int CommonRespIndex_SingleDigitArray = 5;
+ public const int CommonRespIndex_DoubleDigitArray = 6;
+ public const int CommonRespIndex_Error = 7;
+
+ public static readonly Vector256 CommonRespPrefixes = Vector256.Create(
+ FirstAndLast('+', '\r'), // success +OK\r\n
+ FirstAndLast(':', '\n'), // single-digit integer :4\r\n
+ FirstAndLast(':', '\r'), // double-digit integer :42\r\n
+ FirstAndLast('$', '\n'), // 0-9 char string $0\r\n\r\n
+ FirstAndLast('$', '\r'), // null/10-99 char string $-1\r\n or $10\r\nABCDEFGHIJ\r\n
+ FirstAndLast('*', '\n'), // 0-9 length array *0\r\n
+ FirstAndLast('*', '\r'), // null/10-99 length array *-1\r\n or *10\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n
+ FirstAndLast('-', 'R')); // common errors -ERR something bad happened
+
+ public static readonly Vector256 FirstLastMask = CreateUInt32(0xFF0000FF);
+
+ private static Vector256 CreateUInt32(uint value)
+ {
+#if NET7_0_OR_GREATER
+ return Vector256.Create(value);
+#else
+ return Vector256.Create(value, value, value, value, value, value, value, value);
+#endif
+ }
+
+#endif
+}
diff --git a/src/RESPite/Internal/RespConstants.cs b/src/RESPite/Internal/RespConstants.cs
new file mode 100644
index 000000000..accb8400b
--- /dev/null
+++ b/src/RESPite/Internal/RespConstants.cs
@@ -0,0 +1,53 @@
+using System.Buffers.Binary;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+// ReSharper disable InconsistentNaming
+namespace RESPite.Internal;
+
+internal static class RespConstants
+{
+ public static readonly UTF8Encoding UTF8 = new(false);
+
+ public static ReadOnlySpan CrlfBytes => "\r\n"u8;
+
+ public static readonly ushort CrLfUInt16 = UnsafeCpuUInt16(CrlfBytes);
+
+ public static ReadOnlySpan OKBytes_LC => "ok"u8;
+ public static ReadOnlySpan OKBytes => "OK"u8;
+ public static readonly ushort OKUInt16 = UnsafeCpuUInt16(OKBytes);
+ public static readonly ushort OKUInt16_LC = UnsafeCpuUInt16(OKBytes_LC);
+
+ public static readonly uint BulkStringStreaming = UnsafeCpuUInt32("$?\r\n"u8);
+ public static readonly uint BulkStringNull = UnsafeCpuUInt32("$-1\r"u8);
+
+ public static readonly uint ArrayStreaming = UnsafeCpuUInt32("*?\r\n"u8);
+ public static readonly uint ArrayNull = UnsafeCpuUInt32("*-1\r"u8);
+
+ public static ushort UnsafeCpuUInt16(ReadOnlySpan bytes)
+ => Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(bytes));
+ public static ushort UnsafeCpuUInt16(ReadOnlySpan bytes, int offset)
+ => Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bytes), offset));
+ public static byte UnsafeCpuByte(ReadOnlySpan bytes, int offset)
+ => Unsafe.Add(ref MemoryMarshal.GetReference(bytes), offset);
+ public static uint UnsafeCpuUInt32(ReadOnlySpan bytes)
+ => Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(bytes));
+ public static uint UnsafeCpuUInt32(ReadOnlySpan bytes, int offset)
+ => Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bytes), offset));
+ public static ulong UnsafeCpuUInt64(ReadOnlySpan bytes)
+ => Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(bytes));
+ public static ushort CpuUInt16(ushort bigEndian)
+ => BitConverter.IsLittleEndian ? BinaryPrimitives.ReverseEndianness(bigEndian) : bigEndian;
+ public static uint CpuUInt32(uint bigEndian)
+ => BitConverter.IsLittleEndian ? BinaryPrimitives.ReverseEndianness(bigEndian) : bigEndian;
+ public static ulong CpuUInt64(ulong bigEndian)
+ => BitConverter.IsLittleEndian ? BinaryPrimitives.ReverseEndianness(bigEndian) : bigEndian;
+
+ public const int MaxRawBytesInt32 = 11, // "-2147483648"
+ MaxRawBytesInt64 = 20, // "-9223372036854775808",
+ MaxProtocolBytesIntegerInt32 = MaxRawBytesInt32 + 3, // ?X10X\r\n where ? could be $, *, etc - usually a length prefix
+ MaxProtocolBytesBulkStringIntegerInt32 = MaxRawBytesInt32 + 7, // $NN\r\nX11X\r\n for NN (length) 1-11
+ MaxProtocolBytesBulkStringIntegerInt64 = MaxRawBytesInt64 + 7, // $NN\r\nX20X\r\n for NN (length) 1-20
+ MaxRawBytesNumber = 20, // note G17 format, allow 20 for payload
+ MaxProtocolBytesBytesNumber = MaxRawBytesNumber + 7; // $NN\r\nX...X\r\n for NN (length) 1-20
+}
diff --git a/src/RESPite/Internal/RespOperationExtensions.cs b/src/RESPite/Internal/RespOperationExtensions.cs
new file mode 100644
index 000000000..0aedccc69
--- /dev/null
+++ b/src/RESPite/Internal/RespOperationExtensions.cs
@@ -0,0 +1,58 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace RESPite.Internal;
+
+internal static class RespOperationExtensions
+{
+#if PREVIEW_LANGVER
+ extension(in RespOperation operation)
+ {
+ // since this is valid...
+ public ref readonly RespOperation Self => ref operation;
+
+ // so is this (the types are layout-identical)
+ public ref readonly RespOperation Untyped => ref Unsafe.As, RespOperation>(
+ ref Unsafe.AsRef(in operation));
+ }
+#endif
+
+ // if we're recycling a buffer, we need to consider it trashable by other threads; for
+ // debug purposes, force this by overwriting with *****, aka the meaning of life
+ [Conditional("DEBUG")]
+ internal static void DebugScramble(this Span value)
+ => value.Fill(42);
+
+ [Conditional("DEBUG")]
+ internal static void DebugScramble(this Memory value)
+ => value.Span.Fill(42);
+
+ [Conditional("DEBUG")]
+ internal static void DebugScramble(this ReadOnlyMemory value)
+ => MemoryMarshal.AsMemory(value).Span.Fill(42);
+
+ [Conditional("DEBUG")]
+ internal static void DebugScramble(this ReadOnlySequence value)
+ {
+ if (value.IsSingleSegment)
+ {
+ value.First.DebugScramble();
+ }
+ else
+ {
+ foreach (var segment in value)
+ {
+ segment.DebugScramble();
+ }
+ }
+ }
+
+ [Conditional("DEBUG")]
+ internal static void DebugScramble(this byte[]? value)
+ {
+ if (value is not null)
+ value.AsSpan().Fill(42);
+ }
+}
diff --git a/src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs b/src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs
new file mode 100644
index 000000000..1b121fd8b
--- /dev/null
+++ b/src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs
@@ -0,0 +1,123 @@
+using System.Buffers;
+using RESPite.Messages;
+
+namespace RESPite.Internal;
+
+internal partial class BlockBufferSerializer
+{
+ internal static BlockBufferSerializer Create(bool retainChain = false) =>
+ new SynchronizedBlockBufferSerializer(retainChain);
+
+ ///
+ /// Used for things like .
+ ///
+ private sealed class SynchronizedBlockBufferSerializer(bool retainChain) : BlockBufferSerializer
+ {
+ private bool _discardDuringClear;
+
+ private protected override BlockBuffer? Buffer { get; set; } // simple per-instance auto-prop
+
+ /*
+ // use lock-based synchronization
+ public override ReadOnlyMemory Serialize(
+ RespCommandMap? commandMap,
+ ReadOnlySpan command,
+ in TRequest request,
+ IRespFormatter formatter)
+ {
+ bool haveLock = false;
+ try // note that "lock" unrolls to something very similar; we're not adding anything unusual here
+ {
+ // in reality, we *expect* people to not attempt to use batches concurrently, *and*
+ // we expect serialization to be very fast, but: out of an abundance of caution,
+ // add a timeout - just to avoid surprises (since people can write their own formatters)
+ Monitor.TryEnter(this, LockTimeout, ref haveLock);
+ if (!haveLock) ThrowTimeout();
+ return base.Serialize(commandMap, command, in request, formatter);
+ }
+ finally
+ {
+ if (haveLock) Monitor.Exit(this);
+ }
+
+ static void ThrowTimeout() => throw new TimeoutException(
+ "It took a long time to get access to the serialization-buffer. This is very odd - please " +
+ "ask on GitHub, but *as a guess*, you have a custom RESP formatter that is really slow *and* " +
+ "you are using concurrent access to a RESP batch / transaction.");
+ }
+ */
+
+ private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
+
+ private Segment? _head, _tail;
+
+ protected override bool ClaimSegment(ReadOnlyMemory segment)
+ {
+ if (retainChain & !_discardDuringClear)
+ {
+ if (_head is null)
+ {
+ _head = _tail = new Segment(segment);
+ }
+ else
+ {
+ _tail = new Segment(segment, _tail);
+ }
+
+ // note we don't need to increment the ref-count; because of this "true"
+ return true;
+ }
+
+ return false;
+ }
+
+ internal override ReadOnlySequence Flush()
+ {
+ if (_head is null)
+ {
+ // at worst, single-segment - we can skip the alloc
+ return new(BlockBuffer.RetainCurrent(this));
+ }
+
+ // otherwise, flush everything *keeping the chain*
+ ClearWithDiscard(discard: false);
+ ReadOnlySequence seq = new(_head, 0, _tail!, _tail!.Length);
+ _head = _tail = null;
+ return seq;
+ }
+
+ public override void Clear()
+ {
+ ClearWithDiscard(discard: true);
+ _head = _tail = null;
+ }
+
+ private void ClearWithDiscard(bool discard)
+ {
+ try
+ {
+ _discardDuringClear = discard;
+ base.Clear();
+ }
+ finally
+ {
+ _discardDuringClear = false;
+ }
+ }
+
+ private sealed class Segment : ReadOnlySequenceSegment
+ {
+ public Segment(ReadOnlyMemory memory, Segment? previous = null)
+ {
+ Memory = memory;
+ if (previous is not null)
+ {
+ previous.Next = this;
+ RunningIndex = previous.RunningIndex + previous.Length;
+ }
+ }
+
+ public int Length => Memory.Length;
+ }
+ }
+}
diff --git a/src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs b/src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs
new file mode 100644
index 000000000..1c1895ff4
--- /dev/null
+++ b/src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs
@@ -0,0 +1,21 @@
+namespace RESPite.Internal;
+
+internal partial class BlockBufferSerializer
+{
+ internal static BlockBufferSerializer Shared => ThreadLocalBlockBufferSerializer.Instance;
+ private sealed class ThreadLocalBlockBufferSerializer : BlockBufferSerializer
+ {
+ private ThreadLocalBlockBufferSerializer() { }
+ public static readonly ThreadLocalBlockBufferSerializer Instance = new();
+
+ [ThreadStatic]
+ // side-step concurrency using per-thread semantics
+ private static BlockBuffer? _perTreadBuffer;
+
+ private protected override BlockBuffer? Buffer
+ {
+ get => _perTreadBuffer;
+ set => _perTreadBuffer = value;
+ }
+ }
+}
diff --git a/src/RESPite/Messages/RespAttributeReader.cs b/src/RESPite/Messages/RespAttributeReader.cs
new file mode 100644
index 000000000..9d61802c0
--- /dev/null
+++ b/src/RESPite/Messages/RespAttributeReader.cs
@@ -0,0 +1,71 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace RESPite.Messages;
+
+///
+/// Allows attribute data to be parsed conveniently.
+///
+/// The type of data represented by this reader.
+[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)]
+public abstract class RespAttributeReader
+{
+ ///
+ /// Parse a group of attributes.
+ ///
+ public virtual void Read(ref RespReader reader, ref T value)
+ {
+ reader.Demand(RespPrefix.Attribute);
+ _ = ReadKeyValuePairs(ref reader, ref value);
+ }
+
+ ///
+ /// Parse an aggregate as a set of key/value pairs.
+ ///
+ /// The number of pairs successfully processed.
+ protected virtual int ReadKeyValuePairs(ref RespReader reader, ref T value)
+ {
+ var iterator = reader.AggregateChildren();
+
+ byte[] pooledBuffer = [];
+ Span localBuffer = stackalloc byte[128];
+ int count = 0;
+ while (iterator.MoveNext())
+ {
+ if (iterator.Value.IsScalar)
+ {
+ var key = iterator.Value.Buffer(ref pooledBuffer, localBuffer);
+
+ if (iterator.MoveNext())
+ {
+ if (ReadKeyValuePair(key, ref iterator.Value, ref value))
+ {
+ count++;
+ }
+ }
+ else
+ {
+ break; // no matching value for this key
+ }
+ }
+ else
+ {
+ if (iterator.MoveNext())
+ {
+ // we won't try to handle aggregate keys; skip the value
+ }
+ else
+ {
+ break; // no matching value for this key
+ }
+ }
+ }
+ iterator.MovePast(out reader);
+ return count;
+ }
+
+ ///
+ /// Parse an individual key/value pair.
+ ///
+ /// True if the pair was successfully processed.
+ public virtual bool ReadKeyValuePair(scoped ReadOnlySpan key, ref RespReader reader, ref T value) => false;
+}
diff --git a/src/RESPite/Messages/RespFrameScanner.cs b/src/RESPite/Messages/RespFrameScanner.cs
new file mode 100644
index 000000000..da4f9ca63
--- /dev/null
+++ b/src/RESPite/Messages/RespFrameScanner.cs
@@ -0,0 +1,194 @@
+using System.Buffers;
+using System.Diagnostics.CodeAnalysis;
+using static RESPite.Internal.RespConstants;
+namespace RESPite.Messages;
+
+///
+/// Scans RESP frames.
+/// .
+[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)]
+public sealed class RespFrameScanner // : IFrameSacanner, IFrameValidator
+{
+ ///
+ /// Gets a frame scanner for RESP2 request/response connections, or RESP3 connections.
+ ///
+ public static RespFrameScanner Default { get; } = new(false);
+
+ ///
+ /// Gets a frame scanner that identifies RESP2 pub/sub messages.
+ ///
+ public static RespFrameScanner Subscription { get; } = new(true);
+ private RespFrameScanner(bool pubsub) => _pubsub = pubsub;
+ private readonly bool _pubsub;
+
+ private static readonly uint FastNull = UnsafeCpuUInt32("_\r\n\0"u8),
+ SingleCharScalarMask = CpuUInt32(0xFF00FFFF),
+ SingleDigitInteger = UnsafeCpuUInt32(":\0\r\n"u8),
+ EitherBoolean = UnsafeCpuUInt32("#\0\r\n"u8),
+ FirstThree = CpuUInt32(0xFFFFFF00);
+ private static readonly ulong OK = UnsafeCpuUInt64("+OK\r\n\0\0\0"u8),
+ PONG = UnsafeCpuUInt64("+PONG\r\n\0"u8),
+ DoubleCharScalarMask = CpuUInt64(0xFF0000FFFF000000),
+ DoubleDigitInteger = UnsafeCpuUInt64(":\0\0\r\n"u8),
+ FirstFive = CpuUInt64(0xFFFFFFFFFF000000),
+ FirstSeven = CpuUInt64(0xFFFFFFFFFFFFFF00);
+
+ private const OperationStatus UseReader = (OperationStatus)(-1);
+ private static OperationStatus TryFastRead(ReadOnlySpan data, ref RespScanState info)
+ {
+ // use silly math to detect the most common short patterns without needing
+ // to access a reader, or use indexof etc; handles:
+ // +OK\r\n
+ // +PONG\r\n
+ // :N\r\n for any single-digit N (integer)
+ // :NN\r\n for any double-digit N (integer)
+ // #N\r\n for any single-digit N (boolean)
+ // _\r\n (null)
+ uint hi, lo;
+ switch (data.Length)
+ {
+ case 0:
+ case 1:
+ case 2:
+ return OperationStatus.NeedMoreData;
+ case 3:
+ hi = (((uint)UnsafeCpuUInt16(data)) << 16) | (((uint)UnsafeCpuByte(data, 2)) << 8);
+ break;
+ default:
+ hi = UnsafeCpuUInt32(data);
+ break;
+ }
+ if ((hi & FirstThree) == FastNull)
+ {
+ info.SetComplete(3, RespPrefix.Null);
+ return OperationStatus.Done;
+ }
+
+ var masked = hi & SingleCharScalarMask;
+ if (masked == SingleDigitInteger)
+ {
+ info.SetComplete(4, RespPrefix.Integer);
+ return OperationStatus.Done;
+ }
+ else if (masked == EitherBoolean)
+ {
+ info.SetComplete(4, RespPrefix.Boolean);
+ return OperationStatus.Done;
+ }
+
+ switch (data.Length)
+ {
+ case 3:
+ return OperationStatus.NeedMoreData;
+ case 4:
+ return UseReader;
+ case 5:
+ lo = ((uint)data[4]) << 24;
+ break;
+ case 6:
+ lo = ((uint)UnsafeCpuUInt16(data, 4)) << 16;
+ break;
+ case 7:
+ lo = ((uint)UnsafeCpuUInt16(data, 4)) << 16 | ((uint)UnsafeCpuByte(data, 6)) << 8;
+ break;
+ default:
+ lo = UnsafeCpuUInt32(data, 4);
+ break;
+ }
+ var u64 = BitConverter.IsLittleEndian ? ((((ulong)lo) << 32) | hi) : ((((ulong)hi) << 32) | lo);
+ if (((u64 & FirstFive) == OK) | ((u64 & DoubleCharScalarMask) == DoubleDigitInteger))
+ {
+ info.SetComplete(5, RespPrefix.SimpleString);
+ return OperationStatus.Done;
+ }
+ if ((u64 & FirstSeven) == PONG)
+ {
+ info.SetComplete(7, RespPrefix.SimpleString);
+ return OperationStatus.Done;
+ }
+ return UseReader;
+ }
+
+ ///
+ /// Attempt to read more data as part of the current frame.
+ ///
+ public OperationStatus TryRead(ref RespScanState state, in ReadOnlySequence data)
+ {
+ if (!_pubsub & state.TotalBytes == 0 & data.IsSingleSegment)
+ {
+#if NETCOREAPP3_1_OR_GREATER
+ var status = TryFastRead(data.FirstSpan, ref state);
+#else
+ var status = TryFastRead(data.First.Span, ref state);
+#endif
+ if (status != UseReader) return status;
+ }
+
+ return TryReadViaReader(ref state, in data);
+
+ static OperationStatus TryReadViaReader(ref RespScanState state, in ReadOnlySequence data)
+ {
+ var reader = new RespReader(in data);
+ var complete = state.TryRead(ref reader, out var consumed);
+ if (complete)
+ {
+ return OperationStatus.Done;
+ }
+ return OperationStatus.NeedMoreData;
+ }
+ }
+
+ ///
+ /// Attempt to read more data as part of the current frame.
+ ///
+ public OperationStatus TryRead(ref RespScanState state, ReadOnlySpan data)
+ {
+ if (!_pubsub & state.TotalBytes == 0)
+ {
+#if NETCOREAPP3_1_OR_GREATER
+ var status = TryFastRead(data, ref state);
+#else
+ var status = TryFastRead(data, ref state);
+#endif
+ if (status != UseReader) return status;
+ }
+
+ return TryReadViaReader(ref state, data);
+
+ static OperationStatus TryReadViaReader(ref RespScanState state, ReadOnlySpan data)
+ {
+ var reader = new RespReader(data);
+ var complete = state.TryRead(ref reader, out var consumed);
+ if (complete)
+ {
+ return OperationStatus.Done;
+ }
+ return OperationStatus.NeedMoreData;
+ }
+ }
+
+ ///
+ /// Validate that the supplied message is a valid RESP request, specifically: that it contains a single
+ /// top-level array payload with bulk-string elements, the first of which is non-empty (the command).
+ ///
+ public void ValidateRequest(in ReadOnlySequence message)
+ {
+ if (message.IsEmpty) Throw("Empty RESP frame");
+ RespReader reader = new(in message);
+ reader.MoveNext(RespPrefix.Array);
+ reader.DemandNotNull();
+ if (reader.IsStreaming) Throw("Streaming is not supported in this context");
+ var count = reader.AggregateLength();
+ for (int i = 0; i < count; i++)
+ {
+ reader.MoveNext(RespPrefix.BulkString);
+ reader.DemandNotNull();
+ if (reader.IsStreaming) Throw("Streaming is not supported in this context");
+
+ if (i == 0 && reader.ScalarIsEmpty()) Throw("command must be non-empty");
+ }
+ reader.DemandEnd();
+
+ static void Throw(string message) => throw new InvalidOperationException(message);
+ }
+}
diff --git a/src/RESPite/Messages/RespPrefix.cs b/src/RESPite/Messages/RespPrefix.cs
new file mode 100644
index 000000000..d58749120
--- /dev/null
+++ b/src/RESPite/Messages/RespPrefix.cs
@@ -0,0 +1,100 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace RESPite.Messages;
+
+///
+/// RESP protocol prefix.
+///
+[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)]
+public enum RespPrefix : byte
+{
+ ///
+ /// Invalid.
+ ///
+ None = 0,
+
+ ///
+ /// Simple strings: +OK\r\n.
+ ///
+ SimpleString = (byte)'+',
+
+ ///
+ /// Simple errors: -ERR message\r\n.
+ ///
+ SimpleError = (byte)'-',
+
+ ///
+ /// Integers: :123\r\n.
+ ///
+ Integer = (byte)':',
+
+ ///
+ /// String with support for binary data: $7\r\nmessage\r\n.
+ ///
+ BulkString = (byte)'$',
+
+ ///
+ /// Multiple inner messages: *1\r\n+message\r\n.
+ ///
+ Array = (byte)'*',
+
+ ///
+ /// Null strings/arrays: _\r\n.
+ ///
+ Null = (byte)'_',
+
+ ///
+ /// Boolean values: #T\r\n.
+ ///
+ Boolean = (byte)'#',
+
+ ///
+ /// Floating-point number: ,123.45\r\n.
+ ///
+ Double = (byte)',',
+
+ ///
+ /// Large integer number: (12...89\r\n.
+ ///
+ BigInteger = (byte)'(',
+
+ ///
+ /// Error with support for binary data: !7\r\nmessage\r\n.
+ ///
+ BulkError = (byte)'!',
+
+ ///
+ /// String that should be interpreted verbatim: =11\r\ntxt:message\r\n.
+ ///
+ VerbatimString = (byte)'=',
+
+ ///
+ /// Multiple sub-items that represent a map.
+ ///
+ Map = (byte)'%',
+
+ ///
+ /// Multiple sub-items that represent a set.
+ ///
+ Set = (byte)'~',
+
+ ///
+ /// Out-of band messages.
+ ///
+ Push = (byte)'>',
+
+ ///
+ /// Continuation of streaming scalar values.
+ ///
+ StreamContinuation = (byte)';',
+
+ ///
+ /// End sentinel for streaming aggregate values.
+ ///
+ StreamTerminator = (byte)'.',
+
+ ///
+ /// Metadata about the next element.
+ ///
+ Attribute = (byte)'|',
+}
diff --git a/src/RESPite/Messages/RespReader.AggregateEnumerator.cs b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs
new file mode 100644
index 000000000..cd9892b68
--- /dev/null
+++ b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs
@@ -0,0 +1,242 @@
+using System.Collections;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+
+#pragma warning disable IDE0079 // Remove unnecessary suppression
+#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct
+#pragma warning restore IDE0079 // Remove unnecessary suppression
+
+namespace RESPite.Messages;
+
+public ref partial struct RespReader
+{
+ ///
+ /// Reads the sub-elements associated with an aggregate value.
+ ///
+ public readonly AggregateEnumerator AggregateChildren() => new(in this);
+
+ ///
+ /// Reads the sub-elements associated with an aggregate value.
+ ///
+ public ref struct AggregateEnumerator
+ {
+ // Note that _reader is the overall reader that can see outside this aggregate, as opposed
+ // to Current which is the sub-tree of the current element *only*
+ private RespReader _reader;
+ private int _remaining;
+
+ ///
+ /// Create a new enumerator for the specified .
+ ///
+ /// The reader containing the data for this operation.
+ public AggregateEnumerator(scoped in RespReader reader)
+ {
+ reader.DemandAggregate();
+ _remaining = reader.IsStreaming ? -1 : reader._length;
+ _reader = reader;
+ Value = default;
+ }
+
+ ///
+ public readonly AggregateEnumerator GetEnumerator() => this;
+
+ ///
+ [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
+#if DEBUG
+#if NET6_0 || NET8_0
+ [Experimental("SERDBG")]
+#else
+ [Experimental("SERDBG", Message = $"Prefer {nameof(Value)}")]
+#endif
+#endif
+ public RespReader Current => Value;
+
+ ///
+ /// Gets the current element associated with this reader.
+ ///
+ public RespReader Value; // intentionally a field, because of ref-semantics
+
+ ///
+ /// Move to the next child if possible, and move the child element into the next node.
+ ///
+ public bool MoveNext(RespPrefix prefix)
+ {
+ bool result = MoveNextRaw();
+ if (result)
+ {
+ Value.MoveNext(prefix);
+ }
+ return result;
+ }
+
+ ///
+ /// Move to the next child if possible, and move the child element into the next node.
+ ///
+ /// The type of data represented by this reader.
+ public bool MoveNext(RespPrefix prefix, RespAttributeReader respAttributeReader, ref T attributes)
+ {
+ bool result = MoveNextRaw(respAttributeReader, ref attributes);
+ if (result)
+ {
+ Value.MoveNext(prefix);
+ }
+ return result;
+ }
+
+ ///
+ /// Move to the next child and leave the reader *ahead of* the first element,
+ /// allowing us to read attribute data.
+ ///
+ /// If you are not consuming attribute data, is preferred.
+ public bool MoveNextRaw()
+ {
+ object? attributes = null;
+ return MoveNextCore(null, ref attributes);
+ }
+
+ ///
+ /// Move to the next child and move into the first element (skipping attributes etc), leaving it ready to consume.
+ ///
+ public bool MoveNext()
+ {
+ object? attributes = null;
+ if (MoveNextCore(null, ref attributes))
+ {
+ Value.MoveNext();
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Move to the next child (capturing attribute data) and leave the reader *ahead of* the first element,
+ /// allowing us to also read attribute data of the child.
+ ///
+ /// The type of attribute data represented by this reader.
+ /// If you are not consuming attribute data, is preferred.
+ public bool MoveNextRaw(RespAttributeReader respAttributeReader, ref T attributes)
+ => MoveNextCore(respAttributeReader, ref attributes);
+
+ /// >
+ private bool MoveNextCore(RespAttributeReader? attributeReader, ref T attributes)
+ {
+ if (_remaining == 0)
+ {
+ Value = default;
+ return false;
+ }
+
+ // in order to provide access to attributes etc, we want Current to be positioned
+ // *before* the next element; for that, we'll take a snapshot before we read
+ _reader.MovePastCurrent();
+ var snapshot = _reader.Clone();
+
+ if (attributeReader is null)
+ {
+ _reader.MoveNext();
+ }
+ else
+ {
+ _reader.MoveNext(attributeReader, ref attributes);
+ }
+ if (_remaining > 0)
+ {
+ // non-streaming, decrement
+ _remaining--;
+ }
+ else if (_reader.Prefix == RespPrefix.StreamTerminator)
+ {
+ // end of streaming aggregate
+ _remaining = 0;
+ Value = default;
+ return false;
+ }
+
+ // move past that sub-tree and trim the "snapshot" state, giving
+ // us a scoped reader that is *just* that sub-tree
+ _reader.SkipChildren();
+ snapshot.TrimToTotal(_reader.BytesConsumed);
+
+ Value = snapshot;
+ return true;
+ }
+
+ ///
+ /// Move to the end of this aggregate and export the state of the .
+ ///
+ /// The reader positioned at the end of the data; this is commonly
+ /// used to update a tree reader, to get to the next data after the aggregate.
+ public void MovePast(out RespReader reader)
+ {
+ while (MoveNextRaw()) { }
+ reader = _reader;
+ }
+
+ ///
+ /// Moves to the next element, and moves into that element (skipping attributes etc), leaving it ready to consume.
+ ///
+ public void DemandNext()
+ {
+ if (!MoveNext()) ThrowEof();
+ }
+
+ public T ReadOne(Projection projection)
+ {
+ DemandNext();
+ return projection(ref Value);
+ }
+
+ public void FillAll(scoped Span target, Projection projection)
+ {
+ for (int i = 0; i < target.Length; i++)
+ {
+ DemandNext();
+ target[i] = projection(ref Value);
+ }
+ }
+
+ public void FillAll(
+ scoped Span target,
+ Projection first,
+ Projection second,
+ Func combine)
+ {
+ for (int i = 0; i < target.Length; i++)
+ {
+ DemandNext();
+
+ var x = first(ref Value);
+
+ DemandNext();
+
+ var y = second(ref Value);
+ target[i] = combine(x, y);
+ }
+ }
+ }
+
+ internal void TrimToTotal(long length) => TrimToRemaining(length - BytesConsumed);
+
+ internal void TrimToRemaining(long bytes)
+ {
+ if (_prefix != RespPrefix.None || bytes < 0) Throw();
+
+ var current = CurrentAvailable;
+ if (bytes <= current)
+ {
+ UnsafeTrimCurrentBy(current - (int)bytes);
+ _remainingTailLength = 0;
+ return;
+ }
+
+ bytes -= current;
+ if (bytes <= _remainingTailLength)
+ {
+ _remainingTailLength = bytes;
+ return;
+ }
+
+ Throw();
+ static void Throw() => throw new ArgumentOutOfRangeException(nameof(bytes));
+ }
+}
diff --git a/src/RESPite/Messages/RespReader.Debug.cs b/src/RESPite/Messages/RespReader.Debug.cs
new file mode 100644
index 000000000..3f471bbd1
--- /dev/null
+++ b/src/RESPite/Messages/RespReader.Debug.cs
@@ -0,0 +1,33 @@
+using System.Diagnostics;
+
+#pragma warning disable IDE0079 // Remove unnecessary suppression
+#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct
+#pragma warning restore IDE0079 // Remove unnecessary suppression
+
+namespace RESPite.Messages;
+
+[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
+public ref partial struct RespReader
+{
+ internal bool DebugEquals(in RespReader other)
+ => _prefix == other._prefix
+ && _length == other._length
+ && _flags == other._flags
+ && _bufferIndex == other._bufferIndex
+ && _positionBase == other._positionBase
+ && _remainingTailLength == other._remainingTailLength;
+
+ internal new string ToString() => $"{Prefix} ({_flags}); length {_length}, {TotalAvailable} remaining";
+
+ internal void DebugReset()
+ {
+ _bufferIndex = 0;
+ _length = 0;
+ _flags = 0;
+ _prefix = RespPrefix.None;
+ }
+
+#if DEBUG
+ internal bool VectorizeDisabled { get; set; }
+#endif
+}
diff --git a/src/RESPite/Messages/RespReader.ScalarEnumerator.cs b/src/RESPite/Messages/RespReader.ScalarEnumerator.cs
new file mode 100644
index 000000000..9e8ffbe70
--- /dev/null
+++ b/src/RESPite/Messages/RespReader.ScalarEnumerator.cs
@@ -0,0 +1,105 @@
+using System.Buffers;
+using System.Collections;
+
+#pragma warning disable IDE0079 // Remove unnecessary suppression
+#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct
+#pragma warning restore IDE0079 // Remove unnecessary suppression
+
+namespace RESPite.Messages;
+
+public ref partial struct RespReader
+{
+ ///
+ /// Gets the chunks associated with a scalar value.
+ ///
+ public readonly ScalarEnumerator ScalarChunks() => new(in this);
+
+ ///
+ /// Allows enumeration of chunks in a scalar value; this includes simple values
+ /// that span multiple segments, and streaming
+ /// scalar RESP values.
+ ///
+ public ref struct ScalarEnumerator
+ {
+ ///
+ public readonly ScalarEnumerator GetEnumerator() => this;
+
+ private RespReader _reader;
+
+ private ReadOnlySpan _current;
+ private ReadOnlySequenceSegment? _tail;
+ private int _offset, _remaining;
+
+ ///
+ /// Create a new enumerator for the specified .
+ ///
+ /// The reader containing the data for this operation.
+ public ScalarEnumerator(scoped in RespReader reader)
+ {
+ reader.DemandScalar();
+ _reader = reader;
+ InitSegment();
+ }
+
+ private void InitSegment()
+ {
+ _current = _reader.CurrentSpan();
+ _tail = _reader._tail;
+ _offset = CurrentLength = 0;
+ _remaining = _reader._length;
+ if (_reader.TotalAvailable < _remaining) ThrowEof();
+ }
+
+ ///
+ public bool MoveNext()
+ {
+ while (true) // for each streaming element
+ {
+ _offset += CurrentLength;
+ while (_remaining > 0) // for each span in the current element
+ {
+ // look in the active span
+ var take = Math.Min(_remaining, _current.Length - _offset);
+ if (take > 0) // more in the current chunk
+ {
+ _remaining -= take;
+ CurrentLength = take;
+ return true;
+ }
+
+ // otherwise, we expect more tail data
+ if (_tail is null) ThrowEof();
+
+ _current = _tail.Memory.Span;
+ _offset = 0;
+ _tail = _tail.Next;
+ }
+
+ if (!_reader.MoveNextStreamingScalar()) break;
+ InitSegment();
+ }
+
+ CurrentLength = 0;
+ return false;
+ }
+
+ ///
+ public readonly ReadOnlySpan Current => _current.Slice(_offset, CurrentLength);
+
+ ///
+ /// Gets the or .
+ ///
+ public int CurrentLength { readonly get; private set; }
+
+ ///
+ /// Move to the end of this aggregate and export the state of the .
+ ///
+ /// The reader positioned at the end of the data; this is commonly
+ /// used to update a tree reader, to get to the next data after the aggregate.
+ public void MovePast(out RespReader reader)
+ {
+ while (MoveNext()) { }
+ reader = _reader;
+ }
+ }
+}
diff --git a/src/RESPite/Messages/RespReader.Span.cs b/src/RESPite/Messages/RespReader.Span.cs
new file mode 100644
index 000000000..fd3870ef3
--- /dev/null
+++ b/src/RESPite/Messages/RespReader.Span.cs
@@ -0,0 +1,84 @@
+#define USE_UNSAFE_SPAN
+
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+#pragma warning disable IDE0079 // Remove unnecessary suppression
+#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct
+#pragma warning restore IDE0079 // Remove unnecessary suppression
+
+namespace RESPite.Messages;
+
+/*
+ How we actually implement the underlying buffer depends on the capabilities of the runtime.
+ */
+
+#if NET7_0_OR_GREATER && USE_UNSAFE_SPAN
+
+public ref partial struct RespReader
+{
+ // intent: avoid lots of slicing by dealing with everything manually, and accepting the "don't get it wrong" rule
+ private ref byte _bufferRoot;
+ private int _bufferLength;
+
+ private partial void UnsafeTrimCurrentBy(int count)
+ {
+ Debug.Assert(count >= 0 && count <= _bufferLength, "Unsafe trim length");
+ _bufferLength -= count;
+ }
+
+ private readonly partial ref byte UnsafeCurrent
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => ref Unsafe.Add(ref _bufferRoot, _bufferIndex);
+ }
+
+ private readonly partial int CurrentLength
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => _bufferLength;
+ }
+
+ private readonly partial ReadOnlySpan CurrentSpan() => MemoryMarshal.CreateReadOnlySpan(
+ ref UnsafeCurrent, CurrentAvailable);
+
+ private readonly partial ReadOnlySpan UnsafePastPrefix() => MemoryMarshal.CreateReadOnlySpan(
+ ref Unsafe.Add(ref _bufferRoot, _bufferIndex + 1),
+ _bufferLength - (_bufferIndex + 1));
+
+ private partial void SetCurrent(ReadOnlySpan value)
+ {
+ _bufferRoot = ref MemoryMarshal.GetReference(value);
+ _bufferLength = value.Length;
+ }
+}
+#else
+public ref partial struct RespReader // much more conservative - uses slices etc
+{
+ private ReadOnlySpan _buffer;
+
+ private partial void UnsafeTrimCurrentBy(int count)
+ {
+ _buffer = _buffer.Slice(0, _buffer.Length - count);
+ }
+
+ private readonly partial ref byte UnsafeCurrent
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => ref Unsafe.AsRef(in _buffer[_bufferIndex]); // hack around CS8333
+ }
+
+ private readonly partial int CurrentLength
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => _buffer.Length;
+ }
+
+ private readonly partial ReadOnlySpan UnsafePastPrefix() => _buffer.Slice(_bufferIndex + 1);
+
+ private readonly partial ReadOnlySpan CurrentSpan() => _buffer.Slice(_bufferIndex);
+
+ private partial void SetCurrent(ReadOnlySpan value) => _buffer = value;
+}
+#endif
diff --git a/src/RESPite/Messages/RespReader.Utils.cs b/src/RESPite/Messages/RespReader.Utils.cs
new file mode 100644
index 000000000..da6b641d8
--- /dev/null
+++ b/src/RESPite/Messages/RespReader.Utils.cs
@@ -0,0 +1,317 @@
+using System.Buffers.Text;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using RESPite.Internal;
+
+#pragma warning disable IDE0079 // Remove unnecessary suppression
+#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct
+#pragma warning restore IDE0079 // Remove unnecessary suppression
+
+namespace RESPite.Messages;
+
+public ref partial struct RespReader
+{
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void UnsafeAssertClLf(int offset) => UnsafeAssertClLf(ref UnsafeCurrent, offset);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void UnsafeAssertClLf(scoped ref byte source, int offset)
+ {
+ if (Unsafe.ReadUnaligned(ref Unsafe.Add(ref source, offset)) != RespConstants.CrLfUInt16)
+ {
+ ThrowProtocolFailure("Expected CR/LF");
+ }
+ }
+
+ private enum LengthPrefixResult
+ {
+ NeedMoreData,
+ Length,
+ Null,
+ Streaming,
+ }
+
+ ///
+ /// Asserts that the current element is a scalar type.
+ ///
+ public readonly void DemandScalar()
+ {
+ if (!IsScalar) Throw(Prefix);
+ static void Throw(RespPrefix prefix) => throw new InvalidOperationException($"This operation requires a scalar element, got {prefix}");
+ }
+
+ ///
+ /// Asserts that the current element is a scalar type.
+ ///
+ public readonly void DemandAggregate()
+ {
+ if (!IsAggregate) Throw(Prefix);
+ static void Throw(RespPrefix prefix) => throw new InvalidOperationException($"This operation requires an aggregate element, got {prefix}");
+ }
+
+ private static LengthPrefixResult TryReadLengthPrefix(ReadOnlySpan bytes, out int value, out int byteCount)
+ {
+ var end = bytes.IndexOf(RespConstants.CrlfBytes);
+ if (end < 0)
+ {
+ byteCount = value = 0;
+ if (bytes.Length >= RespConstants.MaxRawBytesInt32 + 2)
+ {
+ ThrowProtocolFailure("Unterminated or over-length integer"); // should have failed; report failure to prevent infinite loop
+ }
+ return LengthPrefixResult.NeedMoreData;
+ }
+ byteCount = end + 2;
+ switch (end)
+ {
+ case 0:
+ ThrowProtocolFailure("Length prefix expected");
+ goto case default; // not reached, just satisfying definite assignment
+ case 1 when bytes[0] == (byte)'?':
+ value = 0;
+ return LengthPrefixResult.Streaming;
+ default:
+ if (end > RespConstants.MaxRawBytesInt32 || !(Utf8Parser.TryParse(bytes, out value, out var consumed) && consumed == end))
+ {
+ ThrowProtocolFailure("Unable to parse integer");
+ value = 0;
+ }
+ if (value < 0)
+ {
+ if (value == -1)
+ {
+ value = 0;
+ return LengthPrefixResult.Null;
+ }
+ ThrowProtocolFailure("Invalid negative length prefix");
+ }
+ return LengthPrefixResult.Length;
+ }
+ }
+
+ private readonly RespReader Clone() => this; // useful for performing streaming operations without moving the primary
+
+ [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
+ private static void ThrowProtocolFailure(string message)
+ => throw new InvalidOperationException("RESP protocol failure: " + message); // protocol exception?
+
+ [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
+ internal static void ThrowEof() => throw new EndOfStreamException();
+
+ [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
+ private static void ThrowFormatException() => throw new FormatException();
+
+ private int RawTryReadByte()
+ {
+ if (_bufferIndex < CurrentLength || TryMoveToNextSegment())
+ {
+ var result = UnsafeCurrent;
+ _bufferIndex++;
+ return result;
+ }
+ return -1;
+ }
+
+ private int RawPeekByte()
+ {
+ return (CurrentLength < _bufferIndex || TryMoveToNextSegment()) ? UnsafeCurrent : -1;
+ }
+
+ private bool RawAssertCrLf()
+ {
+ if (CurrentAvailable >= 2)
+ {
+ UnsafeAssertClLf(0);
+ _bufferIndex += 2;
+ return true;
+ }
+ else
+ {
+ int next = RawTryReadByte();
+ if (next < 0) return false;
+ if (next == '\r')
+ {
+ next = RawTryReadByte();
+ if (next < 0) return false;
+ if (next == '\n') return true;
+ }
+ ThrowProtocolFailure("Expected CR/LF");
+ return false;
+ }
+ }
+
+ private LengthPrefixResult RawTryReadLengthPrefix()
+ {
+ _length = 0;
+ if (!RawTryFindCrLf(out int end))
+ {
+ if (TotalAvailable >= RespConstants.MaxRawBytesInt32 + 2)
+ {
+ ThrowProtocolFailure("Unterminated or over-length integer"); // should have failed; report failure to prevent infinite loop
+ }
+ return LengthPrefixResult.NeedMoreData;
+ }
+
+ switch (end)
+ {
+ case 0:
+ ThrowProtocolFailure("Length prefix expected");
+ goto case default; // not reached, just satisfying definite assignment
+ case 1:
+ var b = (byte)RawTryReadByte();
+ RawAssertCrLf();
+ if (b == '?')
+ {
+ return LengthPrefixResult.Streaming;
+ }
+ else
+ {
+ _length = ParseSingleDigit(b);
+ return LengthPrefixResult.Length;
+ }
+ default:
+ if (end > RespConstants.MaxRawBytesInt32)
+ {
+ ThrowProtocolFailure("Unable to parse integer");
+ }
+ Span bytes = stackalloc byte[end];
+ RawFillBytes(bytes);
+ RawAssertCrLf();
+ if (!(Utf8Parser.TryParse(bytes, out _length, out var consumed) && consumed == end))
+ {
+ ThrowProtocolFailure("Unable to parse integer");
+ }
+
+ if (_length < 0)
+ {
+ if (_length == -1)
+ {
+ _length = 0;
+ return LengthPrefixResult.Null;
+ }
+ ThrowProtocolFailure("Invalid negative length prefix");
+ }
+
+ return LengthPrefixResult.Length;
+ }
+ }
+
+ private void RawFillBytes(scoped Span target)
+ {
+ do
+ {
+ var current = CurrentSpan();
+ if (current.Length >= target.Length)
+ {
+ // more than enough, need to trim
+ current.Slice(0, target.Length).CopyTo(target);
+ _bufferIndex += target.Length;
+ return; // we're done
+ }
+ else
+ {
+ // take what we can
+ current.CopyTo(target);
+ target = target.Slice(current.Length);
+ // we could move _bufferIndex here, but we're about to trash that in TryMoveToNextSegment
+ }
+ }
+ while (TryMoveToNextSegment());
+ ThrowEof();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int ParseSingleDigit(byte value)
+ {
+ return value switch
+ {
+ (byte)'0' or (byte)'1' or (byte)'2' or (byte)'3' or (byte)'4' or (byte)'5' or (byte)'6' or (byte)'7' or (byte)'8' or (byte)'9' => value - (byte)'0',
+ _ => Invalid(value),
+ };
+
+ [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
+ static int Invalid(byte value) => throw new FormatException($"Unable to parse integer: '{(char)value}'");
+ }
+
+ private readonly bool RawTryAssertInlineScalarPayloadCrLf()
+ {
+ Debug.Assert(IsInlineScalar, "should be inline scalar");
+
+ var reader = Clone();
+ var len = reader._length;
+ if (len == 0) return reader.RawAssertCrLf();
+
+ do
+ {
+ var current = reader.CurrentSpan();
+ if (current.Length >= len)
+ {
+ reader._bufferIndex += len;
+ return reader.RawAssertCrLf(); // we're done
+ }
+ else
+ {
+ // take what we can
+ len -= current.Length;
+ // we could move _bufferIndex here, but we're about to trash that in TryMoveToNextSegment
+ }
+ }
+ while (reader.TryMoveToNextSegment());
+ return false; // EOF
+ }
+
+ private readonly bool RawTryFindCrLf(out int length)
+ {
+ length = 0;
+ RespReader reader = Clone();
+ do
+ {
+ var span = reader.CurrentSpan();
+ var index = span.IndexOf((byte)'\r');
+ if (index >= 0)
+ {
+ checked
+ {
+ length += index;
+ }
+ // move past the CR and assert the LF
+ reader._bufferIndex += index + 1;
+ var next = reader.RawTryReadByte();
+ if (next < 0) break; // we don't know
+ if (next != '\n') ThrowProtocolFailure("CR/LF expected");
+
+ return true;
+ }
+ checked
+ {
+ length += span.Length;
+ }
+ }
+ while (reader.TryMoveToNextSegment());
+ length = 0;
+ return false;
+ }
+
+ private string GetDebuggerDisplay()
+ {
+ return ToString();
+ }
+
+ internal readonly int GetInitialScanCount(out ushort streamingAggregateDepth)
+ {
+ // this is *similar* to GetDelta, but: without any discount for attributes
+ switch (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming))
+ {
+ case RespFlags.IsAggregate:
+ streamingAggregateDepth = 0;
+ return _length - 1;
+ case RespFlags.IsAggregate | RespFlags.IsStreaming:
+ streamingAggregateDepth = 1;
+ return 0;
+ default:
+ streamingAggregateDepth = 0;
+ return -1;
+ }
+ }
+}
diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs
new file mode 100644
index 000000000..0b87efba2
--- /dev/null
+++ b/src/RESPite/Messages/RespReader.cs
@@ -0,0 +1,1831 @@
+using System.Buffers;
+using System.Buffers.Text;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text;
+using RESPite.Internal;
+
+#if NETCOREAPP3_0_OR_GREATER
+using System.Runtime.Intrinsics;
+using System.Runtime.Intrinsics.X86;
+#endif
+
+#pragma warning disable IDE0079 // Remove unnecessary suppression
+#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct
+#pragma warning restore IDE0079 // Remove unnecessary suppression
+
+namespace RESPite.Messages;
+
+///
+/// Provides low level RESP parsing functionality.
+///
+[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)]
+public ref partial struct RespReader
+{
+ [Flags]
+ private enum RespFlags : byte
+ {
+ None = 0,
+ IsScalar = 1 << 0, // simple strings, bulk strings, etc
+ IsAggregate = 1 << 1, // arrays, maps, sets, etc
+ IsNull = 1 << 2, // explicit null RESP types, or bulk-strings/aggregates with length -1
+ IsInlineScalar = 1 << 3, // a non-null scalar, i.e. with payload+CrLf
+ IsAttribute = 1 << 4, // is metadata for following elements
+ IsStreaming = 1 << 5, // unknown length
+ IsError = 1 << 6, // an explicit error reported inside the protocol
+ }
+
+ // relates to the element we're currently reading
+ private RespFlags _flags;
+ private RespPrefix _prefix;
+
+ private int _length; // for null: 0; for scalars: the length of the payload; for aggregates: the child count
+
+ // the current buffer that we're observing
+ private int _bufferIndex; // after TryRead, this should be positioned immediately before the actual data
+
+ // the position in a multi-segment payload
+ private long _positionBase; // total data we've already moved past in *previous* buffers
+ private ReadOnlySequenceSegment? _tail; // the next tail node
+ private long _remainingTailLength; // how much more can we consume from the tail?
+
+ public long ProtocolBytesRemaining => TotalAvailable;
+
+ private readonly int CurrentAvailable => CurrentLength - _bufferIndex;
+
+ private readonly long TotalAvailable => CurrentAvailable + _remainingTailLength;
+ private partial void UnsafeTrimCurrentBy(int count);
+ private readonly partial ref byte UnsafeCurrent { get; }
+ private readonly partial int CurrentLength { get; }
+ private partial void SetCurrent(ReadOnlySpan value);
+ private RespPrefix UnsafePeekPrefix() => (RespPrefix)UnsafeCurrent;
+ private readonly partial ReadOnlySpan UnsafePastPrefix();
+ private readonly partial ReadOnlySpan CurrentSpan();
+
+ ///
+ /// Get the scalar value as a single-segment span.
+ ///
+ /// True if this is a non-streaming scalar element that covers a single span only, otherwise False.
+ /// If a scalar reports False, can be used to iterate the entire payload.
+ /// When True, the contents of the scalar value.
+ public readonly bool TryGetSpan(out ReadOnlySpan value)
+ {
+ if (IsInlineScalar && CurrentAvailable >= _length)
+ {
+ value = CurrentSpan().Slice(0, _length);
+ return true;
+ }
+
+ value = default;
+ return IsNullScalar;
+ }
+
+ ///
+ /// Returns the position after the end of the current element.
+ ///
+ public readonly long BytesConsumed => _positionBase + _bufferIndex + TrailingLength;
+
+ ///
+ /// Body length of scalar values, plus any terminating sentinels.
+ ///
+ private readonly int TrailingLength => (_flags & RespFlags.IsInlineScalar) == 0 ? 0 : (_length + 2);
+
+ ///
+ /// Gets the RESP kind of the current element.
+ ///
+ public readonly RespPrefix Prefix => _prefix;
+
+ ///
+ /// The payload length of this scalar element (includes combined length for streaming scalars).
+ ///
+ public readonly int ScalarLength() =>
+ IsInlineScalar ? _length : IsNullScalar ? 0 : checked((int)ScalarLengthSlow());
+
+ ///
+ /// Indicates whether this scalar value is zero-length.
+ ///
+ public readonly bool ScalarIsEmpty() =>
+ IsInlineScalar ? _length == 0 : (IsNullScalar || !ScalarChunks().MoveNext());
+
+ ///
+ /// The payload length of this scalar element (includes combined length for streaming scalars).
+ ///
+ public readonly long ScalarLongLength() => IsInlineScalar ? _length : IsNullScalar ? 0 : ScalarLengthSlow();
+
+ private readonly long ScalarLengthSlow()
+ {
+ DemandScalar();
+ long length = 0;
+ var iterator = ScalarChunks();
+ while (iterator.MoveNext())
+ {
+ length += iterator.CurrentLength;
+ }
+
+ return length;
+ }
+
+ ///
+ /// The number of child elements associated with an aggregate.
+ ///
+ /// For
+ /// and aggregates, this is twice the value reported in the RESP protocol,
+ /// i.e. a map of the form %2\r\n... will report 4 as the length.
+ /// Note that if the data could be streaming (), it may be preferable to use
+ /// the API, using the API to update the outer reader.
+ public readonly int AggregateLength() =>
+ (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming)) == RespFlags.IsAggregate
+ ? _length
+ : AggregateLengthSlow();
+
+ public delegate T Projection(ref RespReader value);
+
+ public void FillAll(scoped Span target, Projection projection)
+ {
+ DemandNotNull();
+ AggregateChildren().FillAll(target, projection);
+ }
+
+ private readonly int AggregateLengthSlow()
+ {
+ switch (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming))
+ {
+ case RespFlags.IsAggregate:
+ return _length;
+ case RespFlags.IsAggregate | RespFlags.IsStreaming:
+ break;
+ default:
+ DemandAggregate(); // we expect this to throw
+ break;
+ }
+
+ int count = 0;
+ var reader = Clone();
+ while (true)
+ {
+ if (!reader.TryMoveNext()) ThrowEof();
+ if (reader.Prefix == RespPrefix.StreamTerminator)
+ {
+ return count;
+ }
+
+ reader.SkipChildren();
+ count++;
+ }
+ }
+
+ ///
+ /// Indicates whether this is a scalar value, i.e. with a potential payload body.
+ ///
+ public readonly bool IsScalar => (_flags & RespFlags.IsScalar) != 0;
+
+ internal readonly bool IsInlineScalar => (_flags & RespFlags.IsInlineScalar) != 0;
+
+ internal readonly bool IsNullScalar =>
+ (_flags & (RespFlags.IsScalar | RespFlags.IsNull)) == (RespFlags.IsScalar | RespFlags.IsNull);
+
+ ///
+ /// Indicates whether this is an aggregate value, i.e. represents a collection of sub-values.
+ ///
+ public readonly bool IsAggregate => (_flags & RespFlags.IsAggregate) != 0;
+
+ ///
+ /// Indicates whether this is a null value; this could be an explicit ,
+ /// or a scalar or aggregate a negative reported length.
+ ///
+ public readonly bool IsNull => (_flags & RespFlags.IsNull) != 0;
+
+ ///
+ /// Indicates whether this is an attribute value, i.e. metadata relating to later element data.
+ ///
+ public readonly bool IsAttribute => (_flags & RespFlags.IsAttribute) != 0;
+
+ ///
+ /// Indicates whether this represents streaming content, where the or is not known in advance.
+ ///
+ public readonly bool IsStreaming => (_flags & RespFlags.IsStreaming) != 0;
+
+ ///
+ /// Equivalent to both and .
+ ///
+ internal readonly bool IsStreamingScalar => (_flags & (RespFlags.IsScalar | RespFlags.IsStreaming)) ==
+ (RespFlags.IsScalar | RespFlags.IsStreaming);
+
+ ///
+ /// Indicates errors reported inside the protocol.
+ ///
+ public readonly bool IsError => (_flags & RespFlags.IsError) != 0;
+
+ ///
+ /// Gets the effective change (in terms of how many RESP nodes we expect to see) from consuming this element.
+ /// For simple scalars, this is -1 because we have one less node to read; for simple aggregates, this is
+ /// AggregateLength-1 because we will have consumed one element, but now need to read the additional
+ /// child elements. Attributes report 0, since they supplement data
+ /// we still need to consume. The final terminator for streaming data reports a delta of -1, otherwise: 0.
+ ///
+ /// This does not account for being nested inside a streaming aggregate; the caller must deal with that manually.
+ internal int Delta() =>
+ (_flags & (RespFlags.IsScalar | RespFlags.IsAggregate | RespFlags.IsStreaming | RespFlags.IsAttribute)) switch
+ {
+ RespFlags.IsScalar => -1,
+ RespFlags.IsAggregate => _length - 1,
+ RespFlags.IsAggregate | RespFlags.IsAttribute => _length,
+ _ => 0,
+ };
+
+ ///
+ /// Assert that this is the final element in the current payload.
+ ///
+ /// If additional elements are available.
+ public void DemandEnd()
+ {
+ while (IsStreamingScalar)
+ {
+ if (!TryReadNext()) ThrowEof();
+ }
+
+ if (TryReadNext())
+ {
+ Throw(Prefix);
+ }
+
+ static void Throw(RespPrefix prefix) =>
+ throw new InvalidOperationException($"Expected end of payload, but found {prefix}");
+ }
+
+ private bool TryReadNextSkipAttributes()
+ {
+ while (TryReadNext())
+ {
+ if (IsAttribute)
+ {
+ SkipChildren();
+ }
+ else
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool TryReadNextProcessAttributes(RespAttributeReader respAttributeReader, ref T attributes)
+ {
+ while (TryReadNext())
+ {
+ if (IsAttribute)
+ {
+ respAttributeReader.Read(ref this, ref attributes);
+ }
+ else
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default.
+ ///
+ /// If the data is exhausted before a streaming scalar is exhausted.
+ /// If the data contains an explicit error element.
+ public bool TryMoveNext()
+ {
+ while (IsStreamingScalar) // close out the current streaming scalar
+ {
+ if (!TryReadNextSkipAttributes()) ThrowEof();
+ }
+
+ if (TryReadNextSkipAttributes())
+ {
+ if (IsError) ThrowError();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default.
+ ///
+ /// Whether to check and throw for error messages.
+ /// If the data is exhausted before a streaming scalar is exhausted.
+ /// If the data contains an explicit error element.
+ public bool TryMoveNext(bool checkError)
+ {
+ while (IsStreamingScalar) // close out the current streaming scalar
+ {
+ if (!TryReadNextSkipAttributes()) ThrowEof();
+ }
+
+ if (TryReadNextSkipAttributes())
+ {
+ if (checkError && IsError) ThrowError();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default.
+ ///
+ /// Parser for attribute data preceding the data.
+ /// The state for attributes encountered.
+ /// If the data is exhausted before a streaming scalar is exhausted.
+ /// If the data contains an explicit error element.
+ /// The type of data represented by this reader.
+ public bool TryMoveNext(RespAttributeReader respAttributeReader, ref T attributes)
+ {
+ while (IsStreamingScalar) // close out the current streaming scalar
+ {
+ if (!TryReadNextSkipAttributes()) ThrowEof();
+ }
+
+ if (TryReadNextProcessAttributes(respAttributeReader, ref attributes))
+ {
+ if (IsError) ThrowError();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Move to the next content element, asserting that it is of the expected type; this skips attribute metadata, checking for RESP error messages by default.
+ ///
+ /// The expected data type.
+ /// If the data is exhausted before a streaming scalar is exhausted.
+ /// If the data contains an explicit error element.
+ /// If the data is not of the expected type.
+ public bool TryMoveNext(RespPrefix prefix)
+ {
+ bool result = TryMoveNext();
+ if (result) Demand(prefix);
+ return result;
+ }
+
+ ///
+ /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default.
+ ///
+ /// If the data is exhausted before content is found.
+ /// If the data contains an explicit error element.
+ public void MoveNext()
+ {
+ if (!TryMoveNext()) ThrowEof();
+ }
+
+ ///
+ /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default.
+ ///
+ /// Parser for attribute data preceding the data.
+ /// The state for attributes encountered.
+ /// If the data is exhausted before content is found.
+ /// If the data contains an explicit error element.
+ /// The type of data represented by this reader.
+ public void MoveNext(RespAttributeReader respAttributeReader, ref T attributes)
+ {
+ if (!TryMoveNext(respAttributeReader, ref attributes)) ThrowEof();
+ }
+
+ private bool MoveNextStreamingScalar()
+ {
+ if (IsStreamingScalar)
+ {
+ while (TryReadNext())
+ {
+ if (IsAttribute)
+ {
+ SkipChildren();
+ }
+ else
+ {
+ if (Prefix != RespPrefix.StreamContinuation)
+ ThrowProtocolFailure("Streaming continuation expected");
+ return _length > 0;
+ }
+ }
+
+ ThrowEof(); // we should have found something!
+ }
+
+ return false;
+ }
+
+ ///
+ /// Move to the next content element () and assert that it is a scalar ().
+ ///
+ /// If the data is exhausted before content is found.
+ /// If the data contains an explicit error element.
+ /// If the data is not a scalar type.
+ public void MoveNextScalar()
+ {
+ MoveNext();
+ DemandScalar();
+ }
+
+ ///
+ /// Move to the next content element () and assert that it is an aggregate ().
+ ///
+ /// If the data is exhausted before content is found.
+ /// If the data contains an explicit error element.
+ /// If the data is not an aggregate type.
+ public void MoveNextAggregate()
+ {
+ MoveNext();
+ DemandAggregate();
+ }
+
+ ///
+ /// Move to the next content element () and assert that it of type specified
+ /// in .
+ ///
+ /// The expected data type.
+ /// Parser for attribute data preceding the data.
+ /// The state for attributes encountered.
+ /// If the data is exhausted before content is found.
+ /// If the data contains an explicit error element.
+ /// If the data is not of the expected type.
+ /// The type of data represented by this reader.
+ public void MoveNext(RespPrefix prefix, RespAttributeReader respAttributeReader, ref T attributes)
+ {
+ MoveNext(respAttributeReader, ref attributes);
+ Demand(prefix);
+ }
+
+ ///
+ /// Move to the next content element () and assert that it of type specified
+ /// in .
+ ///
+ /// The expected data type.
+ /// If the data is exhausted before content is found.
+ /// If the data contains an explicit error element.
+ /// If the data is not of the expected type.
+ public void MoveNext(RespPrefix prefix)
+ {
+ MoveNext();
+ Demand(prefix);
+ }
+
+ internal void Demand(RespPrefix prefix)
+ {
+ if (Prefix != prefix) Throw(prefix, Prefix);
+
+ static void Throw(RespPrefix expected, RespPrefix actual) =>
+ throw new InvalidOperationException($"Expected {expected} element, but found {actual}.");
+ }
+
+ private readonly void ThrowError() => throw new RespException(ReadString()!);
+
+ ///
+ /// Skip all sub elements of the current node; this includes both aggregate children and scalar streaming elements.
+ ///
+ public void SkipChildren()
+ {
+ // if this is a simple non-streaming scalar, then: there's nothing complex to do; otherwise, re-use the
+ // frame scanner logic to seek past the noise (this way, we avoid recursion etc)
+ switch (_flags & (RespFlags.IsScalar | RespFlags.IsAggregate | RespFlags.IsStreaming))
+ {
+ case RespFlags.None:
+ // no current element
+ break;
+ case RespFlags.IsScalar:
+ // simple scalar
+ MovePastCurrent();
+ break;
+ default:
+ // something more complex
+ RespScanState state = new(in this);
+ if (!state.TryRead(ref this, out _)) ThrowEof();
+ break;
+ }
+ }
+
+ ///
+ /// Reads the current element as a string value.
+ ///
+ public readonly string? ReadString() => ReadString(out _);
+
+ ///
+ /// Reads the current element as a string value.
+ ///
+ public readonly string? ReadString(out string prefix)
+ {
+ byte[] pooled = [];
+ try
+ {
+ var span = Buffer(ref pooled, stackalloc byte[256]);
+ prefix = "";
+ if (span.IsEmpty)
+ {
+ return IsNull ? null : "";
+ }
+
+ if (Prefix == RespPrefix.VerbatimString
+ && span.Length >= 4 && span[3] == ':')
+ {
+ // "the first three bytes provide information about the format of the following string,
+ // which can be txt for plain text, or mkd for markdown. The fourth byte is always :.
+ // Then the real string follows."
+ var prefixValue = RespConstants.UnsafeCpuUInt32(span);
+ if (prefixValue == PrefixTxt)
+ {
+ prefix = "txt";
+ }
+ else if (prefixValue == PrefixMkd)
+ {
+ prefix = "mkd";
+ }
+ else
+ {
+ prefix = RespConstants.UTF8.GetString(span.Slice(0, 3));
+ }
+
+ span = span.Slice(4);
+ }
+
+ return RespConstants.UTF8.GetString(span);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(pooled);
+ }
+ }
+
+ private static readonly uint
+ PrefixTxt = RespConstants.UnsafeCpuUInt32("txt:"u8),
+ PrefixMkd = RespConstants.UnsafeCpuUInt32("mkd:"u8);
+
+ ///
+ /// Reads the current element as a string value.
+ ///
+ public readonly byte[]? ReadByteArray()
+ {
+ byte[] pooled = [];
+ try
+ {
+ var span = Buffer(ref pooled, stackalloc byte[256]);
+ if (span.IsEmpty)
+ {
+ return IsNull ? null : [];
+ }
+
+ return span.ToArray();
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(pooled);
+ }
+ }
+
+ ///
+ /// Reads the current element using a general purpose text parser.
+ ///
+ /// The type of data being parsed.
+ public readonly T ParseBytes(Parser parser)
+ {
+ byte[] pooled = [];
+ var span = Buffer(ref pooled, stackalloc byte[256]);
+ try
+ {
+ return parser(span);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(pooled);
+ }
+ }
+
+ ///
+ /// Reads the current element using a general purpose text parser.
+ ///
+ /// The type of data being parsed.
+ /// State required by the parser.
+ public readonly T ParseBytes(Parser parser, TState? state)
+ {
+ byte[] pooled = [];
+ var span = Buffer(ref pooled, stackalloc byte[256]);
+ try
+ {
+ return parser(span, default);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(pooled);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal readonly ReadOnlySpan Buffer(Span target)
+ {
+ if (TryGetSpan(out var simple))
+ {
+ return simple;
+ }
+
+#if NET6_0_OR_GREATER
+ return BufferSlow(ref Unsafe.NullRef(), target, usePool: false);
+#else
+ byte[] pooled = [];
+ return BufferSlow(ref pooled, target, usePool: false);
+#endif
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal readonly ReadOnlySpan Buffer(scoped ref byte[] pooled, Span target = default)
+ => TryGetSpan(out var simple) ? simple : BufferSlow(ref pooled, target, true);
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private readonly ReadOnlySpan BufferSlow(scoped ref byte[] pooled, Span target, bool usePool)
+ {
+ DemandScalar();
+
+ if (IsInlineScalar && usePool)
+ {
+ // grow to the correct size in advance, if needed
+ var length = ScalarLength();
+ if (length > target.Length)
+ {
+ var bigger = ArrayPool.Shared.Rent(length);
+ ArrayPool.Shared.Return(pooled);
+ target = pooled = bigger;
+ }
+ }
+
+ var iterator = ScalarChunks();
+ ReadOnlySpan current;
+ int offset = 0;
+ while (iterator.MoveNext())
+ {
+ // will the current chunk fit?
+ current = iterator.Current;
+ if (current.TryCopyTo(target.Slice(offset)))
+ {
+ // fits into the current buffer
+ offset += current.Length;
+ }
+ else if (!usePool)
+ {
+ // rent disallowed; fill what we can
+ var available = target.Slice(offset);
+ current.Slice(0, available.Length).CopyTo(available);
+ return target; // we filled it
+ }
+ else
+ {
+ // rent a bigger buffer, copy and recycle
+ var bigger = ArrayPool.Shared.Rent(offset + current.Length);
+ if (offset != 0)
+ {
+ target.Slice(0, offset).CopyTo(bigger);
+ }
+
+ ArrayPool.Shared.Return(pooled);
+ target = pooled = bigger;
+ current.CopyTo(target.Slice(offset));
+ }
+ }
+
+ return target.Slice(0, offset);
+ }
+
+ ///
+ /// Reads the current element using a general purpose byte parser.
+ ///
+ /// The type of data being parsed.
+ public readonly T ParseChars(Parser parser)
+ {
+ byte[] bArr = [];
+ char[] cArr = [];
+ try
+ {
+ var bSpan = Buffer(ref bArr, stackalloc byte[128]);
+ var maxChars = RespConstants.UTF8.GetMaxCharCount(bSpan.Length);
+ Span cSpan = maxChars <= 128 ? stackalloc char[128] : (cArr = ArrayPool.Shared.Rent(maxChars));
+ int chars = RespConstants.UTF8.GetChars(bSpan, cSpan);
+ return parser(cSpan.Slice(0, chars));
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(bArr);
+ ArrayPool.Shared.Return(cArr);
+ }
+ }
+
+ ///
+ /// Reads the current element using a general purpose byte parser.
+ ///
+ /// The type of data being parsed.
+ /// State required by the parser.
+ public readonly T ParseChars(Parser parser, TState? state)
+ {
+ byte[] bArr = [];
+ char[] cArr = [];
+ try
+ {
+ var bSpan = Buffer(ref bArr, stackalloc byte[128]);
+ var maxChars = RespConstants.UTF8.GetMaxCharCount(bSpan.Length);
+ Span cSpan = maxChars <= 128 ? stackalloc char[128] : (cArr = ArrayPool.Shared.Rent(maxChars));
+ int chars = RespConstants.UTF8.GetChars(bSpan, cSpan);
+ return parser(cSpan.Slice(0, chars), state);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(bArr);
+ ArrayPool.Shared.Return(cArr);
+ }
+ }
+
+#if NET7_0_OR_GREATER
+ ///
+ /// Reads the current element using .
+ ///
+ /// The type of data being parsed.
+#pragma warning disable RS0016, RS0027 // back-compat overload
+ public readonly T ParseChars(IFormatProvider? formatProvider = null) where T : ISpanParsable
+#pragma warning restore RS0016, RS0027 // back-compat overload
+ {
+ byte[] bArr = [];
+ char[] cArr = [];
+ try
+ {
+ var bSpan = Buffer(ref bArr, stackalloc byte[128]);
+ var maxChars = RespConstants.UTF8.GetMaxCharCount(bSpan.Length);
+ Span cSpan = maxChars <= 128 ? stackalloc char[128] : (cArr = ArrayPool.Shared.Rent(maxChars));
+ int chars = RespConstants.UTF8.GetChars(bSpan, cSpan);
+ return T.Parse(cSpan.Slice(0, chars), formatProvider ?? CultureInfo.InvariantCulture);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(bArr);
+ ArrayPool.Shared.Return(cArr);
+ }
+ }
+#endif
+
+#if NET8_0_OR_GREATER
+ ///
+ /// Reads the current element using .
+ ///
+ /// The type of data being parsed.
+#pragma warning disable RS0016, RS0027 // back-compat overload
+ public readonly T ParseBytes(IFormatProvider? formatProvider = null) where T : IUtf8SpanParsable
+#pragma warning restore RS0016, RS0027 // back-compat overload
+ {
+ byte[] bArr = [];
+ try
+ {
+ var bSpan = Buffer(ref bArr, stackalloc byte[128]);
+ return T.Parse(bSpan, formatProvider ?? CultureInfo.InvariantCulture);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(bArr);
+ }
+ }
+#endif
+
+ ///
+ /// General purpose parsing callback.
+ ///
+ /// The type of source data being parsed.
+ /// State required by the parser.
+ /// The output type of data being parsed.
+ public delegate TValue Parser(ReadOnlySpan value, TState? state);
+
+ ///
+ /// General purpose parsing callback.
+ ///
+ /// The type of source data being parsed.
+ /// The output type of data being parsed.
+ public delegate TValue Parser(ReadOnlySpan value);
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The raw contents to parse with this instance.
+ public RespReader(ReadOnlySpan value)
+ {
+ _length = 0;
+ _flags = RespFlags.None;
+ _prefix = RespPrefix.None;
+ SetCurrent(value);
+
+ _remainingTailLength = _positionBase = 0;
+ _tail = null;
+ }
+
+ private void MovePastCurrent()
+ {
+ // skip past the trailing portion of a value, if any
+ var skip = TrailingLength;
+ if (_bufferIndex + skip <= CurrentLength)
+ {
+ _bufferIndex += skip; // available in the current buffer
+ }
+ else
+ {
+ AdvanceSlow(skip);
+ }
+
+ // reset the current state
+ _length = 0;
+ _flags = 0;
+ _prefix = RespPrefix.None;
+ }
+
+ ///
+ public RespReader(scoped in ReadOnlySequence value)
+#if NETCOREAPP3_0_OR_GREATER
+ : this(value.FirstSpan)
+#else
+ : this(value.First.Span)
+#endif
+ {
+ if (!value.IsSingleSegment)
+ {
+ _remainingTailLength = value.Length - CurrentLength;
+ _tail = (value.Start.GetObject() as ReadOnlySequenceSegment)?.Next ?? MissingNext();
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn]
+ static ReadOnlySequenceSegment MissingNext() =>
+ throw new ArgumentException("Unable to extract tail segment", nameof(value));
+ }
+
+ ///
+ /// Attempt to move to the next RESP element.
+ ///
+ /// Unless you are intentionally handling errors, attributes and streaming data, should be preferred.
+ [EditorBrowsable(EditorBrowsableState.Never), Browsable(false)]
+ public unsafe bool TryReadNext()
+ {
+ MovePastCurrent();
+
+#if NETCOREAPP3_0_OR_GREATER
+ // check what we have available; don't worry about zero/fetching the next segment; this is only
+ // for SIMD lookup, and zero would only apply when data ends exactly on segment boundaries, which
+ // is incredible niche
+ var available = CurrentAvailable;
+
+ if (Avx2.IsSupported && Bmi1.IsSupported && available >= sizeof(uint))
+ {
+ // read the first 4 bytes
+ ref byte origin = ref UnsafeCurrent;
+ var comparand = Unsafe.ReadUnaligned(ref origin);
+
+ // broadcast those 4 bytes into a vector, mask to get just the first and last byte, and apply a SIMD equality test with our known cases
+ var eqs =
+ Avx2.CompareEqual(Avx2.And(Avx2.BroadcastScalarToVector256(&comparand), Raw.FirstLastMask), Raw.CommonRespPrefixes);
+
+ // reinterpret that as floats, and pick out the sign bits (which will be 1 for "equal", 0 for "not equal"); since the
+ // test cases are mutually exclusive, we expect zero or one matches, so: lzcount tells us which matched
+ var index =
+ Bmi1.TrailingZeroCount((uint)Avx.MoveMask(Unsafe.As, Vector256>(ref eqs)));
+ int len;
+#if DEBUG
+ if (VectorizeDisabled) index = uint.MaxValue; // just to break the switch
+#endif
+ switch (index)
+ {
+ case Raw.CommonRespIndex_Success when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n':
+ _prefix = RespPrefix.SimpleString;
+ _length = 2;
+ _bufferIndex++;
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ return true;
+ case Raw.CommonRespIndex_SingleDigitInteger when Unsafe.Add(ref origin, 2) == (byte)'\r':
+ _prefix = RespPrefix.Integer;
+ _length = 1;
+ _bufferIndex++;
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ return true;
+ case Raw.CommonRespIndex_DoubleDigitInteger when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n':
+ _prefix = RespPrefix.Integer;
+ _length = 2;
+ _bufferIndex++;
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ return true;
+ case Raw.CommonRespIndex_SingleDigitString when Unsafe.Add(ref origin, 2) == (byte)'\r':
+ if (comparand == RespConstants.BulkStringStreaming)
+ {
+ _flags = RespFlags.IsScalar | RespFlags.IsStreaming;
+ }
+ else
+ {
+ len = ParseSingleDigit(Unsafe.Add(ref origin, 1));
+ if (available < len + 6) break; // need more data
+
+ UnsafeAssertClLf(4 + len);
+ _length = len;
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ }
+ _prefix = RespPrefix.BulkString;
+ _bufferIndex += 4;
+ return true;
+ case Raw.CommonRespIndex_DoubleDigitString when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n':
+ if (comparand == RespConstants.BulkStringNull)
+ {
+ _length = 0;
+ _flags = RespFlags.IsScalar | RespFlags.IsNull;
+ }
+ else
+ {
+ len = ParseDoubleDigitsNonNegative(ref Unsafe.Add(ref origin, 1));
+ if (available < len + 7) break; // need more data
+
+ UnsafeAssertClLf(5 + len);
+ _length = len;
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ }
+ _prefix = RespPrefix.BulkString;
+ _bufferIndex += 5;
+ return true;
+ case Raw.CommonRespIndex_SingleDigitArray when Unsafe.Add(ref origin, 2) == (byte)'\r':
+ if (comparand == RespConstants.ArrayStreaming)
+ {
+ _flags = RespFlags.IsAggregate | RespFlags.IsStreaming;
+ }
+ else
+ {
+ _flags = RespFlags.IsAggregate;
+ _length = ParseSingleDigit(Unsafe.Add(ref origin, 1));
+ }
+ _prefix = RespPrefix.Array;
+ _bufferIndex += 4;
+ return true;
+ case Raw.CommonRespIndex_DoubleDigitArray when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n':
+ if (comparand == RespConstants.ArrayNull)
+ {
+ _flags = RespFlags.IsAggregate | RespFlags.IsNull;
+ }
+ else
+ {
+ _length = ParseDoubleDigitsNonNegative(ref Unsafe.Add(ref origin, 1));
+ _flags = RespFlags.IsAggregate;
+ }
+ _prefix = RespPrefix.Array;
+ _bufferIndex += 5;
+ return true;
+ case Raw.CommonRespIndex_Error:
+ len = UnsafePastPrefix().IndexOf(RespConstants.CrlfBytes);
+ if (len < 0) break; // need more data
+
+ _prefix = RespPrefix.SimpleError;
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar | RespFlags.IsError;
+ _length = len;
+ _bufferIndex++;
+ return true;
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ static int ParseDoubleDigitsNonNegative(ref byte value) => (10 * ParseSingleDigit(value)) + ParseSingleDigit(Unsafe.Add(ref value, 1));
+#endif
+
+ // no fancy vectorization, but: we can still try to find the payload the fast way in a single segment
+ if (_bufferIndex + 3 <= CurrentLength) // shortest possible RESP fragment is length 3
+ {
+ var remaining = UnsafePastPrefix();
+ switch (_prefix = UnsafePeekPrefix())
+ {
+ case RespPrefix.SimpleString:
+ case RespPrefix.SimpleError:
+ case RespPrefix.Integer:
+ case RespPrefix.Boolean:
+ case RespPrefix.Double:
+ case RespPrefix.BigInteger:
+ // CRLF-terminated
+ _length = remaining.IndexOf(RespConstants.CrlfBytes);
+ if (_length < 0) break; // can't find, need more data
+ _bufferIndex++; // payload follows prefix directly
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ if (_prefix == RespPrefix.SimpleError) _flags |= RespFlags.IsError;
+ return true;
+ case RespPrefix.BulkError:
+ case RespPrefix.BulkString:
+ case RespPrefix.VerbatimString:
+ // length prefix with value payload; first, the length
+ switch (TryReadLengthPrefix(remaining, out _length, out int consumed))
+ {
+ case LengthPrefixResult.Length:
+ // still need to valid terminating CRLF
+ if (remaining.Length < consumed + _length + 2) break; // need more data
+ UnsafeAssertClLf(1 + consumed + _length);
+
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ break;
+ case LengthPrefixResult.Null:
+ _flags = RespFlags.IsScalar | RespFlags.IsNull;
+ break;
+ case LengthPrefixResult.Streaming:
+ _flags = RespFlags.IsScalar | RespFlags.IsStreaming;
+ break;
+ }
+
+ if (_flags == 0) break; // will need more data to know
+ if (_prefix == RespPrefix.BulkError) _flags |= RespFlags.IsError;
+ _bufferIndex += 1 + consumed;
+ return true;
+ case RespPrefix.StreamContinuation:
+ // length prefix, possibly with value payload; first, the length
+ switch (TryReadLengthPrefix(remaining, out _length, out consumed))
+ {
+ case LengthPrefixResult.Length when _length == 0:
+ // EOF, no payload
+ _flags = RespFlags
+ .IsScalar; // don't claim as streaming, we want this to count towards delta-decrement
+ break;
+ case LengthPrefixResult.Length:
+ // still need to valid terminating CRLF
+ if (remaining.Length < consumed + _length + 2) break; // need more data
+ UnsafeAssertClLf(1 + consumed + _length);
+
+ _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar | RespFlags.IsStreaming;
+ break;
+ case LengthPrefixResult.Null:
+ case LengthPrefixResult.Streaming:
+ ThrowProtocolFailure("Invalid streaming scalar length prefix");
+ break;
+ }
+
+ if (_flags == 0) break; // will need more data to know
+ _bufferIndex += 1 + consumed;
+ return true;
+ case RespPrefix.Array:
+ case RespPrefix.Set:
+ case RespPrefix.Map:
+ case RespPrefix.Push:
+ case RespPrefix.Attribute:
+ // length prefix without value payload (child values follow)
+ switch (TryReadLengthPrefix(remaining, out _length, out consumed))
+ {
+ case LengthPrefixResult.Length:
+ _flags = RespFlags.IsAggregate;
+ if (AggregateLengthNeedsDoubling()) _length *= 2;
+ break;
+ case LengthPrefixResult.Null:
+ _flags = RespFlags.IsAggregate | RespFlags.IsNull;
+ break;
+ case LengthPrefixResult.Streaming:
+ _flags = RespFlags.IsAggregate | RespFlags.IsStreaming;
+ break;
+ }
+
+ if (_flags == 0) break; // will need more data to know
+ if (_prefix is RespPrefix.Attribute) _flags |= RespFlags.IsAttribute;
+ _bufferIndex += consumed + 1;
+ return true;
+ case RespPrefix.Null: // null
+ // note we already checked we had 3 bytes
+ UnsafeAssertClLf(1);
+ _flags = RespFlags.IsScalar | RespFlags.IsNull;
+ _bufferIndex += 3; // skip prefix+terminator
+ return true;
+ case RespPrefix.StreamTerminator:
+ // note we already checked we had 3 bytes
+ UnsafeAssertClLf(1);
+ _flags = RespFlags.IsAggregate; // don't claim as streaming - this counts towards delta
+ _bufferIndex += 3; // skip prefix+terminator
+ return true;
+ default:
+ ThrowProtocolFailure("Unexpected protocol prefix: " + _prefix);
+ return false;
+ }
+ }
+
+ return TryReadNextSlow(ref this);
+ }
+
+ private static bool TryReadNextSlow(ref RespReader live)
+ {
+ // in the case of failure, we don't want to apply any changes,
+ // so we work against an isolated copy until we're happy
+ live.MovePastCurrent();
+ RespReader isolated = live;
+
+ int next = isolated.RawTryReadByte();
+ if (next < 0) return false;
+
+ switch (isolated._prefix = (RespPrefix)next)
+ {
+ case RespPrefix.SimpleString:
+ case RespPrefix.SimpleError:
+ case RespPrefix.Integer:
+ case RespPrefix.Boolean:
+ case RespPrefix.Double:
+ case RespPrefix.BigInteger:
+ // CRLF-terminated
+ if (!isolated.RawTryFindCrLf(out isolated._length)) return false;
+ isolated._flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ if (isolated._prefix == RespPrefix.SimpleError) isolated._flags |= RespFlags.IsError;
+ break;
+ case RespPrefix.BulkError:
+ case RespPrefix.BulkString:
+ case RespPrefix.VerbatimString:
+ // length prefix with value payload
+ switch (isolated.RawTryReadLengthPrefix())
+ {
+ case LengthPrefixResult.Length:
+ // still need to valid terminating CRLF
+ isolated._flags = RespFlags.IsScalar | RespFlags.IsInlineScalar;
+ if (!isolated.RawTryAssertInlineScalarPayloadCrLf()) return false;
+ break;
+ case LengthPrefixResult.Null:
+ isolated._flags = RespFlags.IsScalar | RespFlags.IsNull;
+ break;
+ case LengthPrefixResult.Streaming:
+ isolated._flags = RespFlags.IsScalar | RespFlags.IsStreaming;
+ break;
+ case LengthPrefixResult.NeedMoreData:
+ return false;
+ default:
+ ThrowProtocolFailure("Unexpected length prefix");
+ return false;
+ }
+
+ if (isolated._prefix == RespPrefix.BulkError) isolated._flags |= RespFlags.IsError;
+ break;
+ case RespPrefix.Array:
+ case RespPrefix.Set:
+ case RespPrefix.Map:
+ case RespPrefix.Push:
+ case RespPrefix.Attribute:
+ // length prefix without value payload (child values follow)
+ switch (isolated.RawTryReadLengthPrefix())
+ {
+ case LengthPrefixResult.Length:
+ isolated._flags = RespFlags.IsAggregate;
+ if (isolated.AggregateLengthNeedsDoubling()) isolated._length *= 2;
+ break;
+ case LengthPrefixResult.Null:
+ isolated._flags = RespFlags.IsAggregate | RespFlags.IsNull;
+ break;
+ case LengthPrefixResult.Streaming:
+ isolated._flags = RespFlags.IsAggregate | RespFlags.IsStreaming;
+ break;
+ case LengthPrefixResult.NeedMoreData:
+ return false;
+ default:
+ ThrowProtocolFailure("Unexpected length prefix");
+ return false;
+ }
+
+ if (isolated._prefix is RespPrefix.Attribute) isolated._flags |= RespFlags.IsAttribute;
+ break;
+ case RespPrefix.Null: // null
+ if (!isolated.RawAssertCrLf()) return false;
+ isolated._flags = RespFlags.IsScalar | RespFlags.IsNull;
+ break;
+ case RespPrefix.StreamTerminator:
+ if (!isolated.RawAssertCrLf()) return false;
+ isolated._flags = RespFlags.IsAggregate; // don't claim as streaming - this counts towards delta
+ break;
+ case RespPrefix.StreamContinuation:
+ // length prefix, possibly with value payload; first, the length
+ switch (isolated.RawTryReadLengthPrefix())
+ {
+ case LengthPrefixResult.Length when isolated._length == 0:
+ // EOF, no payload
+ isolated._flags =
+ RespFlags
+ .IsScalar; // don't claim as streaming, we want this to count towards delta-decrement
+ break;
+ case LengthPrefixResult.Length:
+ // still need to valid terminating CRLF
+ isolated._flags = RespFlags.IsScalar | RespFlags.IsInlineScalar | RespFlags.IsStreaming;
+ if (!isolated.RawTryAssertInlineScalarPayloadCrLf()) return false; // need more data
+ break;
+ case LengthPrefixResult.Null:
+ case LengthPrefixResult.Streaming:
+ ThrowProtocolFailure("Invalid streaming scalar length prefix");
+ break;
+ case LengthPrefixResult.NeedMoreData:
+ default:
+ return false;
+ }
+
+ break;
+ default:
+ ThrowProtocolFailure("Unexpected protocol prefix: " + isolated._prefix);
+ return false;
+ }
+
+ // commit the speculative changes back, and accept
+ live = isolated;
+ return true;
+ }
+
+ private void AdvanceSlow(long bytes)
+ {
+ while (bytes > 0)
+ {
+ var available = CurrentLength - _bufferIndex;
+ if (bytes <= available)
+ {
+ _bufferIndex += (int)bytes;
+ return;
+ }
+
+ bytes -= available;
+
+ if (!TryMoveToNextSegment()) Throw();
+ }
+
+ [DoesNotReturn]
+ static void Throw() => throw new EndOfStreamException(
+ "Unexpected end of payload; this is unexpected because we already validated that it was available!");
+ }
+
+ private bool AggregateLengthNeedsDoubling() => _prefix is RespPrefix.Map or RespPrefix.Attribute;
+
+ private bool TryMoveToNextSegment()
+ {
+ while (_tail is not null && _remainingTailLength > 0)
+ {
+ var memory = _tail.Memory;
+ _tail = _tail.Next;
+ if (!memory.IsEmpty)
+ {
+ var span = memory.Span; // check we can get this before mutating anything
+ _positionBase += CurrentLength;
+ if (span.Length > _remainingTailLength)
+ {
+ span = span.Slice(0, (int)_remainingTailLength);
+ _remainingTailLength = 0;
+ }
+ else
+ {
+ _remainingTailLength -= span.Length;
+ }
+
+ SetCurrent(span);
+ _bufferIndex = 0;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal readonly bool IsOK() // go mad with this, because it is used so often
+ {
+ if (TryGetSpan(out var span) && span.Length == 2)
+ {
+ var u16 = Unsafe.ReadUnaligned(ref UnsafeCurrent);
+ return u16 == RespConstants.OKUInt16 | u16 == RespConstants.OKUInt16_LC;
+ }
+
+ return IsSlow(RespConstants.OKBytes, RespConstants.OKBytes_LC);
+ }
+
+ ///
+ /// Indicates whether the current element is a scalar with a value that matches the provided .
+ ///
+ /// The payload value to verify.
+ public readonly bool Is(ReadOnlySpan value)
+ => TryGetSpan(out var span) ? span.SequenceEqual(value) : IsSlow(value);
+
+ ///
+ /// Indicates whether the current element is a scalar with a value that starts with the provided .
+ ///
+ /// The payload value to verify.
+ public readonly bool StartsWith(ReadOnlySpan value)
+ => TryGetSpan(out var span) ? span.StartsWith(value) : StartsWithSlow(value);
+
+ ///
+ /// Indicates whether the current element is a scalar with a value that matches the provided .
+ ///
+ /// The payload value to verify.
+ public readonly bool Is(ReadOnlySpan value)
+ {
+ var bytes = RespConstants.UTF8.GetMaxByteCount(value.Length);
+ byte[]? oversized = null;
+ Span buffer = bytes <= 128 ? stackalloc byte[128] : (oversized = ArrayPool.Shared.Rent(bytes));
+ bytes = RespConstants.UTF8.GetBytes(value, buffer);
+ bool result = Is(buffer.Slice(0, bytes));
+ if (oversized is not null) ArrayPool.Shared.Return(oversized);
+ return result;
+ }
+
+ internal readonly bool IsInlneCpuUInt32(uint value)
+ {
+ if (IsInlineScalar && _length == sizeof(uint))
+ {
+ return CurrentAvailable >= sizeof(uint)
+ ? Unsafe.ReadUnaligned(ref UnsafeCurrent) == value
+ : SlowIsInlneCpuUInt32(value);
+ }
+
+ return false;
+ }
+
+ private readonly bool SlowIsInlneCpuUInt32(uint value)
+ {
+ Debug.Assert(IsInlineScalar && _length == sizeof(uint), "should be inline scalar of length 4");
+ Span buffer = stackalloc byte[sizeof(uint)];
+ var copy = this;
+ copy.RawFillBytes(buffer);
+ return RespConstants.UnsafeCpuUInt32(buffer) == value;
+ }
+
+ ///
+ /// Indicates whether the current element is a scalar with a value that matches the provided .
+ ///
+ /// The payload value to verify.
+ public readonly bool Is(byte value)
+ {
+ if (IsInlineScalar && _length == 1 && CurrentAvailable >= 1)
+ {
+ return UnsafeCurrent == value;
+ }
+
+ ReadOnlySpan span = [value];
+ return IsSlow(span);
+ }
+
+ private readonly bool IsSlow(ReadOnlySpan testValue0, ReadOnlySpan testValue2)
+ => IsSlow(testValue0) || IsSlow(testValue2);
+
+ private readonly bool IsSlow(ReadOnlySpan testValue)
+ {
+ DemandScalar();
+ if (IsNull) return false; // nothing equals null
+ if (TotalAvailable < testValue.Length) return false;
+
+ if (!IsStreaming && testValue.Length != ScalarLength()) return false;
+
+ var iterator = ScalarChunks();
+ while (true)
+ {
+ if (testValue.IsEmpty)
+ {
+ // nothing left to test; if also nothing left to read, great!
+ return !iterator.MoveNext();
+ }
+
+ if (!iterator.MoveNext())
+ {
+ return false; // test is longer
+ }
+
+ var current = iterator.Current;
+ if (testValue.Length < current.Length) return false; // payload is longer
+
+ if (!current.SequenceEqual(testValue.Slice(0, current.Length))) return false; // payload is different
+
+ testValue = testValue.Slice(current.Length); // validated; continue
+ }
+ }
+
+ private readonly bool StartsWithSlow(ReadOnlySpan testValue)
+ {
+ DemandScalar();
+ if (IsNull) return false; // nothing equals null
+ if (testValue.IsEmpty) return true; // every non-null scalar starts-with empty
+ if (TotalAvailable < testValue.Length) return false;
+
+ if (!IsStreaming && testValue.Length < ScalarLength()) return false;
+
+ var iterator = ScalarChunks();
+ while (true)
+ {
+ if (testValue.IsEmpty)
+ {
+ return true;
+ }
+
+ if (!iterator.MoveNext())
+ {
+ return false; // test is longer
+ }
+
+ var current = iterator.Current;
+ if (testValue.Length <= current.Length)
+ {
+ // current fragment exhausts the test data; check it with StartsWith
+ return testValue.StartsWith(current);
+ }
+
+ // current fragment is longer than the test data; the overlap must match exactly
+ if (!current.SequenceEqual(testValue.Slice(0, current.Length))) return false; // payload is different
+
+ testValue = testValue.Slice(current.Length); // validated; continue
+ }
+ }
+
+ ///
+ /// Copy the current scalar value out into the supplied , or as much as can be copied.
+ ///
+ /// The destination for the copy operation.
+ /// The number of bytes successfully copied.
+ public readonly int CopyTo(scoped Span target)
+ {
+ if (TryGetSpan(out var value))
+ {
+ if (target.Length < value.Length) value = value.Slice(0, target.Length);
+
+ value.CopyTo(target);
+ return value.Length;
+ }
+
+ int totalBytes = 0;
+ var iterator = ScalarChunks();
+ while (iterator.MoveNext())
+ {
+ value = iterator.Current;
+ if (target.Length <= value.Length)
+ {
+ value.Slice(0, target.Length).CopyTo(target);
+ return totalBytes + target.Length;
+ }
+
+ value.CopyTo(target);
+ target = target.Slice(value.Length);
+ totalBytes += value.Length;
+ }
+
+ return totalBytes;
+ }
+
+ ///
+ /// Copy the current scalar value out into the supplied , or as much as can be copied.
+ ///
+ /// The destination for the copy operation.
+ /// The number of bytes successfully copied.
+ public readonly int CopyTo(IBufferWriter target)
+ {
+ if (TryGetSpan(out var value))
+ {
+ target.Write(value);
+ return value.Length;
+ }
+
+ int totalBytes = 0;
+ var iterator = ScalarChunks();
+ while (iterator.MoveNext())
+ {
+ value = iterator.Current;
+ target.Write(value);
+ totalBytes += value.Length;
+ }
+
+ return totalBytes;
+ }
+
+ ///
+ /// Asserts that the current element is not null.
+ ///
+ public void DemandNotNull()
+ {
+ if (IsNull) Throw();
+ static void Throw() => throw new InvalidOperationException("A non-null element was expected");
+ }
+
+ ///
+ /// Read the current element as a value.
+ ///
+ [SuppressMessage("Style", "IDE0018:Inline variable declaration", Justification = "No it can't - conditional")]
+ public readonly long ReadInt64()
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt64 + 1]);
+ long value;
+ if (!(span.Length <= RespConstants.MaxRawBytesInt64
+ && Utf8Parser.TryParse(span, out value, out int bytes)
+ && bytes == span.Length))
+ {
+ ThrowFormatException();
+ value = 0;
+ }
+
+ return value;
+ }
+
+ ///
+ /// Try to read the current element as a value.
+ ///
+ public readonly bool TryReadInt64(out long value)
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt64 + 1]);
+ if (span.Length <= RespConstants.MaxRawBytesInt64)
+ {
+ return Utf8Parser.TryParse(span, out value, out int bytes) & bytes == span.Length;
+ }
+
+ value = 0;
+ return false;
+ }
+
+ ///
+ /// Read the current element as a value.
+ ///
+ [SuppressMessage("Style", "IDE0018:Inline variable declaration", Justification = "No it can't - conditional")]
+ public readonly int ReadInt32()
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt32 + 1]);
+ int value;
+ if (!(span.Length <= RespConstants.MaxRawBytesInt32
+ && Utf8Parser.TryParse(span, out value, out int bytes)
+ && bytes == span.Length))
+ {
+ ThrowFormatException();
+ value = 0;
+ }
+
+ return value;
+ }
+
+ ///
+ /// Try to read the current element as a value.
+ ///
+ public readonly bool TryReadInt32(out int value)
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt32 + 1]);
+ if (span.Length <= RespConstants.MaxRawBytesInt32)
+ {
+ return Utf8Parser.TryParse(span, out value, out int bytes) & bytes == span.Length;
+ }
+
+ value = 0;
+ return false;
+ }
+
+ ///
+ /// Read the current element as a value.
+ ///
+ public readonly double ReadDouble()
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]);
+
+ if (span.Length <= RespConstants.MaxRawBytesNumber
+ && Utf8Parser.TryParse(span, out double value, out int bytes)
+ && bytes == span.Length)
+ {
+ return value;
+ }
+
+ switch (span.Length)
+ {
+ case 3 when "inf"u8.SequenceEqual(span):
+ return double.PositiveInfinity;
+ case 3 when "nan"u8.SequenceEqual(span):
+ return double.NaN;
+ case 4 when "+inf"u8.SequenceEqual(span): // not actually mentioned in spec, but: we'll allow it
+ return double.PositiveInfinity;
+ case 4 when "-inf"u8.SequenceEqual(span):
+ return double.NegativeInfinity;
+ }
+
+ ThrowFormatException();
+ return 0;
+ }
+
+ ///
+ /// Try to read the current element as a value.
+ ///
+ public bool TryReadDouble(out double value, bool allowTokens = true)
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]);
+
+ if (span.Length <= RespConstants.MaxRawBytesNumber
+ && Utf8Parser.TryParse(span, out value, out int bytes)
+ && bytes == span.Length)
+ {
+ return true;
+ }
+
+ if (allowTokens)
+ {
+ switch (span.Length)
+ {
+ case 3 when "inf"u8.SequenceEqual(span):
+ value = double.PositiveInfinity;
+ return true;
+ case 3 when "nan"u8.SequenceEqual(span):
+ value = double.NaN;
+ return true;
+ case 4 when "+inf"u8.SequenceEqual(span): // not actually mentioned in spec, but: we'll allow it
+ value = double.PositiveInfinity;
+ return true;
+ case 4 when "-inf"u8.SequenceEqual(span):
+ value = double.NegativeInfinity;
+ return true;
+ }
+ }
+
+ value = 0;
+ return false;
+ }
+
+ ///
+ /// Note this uses a stackalloc buffer; requesting too much may overflow the stack.
+ ///
+ internal readonly bool UnsafeTryReadShortAscii(out string value, int maxLength = 127)
+ {
+ var span = Buffer(stackalloc byte[maxLength + 1]);
+ value = "";
+ if (span.IsEmpty) return true;
+
+ if (span.Length <= maxLength)
+ {
+ // check for anything that looks binary or unicode
+ foreach (var b in span)
+ {
+ // allow [SPACE]-thru-[DEL], plus CR/LF
+ if (!(b < 127 & (b >= 32 | (b is 12 or 13))))
+ {
+ return false;
+ }
+ }
+
+ value = Encoding.UTF8.GetString(span);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Read the current element as a value.
+ ///
+ [SuppressMessage("Style", "IDE0018:Inline variable declaration", Justification = "No it can't - conditional")]
+ public readonly decimal ReadDecimal()
+ {
+ var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]);
+ decimal value;
+ if (!(span.Length <= RespConstants.MaxRawBytesNumber
+ && Utf8Parser.TryParse(span, out value, out int bytes)
+ && bytes == span.Length))
+ {
+ ThrowFormatException();
+ value = 0;
+ }
+
+ return value;
+ }
+
+ ///
+ /// Read the current element as a value.
+ ///
+ public readonly bool ReadBoolean()
+ {
+ var span = Buffer(stackalloc byte[2]);
+ switch (span.Length)
+ {
+ case 1:
+ switch (span[0])
+ {
+ case (byte)'0' when Prefix == RespPrefix.Integer: return false;
+ case (byte)'1' when Prefix == RespPrefix.Integer: return true;
+ case (byte)'f' when Prefix == RespPrefix.Boolean: return false;
+ case (byte)'t' when Prefix == RespPrefix.Boolean: return true;
+ }
+
+ break;
+ case 2 when Prefix == RespPrefix.SimpleString && IsOK(): return true;
+ }
+
+ ThrowFormatException();
+ return false;
+ }
+
+ ///
+ /// Parse a scalar value as an enum of type .
+ ///
+ /// The value to report if the value is not recognized.
+ /// The type of enum being parsed.
+ public readonly T ReadEnum(T unknownValue = default) where T : struct, Enum
+ {
+#if NET6_0_OR_GREATER
+ return ParseChars(static (chars, state) => Enum.TryParse(chars, true, out T value) ? value : state, unknownValue);
+#else
+ return Enum.TryParse(ReadString(), true, out T value) ? value : unknownValue;
+#endif
+ }
+
+ ///
+ /// Reads an aggregate as an array of elements without changing the position.
+ ///
+ /// The type of data to be projected.
+ public TResult[]? ReadArray(Projection projection, bool scalar = false)
+ {
+ var copy = this;
+ return copy.ReadPastArray(projection, scalar);
+ }
+
+ ///
+ /// Reads an aggregate as an array of elements, moving past the data as a side effect.
+ ///
+ /// The type of data to be projected.
+ public TResult[]? ReadPastArray(Projection projection, bool scalar = false)
+ {
+ if (Prefix is RespPrefix.Null) return null; // RESP3 nulls are neither aggregate nor scalar
+ DemandAggregate();
+ if (IsNull) return null;
+ var len = AggregateLength();
+ if (len == 0) return [];
+ var result = new TResult[len];
+ if (scalar)
+ {
+ // if the data to be consumed is simple (scalar), we can use
+ // a simpler path that doesn't need to worry about RESP subtrees
+ for (int i = 0; i < result.Length; i++)
+ {
+ MoveNextScalar();
+ result[i] = projection(ref this);
+ }
+ }
+ else
+ {
+ var agg = AggregateChildren();
+ agg.FillAll(result, projection);
+ agg.MovePast(out this);
+ }
+
+ return result;
+ }
+
+ public TResult[]? ReadPairArray(
+ Projection first,
+ Projection second,
+ Func combine,
+ bool scalar = true)
+ {
+ DemandAggregate();
+ if (IsNull) return null;
+ int sourceLength = AggregateLength();
+ if (sourceLength is 0 or 1) return [];
+ var result = new TResult[sourceLength >> 1];
+ if (scalar)
+ {
+ // if the data to be consumed is simple (scalar), we can use
+ // a simpler path that doesn't need to worry about RESP subtrees
+ for (int i = 0; i < result.Length; i++)
+ {
+ MoveNextScalar();
+ var x = first(ref this);
+ MoveNextScalar();
+ var y = second(ref this);
+ result[i] = combine(x, y);
+ }
+ // if we have an odd number of source elements, skip the last one
+ if ((sourceLength & 1) != 0) MoveNextScalar();
+ }
+ else
+ {
+ var agg = AggregateChildren();
+ agg.FillAll(result, first, second, combine);
+ agg.MovePast(out this);
+ }
+ return result;
+ }
+ internal TResult[]? ReadLeasedPairArray(
+ Projection first,
+ Projection second,
+ Func combine,
+ out int count,
+ bool scalar = true)
+ {
+ DemandAggregate();
+ if (IsNull)
+ {
+ count = 0;
+ return null;
+ }
+ int sourceLength = AggregateLength();
+ count = sourceLength >> 1;
+ if (count is 0) return [];
+
+ var oversized = ArrayPool.Shared.Rent(count);
+ var result = oversized.AsSpan(0, count);
+ if (scalar)
+ {
+ // if the data to be consumed is simple (scalar), we can use
+ // a simpler path that doesn't need to worry about RESP subtrees
+ for (int i = 0; i < result.Length; i++)
+ {
+ MoveNextScalar();
+ var x = first(ref this);
+ MoveNextScalar();
+ var y = second(ref this);
+ result[i] = combine(x, y);
+ }
+ // if we have an odd number of source elements, skip the last one
+ if ((sourceLength & 1) != 0) MoveNextScalar();
+ }
+ else
+ {
+ var agg = AggregateChildren();
+ agg.FillAll(result, first, second, combine);
+ agg.MovePast(out this);
+ }
+ return oversized;
+ }
+}
diff --git a/src/RESPite/Messages/RespScanState.cs b/src/RESPite/Messages/RespScanState.cs
new file mode 100644
index 000000000..0b8c99de2
--- /dev/null
+++ b/src/RESPite/Messages/RespScanState.cs
@@ -0,0 +1,161 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace RESPite.Messages;
+
+///
+/// Holds state used for RESP frame parsing, i.e. detecting the RESP for an entire top-level message.
+///
+[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)]
+public struct RespScanState
+{
+ /*
+ The key point of ScanState is to skim over a RESP stream with minimal frame processing, to find the
+ end of a single top-level RESP message. We start by expecting 1 message, and then just read, with the
+ rules that the end of a message subtracts one, and aggregates add N. Streaming scalars apply zero offset
+ until the scalar stream terminator. Attributes also apply zero offset.
+ Note that streaming aggregates change the rules - when at least one streaming aggregate is in effect,
+ no offsets are applied until we get back out of the outermost streaming aggregate - we achieve this
+ by simply counting the streaming aggregate depth, which is usually zero.
+ Note that in reality streaming (scalar and aggregates) and attributes are non-existent; in addition
+ to being specific to RESP3, no known server currently implements these parts of the RESP3 specification,
+ so everything here is theoretical, but: works according to the spec.
+ */
+ private int _delta; // when this becomes -1, we have fully read a top-level message;
+ private ushort _streamingAggregateDepth;
+ private RespPrefix _prefix;
+
+ public RespPrefix Prefix => _prefix;
+
+ private long _totalBytes;
+#if DEBUG
+ private int _elementCount;
+
+ ///
+ public override string ToString() => $"{_prefix}, consumed: {_totalBytes} bytes, {_elementCount} nodes, complete: {IsComplete}";
+#else
+ ///
+ public override string ToString() => _prefix.ToString();
+#endif
+
+ ///
+ public override bool Equals([NotNullWhen(true)] object? obj) => throw new NotSupportedException();
+
+ ///
+ public override int GetHashCode() => throw new NotSupportedException();
+
+ ///
+ /// Gets whether an entire top-level RESP message has been consumed.
+ ///
+ public bool IsComplete => _delta == -1;
+
+ ///
+ /// Gets the total length of the payload read (or read so far, if it is not yet complete); this combines payloads from multiple
+ /// TryRead operations.
+ ///
+ public long TotalBytes => _totalBytes;
+
+ // used when spotting common replies - we entirely bypass the usual reader/delta mechanism
+ internal void SetComplete(int totalBytes, RespPrefix prefix)
+ {
+ _totalBytes = totalBytes;
+ _delta = -1;
+ _prefix = prefix;
+#if DEBUG
+ _elementCount = 1;
+#endif
+ }
+
+ ///
+ /// The amount of data, in bytes, to read before attempting to read the next frame.
+ ///
+ public const int MinBytes = 3; // minimum legal RESP frame is: _\r\n
+
+ ///
+ /// Create a new value that can parse the supplied node (and subtree).
+ ///
+ internal RespScanState(in RespReader reader)
+ {
+ Debug.Assert(reader.Prefix != RespPrefix.None, "missing RESP prefix");
+ _totalBytes = 0;
+ _delta = reader.GetInitialScanCount(out _streamingAggregateDepth);
+ }
+
+ ///
+ /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted.
+ ///
+ /// True if a top-level RESP message has been consumed.
+ public bool TryRead(ref RespReader reader, out long bytesRead)
+ {
+ bytesRead = ReadCore(ref reader, reader.BytesConsumed);
+ return IsComplete;
+ }
+
+ ///
+ /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted.
+ ///
+ /// True if a top-level RESP message has been consumed.
+ public bool TryRead(ReadOnlySpan value, out int bytesRead)
+ {
+ var reader = new RespReader(value);
+ bytesRead = (int)ReadCore(ref reader);
+ return IsComplete;
+ }
+
+ ///
+ /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted.
+ ///
+ /// True if a top-level RESP message has been consumed.
+ public bool TryRead(in ReadOnlySequence value, out long bytesRead)
+ {
+ var reader = new RespReader(in value);
+ bytesRead = ReadCore(ref reader);
+ return IsComplete;
+ }
+
+ ///
+ /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted.
+ ///
+ /// The number of bytes consumed in this operation.
+ private long ReadCore(ref RespReader reader, long startOffset = 0)
+ {
+ while (_delta >= 0 && reader.TryReadNext())
+ {
+#if DEBUG
+ _elementCount++;
+#endif
+ if (!reader.IsAttribute & _prefix == RespPrefix.None)
+ {
+ _prefix = reader.Prefix;
+ }
+
+ if (reader.IsAggregate) ApplyAggregateRules(ref reader);
+
+ if (_streamingAggregateDepth == 0) _delta += reader.Delta();
+ }
+
+ var bytesRead = reader.BytesConsumed - startOffset;
+ _totalBytes += bytesRead;
+ return bytesRead;
+ }
+
+ private void ApplyAggregateRules(ref RespReader reader)
+ {
+ Debug.Assert(reader.IsAggregate, "RESP aggregate expected");
+ if (reader.IsStreaming)
+ {
+ // entering an aggregate stream
+ if (_streamingAggregateDepth == ushort.MaxValue) ThrowTooDeep();
+ _streamingAggregateDepth++;
+ }
+ else if (reader.Prefix == RespPrefix.StreamTerminator)
+ {
+ // exiting an aggregate stream
+ if (_streamingAggregateDepth == 0) ThrowUnexpectedTerminator();
+ _streamingAggregateDepth--;
+ }
+ static void ThrowTooDeep() => throw new InvalidOperationException("Maximum streaming aggregate depth exceeded.");
+ static void ThrowUnexpectedTerminator() => throw new InvalidOperationException("Unexpected streaming aggregate terminator.");
+ }
+}
diff --git a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt
new file mode 100644
index 000000000..ab058de62
--- /dev/null
+++ b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000..d81dc0388
--- /dev/null
+++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt
@@ -0,0 +1,159 @@
+#nullable enable
+[SER004]const RESPite.Buffers.CycleBuffer.GetAnything = 0 -> int
+[SER004]const RESPite.Buffers.CycleBuffer.GetFullPagesOnly = -1 -> int
+[SER004]RESPite.Buffers.CycleBuffer
+[SER004]RESPite.Buffers.CycleBuffer.Commit(int count) -> void
+[SER004]RESPite.Buffers.CycleBuffer.CommittedIsEmpty.get -> bool
+[SER004]RESPite.Buffers.CycleBuffer.CycleBuffer() -> void
+[SER004]RESPite.Buffers.CycleBuffer.DiscardCommitted(int count) -> void
+[SER004]RESPite.Buffers.CycleBuffer.DiscardCommitted(long count) -> void
+[SER004]RESPite.Buffers.CycleBuffer.GetAllCommitted() -> System.Buffers.ReadOnlySequence
+[SER004]RESPite.Buffers.CycleBuffer.GetCommittedLength() -> long
+[SER004]RESPite.Buffers.CycleBuffer.GetUncommittedMemory(int hint = 0) -> System.Memory
+[SER004]RESPite.Buffers.CycleBuffer.GetUncommittedSpan(int hint = 0) -> System.Span
+[SER004]RESPite.Buffers.CycleBuffer.PageSize.get -> int
+[SER004]RESPite.Buffers.CycleBuffer.Pool.get -> System.Buffers.MemoryPool!
+[SER004]RESPite.Buffers.CycleBuffer.Release() -> void
+[SER004]RESPite.Buffers.CycleBuffer.TryGetCommitted(out System.ReadOnlySpan span) -> bool
+[SER004]RESPite.Buffers.CycleBuffer.TryGetFirstCommittedMemory(int minBytes, out System.ReadOnlyMemory memory) -> bool
+[SER004]RESPite.Buffers.CycleBuffer.TryGetFirstCommittedSpan(int minBytes, out System.ReadOnlySpan span) -> bool
+[SER004]RESPite.Buffers.CycleBuffer.UncommittedAvailable.get -> int
+[SER004]RESPite.Buffers.CycleBuffer.Write(in System.Buffers.ReadOnlySequence value) -> void
+[SER004]RESPite.Buffers.CycleBuffer.Write(System.ReadOnlySpan value) -> void
+[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw() -> bool
+[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool
+[SER004]static RESPite.Buffers.CycleBuffer.Create(System.Buffers.MemoryPool? pool = null, int pageSize = 8192) -> RESPite.Buffers.CycleBuffer
+[SER004]const RESPite.Messages.RespScanState.MinBytes = 3 -> int
+[SER004]override RESPite.Messages.RespScanState.Equals(object? obj) -> bool
+[SER004]override RESPite.Messages.RespScanState.GetHashCode() -> int
+[SER004]override RESPite.Messages.RespScanState.ToString() -> string!
+[SER004]RESPite.Messages.RespAttributeReader