A read reports progress rather than promising a full frame
A consumer often knows that the next field occupies four bytes, but Stream.Read does not generally promise to fill a four-byte destination in one call. It reports the bytes actually copied. A positive short read therefore means progress, while the remainder of a fixed-size field still needs attention. This lesson makes that possibility deterministic with a real Stream subclass that caps each read at two bytes. It wraps memory rather than pretending to contact a server. The distinction belongs to the stream contract and does not depend on unreliable timing or a slow internet connection.
View full size ↗Read the diagram
The source contains four example byte values, in order: 10, 20, 30 and 40. A four-byte destination buffer is supplied, but this particular stream returns at most two bytes per read.
The illustrated call requests four bytes and returns two. It copies 10 and 20 into the first two destination cells, consuming two bytes. Values 30 and 40 remain unread. Position here describes logical progress; a nonseekable Stream can reject its Position getter, as the lesson's capped stream does.
Only the first two destination cells belong to this call's returned payload. The question marks mark cells outside the returned count; the call does not establish their contents. Buffer capacity is not the number of bytes returned.
A positive short result does not establish end of stream. The lesson's real capped stream demonstrates repeated reads and Stream.ReadExactly using byte values 1, 2, 3 and 4 instead of the image's illustrative 10, 20, 30 and 40. Requesting more bytes than exist under an exact-read contract throws EndOfStreamException. The image shows one ordinary read, not the multi-read behavior of ReadExactly.
Illustration created with ImageGen for SharpRune.
Compare a single read, a complete header and a truncated header
The first scoped input contains four bytes and receives a request for four, but its implementation copies only two, so the first print is 2. The second scoped input starts fresh. ReadExactly repeatedly invokes reads until all four destination slots are filled, producing hexadecimal 01020304. The final scoped input still contains only four bytes, but its destination requires five. Once those four are consumed, the next nonempty request reaches end-of-input and ReadExactly throws EndOfStreamException. The catch prints truncated header. Separate inputs prevent an earlier experiment's cursor from accidentally influencing a later result.
Preserve the original arguments before applying a cap
A custom stream must still honor its base API's argument contract. The array overload validates a null buffer, negative offset, negative count and the original requested range before reducing the count to two. Otherwise a caller requesting five bytes into a four-byte array could incorrectly succeed after the cap concealed its invalid request. The span overload receives an already valid span and slices its length safely. Both paths return zero for an empty request without inventing an end condition. CanRead delegates to the inner stream so capability reporting also reflects disposal rather than remaining permanently true.
Partial consumption is not automatically undone
When ReadExactly throws for the five-byte destination, it has already consumed available input and may have written a prefix into the destination. The failure does not rewind the source or clear that prefix. A parser should therefore publish a complete record only after its framing requirements succeed, rather than exposing a partially filled array as a valid header. Catch the expected truncation at a boundary that knows how to report it; do not replace missing bytes with zero padding unless the format explicitly defines that behavior. This lesson diagnoses incomplete data instead of silently changing its meaning.
Keep the classroom adapter deliberately synchronous
The subclass implements the synchronous read operations used by this example and rejects seeking and writing. Its purpose is a contract test, not a complete reusable transport implementation. The sample does not call inherited asynchronous members that might use a different implementation strategy, and it creates no worker thread. Disposing the adapter disposes its owned inner memory stream. A real asynchronous parser should use the appropriate asynchronous exact-read API and pass its cancellation token through the operations it actually invokes. Contract-focused test doubles are most useful when their supported surface and ownership are explicit.
Vary chunking independently from record length
First change the cap from two to one in both read overloads. Predict which of the three printed lines changes before you run the sample, and explain why complete-header correctness must not depend on the number of successful internal reads. Then restore the cap and change only the final destination length from five to four. Predict whether the catch executes and add a success print in that branch. These changes isolate the transport's chunking behavior from the format's required size, making it harder to confuse a convenient large read with a framing guarantee.
- Hint for a cap of one: the first line becomes one; the four-byte exact read still reconstructs all four bytes.
- Hint for the final destination: four available bytes satisfy a four-byte requirement without a truncation exception.
Test boundary behavior as well as the happy path
The companion framing project adds independent invalid-argument checks so the cap cannot hide a bad caller request. You can also test an empty span, a destination exactly matching the source, and a destination one byte too long. Review each result using requested count, returned count and total accumulated count as separate numbers. For a nonzero-length request, a zero result indicates end; a short positive result alone does not. This vocabulary transfers directly to binary headers, compressed data and response bodies while avoiding claims that every stream supports positions or that every record can be retried after partial consumption.
- Validate the original range before altering a custom read request.
- Accept a framed value only after its full required byte count has arrived.
- Document whether a failed consumer has already advanced the input.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using (var input = new CappedReadStream(new byte[] { 1, 2, 3, 4 })) { byte[] buffer = new byte[4]; Console.WriteLine(input.Read(buffer, 0, buffer.Length)); }
using (var input = new CappedReadStream(new byte[] { 1, 2, 3, 4 })) { byte[] header = new byte[4]; input.ReadExactly(header); Console.WriteLine(Convert.ToHexString(header)); }
using (var input = new CappedReadStream(new byte[] { 1, 2, 3, 4 })) { try { input.ReadExactly(new byte[5]); } catch (EndOfStreamException) { Console.WriteLine("truncated header"); } }
sealed class CappedReadStream : Stream {
private readonly MemoryStream inner; public CappedReadStream(byte[] data) { inner = new MemoryStream(data, writable: false); }
public override bool CanRead => inner.CanRead; public override bool CanSeek => false; public override bool CanWrite => false;
public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count) {
ArgumentNullException.ThrowIfNull(buffer); ArgumentOutOfRangeException.ThrowIfNegative(offset); ArgumentOutOfRangeException.ThrowIfNegative(count);
if (offset > buffer.Length - count) throw new ArgumentException("Invalid buffer range");
return inner.Read(buffer, offset, Math.Min(count, 2));
}
public override int Read(Span<byte> buffer) => inner.Read(buffer.Slice(0, Math.Min(buffer.Length, 2)));
public override void Flush() { } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing) { if (disposing) inner.Dispose(); base.Dispose(disposing); }
}
Expected output
2 01020304 truncated header
A fixed-size frame needs repeated reads; ReadExactly reports premature end rather than padding the data.