Repository navigation
Description
Background and motivation
CborReader uses single buffer and when a buffer ends mid item PeekState() and Read* methods throw CborContentException , and Reset(ReadOnlyMemory<byte>) the only API to update the buffer discards all nesting state.
So consumers reading CBOR from a Stream in fixed chunks must either load the whole stream or drive control flow with try/catch.
Utf8JsonReader on the other hand fixes this gap by adding isFinalBlock and CurrentState. CBOR should have similar synchronous incremental mode, with the caller owning I/O (sync or async).
API Proposal
namespace System.Formats.Cbor;
public partial enum CborReaderState
{
NeedsMoreData,
}
public partial class CborReader
{
// Existing:
// public CborReader(ReadOnlyMemory<byte> data, CborReaderOptions? options);
// So isFinalBlock could be defaulted, or not.
//
// throws ArgumentException if isFinalBlock is false and options.ConformanceMode != Lax.
public CborReader(ReadOnlyMemory<byte> data, CborReaderOptions? options, bool isFinalBlock);
// Replaces the current _data and sets the instance _offset to 0, but does not reset the
// array/map nesting stack.
//
// throws InvalidOperationException if _isFinalBlock is true (cannot Slide once the end is known)
public void SlideData(ReadOnlyMemory<byte> data, bool isFinalBlock);
// Replaces the current _data and sets the instance _offset to 0, and resets
// array/map nesting stack.
//
// Existing: public void Reset(ReadOnlyMemory<byte> data);
public void Reset(ReadOnlyMemory<byte> data, bool isFinalBlock);
// true: All cases where SkipValue would not throw. _offset has moved to the end of the value.
// false: SkipValue would have thrown "unexpected end of buffer" and _isFinalBlock is false
// throws: All other reasons (e.g. malformed input)
//
// Note: Until/unless a solution is landed on to enable isFinalBlock for !Lax,
// the boolean has no measurable effect. But it matches the signature of SkipValue.
public bool TrySkipValue(bool disableConformanceModeChecks = false);
public bool TrySkipToParent(bool disableConformanceModeChecks = false);
}- ctor or ResetData with isFinalBlock:true preserve today's behavior for all methods
- The new SlideData method will throw InvalidOperationException
- isFinalBlock:false
- Ctor throws unless conformance mode is Lax (v1 restriction)
- Read* still succeeds or throws as today
- SkipValue and SkipToParent still succeed or throw as today
- PeekState reports NeedsMoreData when the object at the current offset is incomplete, rather than throwing CborContentException. Still throws in other cases.
- TrySkipValue/TrySkipToParent return true when SkipValue/SkipToParent would succeed, false when they would fail due to data truncation, throw when data is invalid (e.g. an indefinite map with an odd number of entries, violating key/value parity)
- SlideData resets _data to the new value and _offset to 0, but remembers CurrentDepth and the state push/pop stack.
- Reset resets _data to the new value, _offset to 0, and clears the state push/pop stack (resetting CurrentDepth to 0).
API Usage
static async Task<MyValue> ReadCborFromStreamAsync(Stream stream, CancellationToken ct)
{
// Two-arg Reset is supported only in Lax conformance mode.
var reader = new CborReader(ReadOnlyMemory<byte>.Empty, CborConformanceMode.Lax);
byte[] buffer = new byte[4096];
int dataLength = await stream.ReadAsync(buffer, ct);
bool isFinalBlock = dataLength == 0;
reader.Reset(buffer.AsMemory(0, dataLength), isFinalBlock);
MyValue result = default;
while (true)
{
switch (reader.PeekState())
{
case CborReaderState.NeedsMoreData:
// In Lax the unconsumed tail IS the leftover, so BytesRemaining is all we need.
int keep = reader.BytesRemaining;
int start = dataLength - keep;
if (keep == buffer.Length) // one item bigger than the buffer
Array.Resize(ref buffer, buffer.Length * 2);
Buffer.BlockCopy(buffer, start, buffer, 0, keep);
int read = await stream.ReadAsync(buffer.AsMemory(keep), ct);
dataLength = keep + read;
isFinalBlock = read == 0;
reader.SlideData(buffer.AsMemory(0, dataLength), isFinalBlock); // nesting preserved
continue;
case CborReaderState.Finished:
return result; // whole document consumed
case CborReaderState.*: //One token read is safe as `PeekState() == NeedsMoreData` checked before as the non-throwing gate
result = reader.Read*();
break;
// ... other typed cases ...
default:
throw new InvalidOperationException($"Unexpected state {reader.PeekState()}.");
}
}
}Alternative Designs
Stream/async overloads on CborReader (the rejected #99993 ask). Implies async variants of every Read* (the maintainer objection). The isFinalBlock model keeps the reader synchronous and the caller owns I/O.
External JsonReaderState-style state struct + new-reader-per-chunk. Exists only because Utf8JsonReader is a ref struct however CborReader is a class, so in-place preservation via the Reset overload is simpler.
Changing TryReadByteString/TryReadTextString to also return false on needs-more-data: overloads false with two incompatible recoveries (grow output vs refill input) the caller can't disambiguate, and is redundant because the PeekState() gate already guarantees a definite string is buffered before the read. TrySkipValue is the sole Try* that signals needs-more-data, justified because no single-token peek can gate a multi-token subtree skip and its false is unambiguous.
Risks
Additive enum member. CborReaderState.NeedsMoreData can appear in a switch with { default: throw } but it is returned only when the caller opts into non-final mode, so existing single-buffer callers shouldn't observe it.
PeekState() behavior change is scoped to non-final mode. Final mode (and the one-arg Reset) is byte-for-byte unchanged.
Buffer-size floor. Non-final mode can't progress if the buffer is smaller than the largest single definite string value. Same as the documented Utf8JsonReader constraint.
TrySkipValue buffers the skipped subtree (rewind-and-retry); fine for small unknown fields, resumable streaming skip is can be added as a follow up if needed.
NotSupportedException for non-Lax is a deliberate scope limit and doesn't have to be permanent.
Activity
dotnet-policy-service commentedon Jun 22, 2026
Tagging subscribers to this area: @dotnet/area-system-io
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Jun 22, 2026
Tagging subscribers to this area: @dotnet/area-system-formats-cbor, @bartonjs, @vcsjones
See info in area-owners.md if you want to be subscribed.
bartonjs commentedon Jun 22, 2026
I certainly wouldn't expect a method called "Reset" to be state-preserving. Instead, I'd expect that to be "I've finished reading that thing, here's some new data, start back at 0". This seems more like "ExtendData" or maybe even just "Extend". (I like "ExtendData" over "ReplaceData" because it better suggests currentData.SequenceEquals(newData.Slice(0, currentData.Length))... whether or not we choose to enforce that at runtime)
@eiriktsarpalis Do you remember if we had any thoughts regarding this back when we first built it?
muhammad-othman commentedon Jun 23, 2026
Thanks you @bartonjs for the quick review of this proposal.
I agree "Reset" is a bit misleading and I'm good with renaming it. I was borrowing from
Utf8JsonReaderwith itsisFinalBlock, and the caller owning the read/refill loop. JSON reconstructs the reader each chunk so I was thinking thatResetcan act similar to JSON construction in addition to keeping the API change small. But yeah it can be misleading.One thing I want to make sure I understand, because there are two ways I read "extend" and they behave very differently:
NeedsMoreData, the caller takes the bytes the reader hasn't consumed yet (the trailingBytesRemainingbytes), moves them to the front of the buffer, reads more bytes in after them, and passes that. The reader keeps its nesting state but starts again from index 0 of this new buffer.And I agree it shouldn't be enforced on every call since comparing the bytes each refill would degrade the performance, just documenting the expectation and maybe add
Debug.Assert,.bartonjs commentedon Jun 23, 2026
"Keep the whole buffer and append to it" is what I had in mind with a name like ExtendData. If it's "reset the cursor position to 0 while maintaining the stack" that feels like another verb. Shift/Slide/Augment/Transition/...?
eiriktsarpalis commentedon Jun 23, 2026
The key motivating use case at the time was CTAP and we were operating under the assumption that large payloads were out of scope.
muhammad-othman commentedon Jun 23, 2026
@bartonjs I was trying to move toward the bounded version where the consumed bytes are discarded to decrease the memory allocation and the leftover are moved to the start of the buffer then the rest is filled with a new read so we don't have to expand the buffer size unless a single item doesn't fit. From your suggestions I feel
Slidemakes mode sense as the sliding window mental model seems the closest to what I'm trying to describe.@eiriktsarpalis Thank you for the context, now it make sense why streaming wasn't in the scope initially. For our use case we're deserializing AWS service responses over HTTPS, where payloads can be tens of megabytes or even more, the larges service that uses CBOR right now is CloudWatch and other services are expected to support too.
muhammad-othman commentedon Jul 10, 2026
I believe we have agreed on
Slideas the naming. Is there anything else needed before this can move to review?bartonjs commentedon Jul 20, 2026
I don't know that we have "agreed" on
Slide, though it was a word I had spat out, so in that sense I guess it has two party assent 😄. I don't know that we've ever done something like that in the BCL, so I don't know that we have naming prior art. But it could be a placeholder to make progress into API Review.However, I don't know that it can even function all that well. ReadStartMap pushes the offset onto the state stack, and that offset is restored on pop. I believe the reader uses the offset to go back and check on dictionary key conflicts (when it needs to), so either a) Slide can't slide beyond the first offset saved in that stack, or b) the stack needs to change to making copies of the keys when in non-final mode.
Whether the continuation mode be from Expand or Slide, I imagine we'd also want
So that the expected operation isn't
but just
muhammad-othman commentedon Jul 20, 2026
@bartonjs You said
Slide, it means you agreed on it 😅. The closest pattern I have found isUtf8JsonReaderconstructor which acceptsisFinalBlockin addition to the previous state, though I don't think we should imitate that inCborReaderconstructor asCborReaderclass that doesn't have the same limitations thatUtf8JsonReaderstruct has.I initially suggest using
Resetto avoid creating a new method and limit the surface API changes, but the names that you have suggested works too, I lean towardShiftorSlidebut I don't really have a strong opinion regarding that.For the
check on dictionary key conflictsissue, based on my understand I believe that this doesn't happen whenConformanceMode == Laxwhich is why I suggested supportingLaxonly for this iteration.If we want to support other
ConformanceModewe will need to implement the key copying you have mentioned, and I think it is reasonable to implement this as a follow up rather than on shot all theConformanceModes.The constructor looks much nicer than construct then reset approach for the first block, but we still need the continuation method to add new chunks while preserving the state.
Please let me know if I missed something or any of my assumptions are incorrect.
11 remaining items
muhammad-othman commentedon Jul 23, 2026
Thank you so much @bartonjs for taking care of that.
bartonjs commentedon Aug 11, 2026
Video
Looks good as proposed
muhammad-othman commentedon Aug 12, 2026
Thank you @bartonjs for discussing and approving this, I will work on the implementation and open a PR soon.
Add incremental chunked reading to CborReader
vcsjones commentedon Oct 3, 2026
@muhammad-othman @bartonjs it looks like this has been merged and backported to 11.0 - is there anything pending here or can this be closed?
muhammad-othman commentedon Oct 4, 2026
Yes Kevin we can close it, nothing else needed after #133602 was merged.
Thank you Jeremy for closing it.