Go Binary Protocols
Work through examples, try the reasoning yourself, then put it into practice.
View SlidesBefore you start
- Manipulate Go byte slices and bit shifts.
- Explain slice aliasing and a caller-owned receive buffer.
What you will practice
- Accumulate a uint64 varint and classify incomplete versus overflowing prefixes.
- Decode exactly one frame with overflow-safe limits and consumed-byte accounting.
- Explain when a zero-copy payload must be copied and measure parser allocations.
In this lesson
Lesson
7 min readGo Binary Protocols: Framing, Varints, and Buffer Ownership
A stream delivers bytes, not messages. A read can stop halfway through a length, halfway through a payload, or after several frames. Your decoder must identify one complete frame without losing the bytes needed for a retry.
You should know Go slices, integer shifts, and error returns. After this module, you should be able to derive a varint length byte by byte, classify incomplete and malformed inputs, check bounds without integer overflow, and explain how long a returned payload remains valid.
One frame and one contract
The exercise format is [unsigned-varint length][payload bytes]. Length counts
only payload bytes. DecodeFrame(buf,maxLen) decodes the first frame; trailing
bytes belong to later frames. On success, consumed counts both the length
prefix and payload. On every error, it returns nil,0,error and leaves input
unchanged. There is no saved decoder state: retries pass the entire unconsumed
buffer again.
The return errors describe different decisions:
| Stage | Condition | Result |
|---|---|---|
| Length | Prefix can still become a valid uint64 | ErrNeedMore |
| Length | Prefix cannot encode a uint64 | ErrMalformedVarint |
| Limit | Complete length exceeds maxLen |
ErrFrameTooLarge |
| Body | Valid allowed length exceeds bytes available | ErrNeedMore |
| Done | Whole first frame is present | payload slice and consumed count |
A declared size over the limit is rejected even if its payload has not arrived.
A partial varint is classified before checking the frame limit. maxLen is a
nonnegative int in this exercise.
Derive unsigned varints
Each byte contributes seven low bits; the high bit says another byte follows.
The groups are least significant first. At zero-based byte index i, add
uint64(b & 0x7f) << (7*i) after checking the boundary rules.
Decode bytes AC 02 (hex):
| Index | Byte | Continue bit | Seven data bits as decimal | Contribution | Accumulator |
|---|---|---|---|---|---|
| 0 | AC |
1 | 44 | 44 << 0 = 44 |
44 |
| 1 | 02 |
0 | 2 | 2 << 7 = 256 |
300 |
Thus 300 bytes require a two-byte prefix, and a complete frame consumes 302
bytes. Converting in the other direction, 300 = 44 + 2*128: encode 44 with
continuation (0xAC), followed by the final group 2 (0x02). A one-byte zero
prefix describes a complete empty payload and still consumes one byte.
This format permits non-minimal encodings: 80 00 also means zero. Canonical
encoding is a separate protocol rule, not an implicit overflow check here.
Why the tenth byte is special
Nine groups cover 63 bits. A uint64 has just one bit left for the tenth group,
so byte 10 (index 9) must terminate and can only be 00 or 01.
| Prefix | Meaning |
|---|---|
Nine 80 bytes, no tenth byte |
Incomplete: another byte could terminate it |
Nine 80 bytes then 00 |
Valid non-minimal zero |
Nine FF bytes then 01 |
Valid 2^64-1; still subject to maxLen |
Nine 80 bytes then 02 |
Overflow; malformed |
Ten 80 bytes |
Malformed now; an eleventh byte cannot repair it |
Do not let a shift silently discard overflow bits before validating byte 10.
Go's encoding/binary.Uvarint
returns a positive count on success, zero for incomplete input, and a negative
count for overflow. Inspect its implementation
when comparing a helper with the exercise's error mapping: an unterminated
ten-byte prefix must be rejected immediately by this frame contract.
Trace an incremental receive buffer
Suppose a sender emits a 130-byte payload with prefix 82 01:
| Bytes retained so far | Decoder state | Result and caller action |
|---|---|---|
82 |
First group is 2, continuation set | Need more; retain that byte |
82 01 plus 40 payload bytes |
Length 130, prefix 2, body 40 | Need more; retain all 42 bytes |
82 01 plus 130 payload bytes |
Whole frame available | Success; consume 132 bytes |
Consuming the two prefix bytes at the middle row would lose the length on the next stateless call. Consuming zero means the caller can append new bytes and retry the same frame.
For 02 'h' 'i' 01 '!', the first call returns "hi", consumed 3. Once the
caller has finished using that payload, it can decode buf[3:], which returns
"!", consumed 2. A read boundary is never used as a frame boundary.
Bounds checks must remain valid at extreme lengths
Decode into uint64 first. After a complete length value is known, compare
value > uint64(maxLen) before converting it to int. That proves conversion
is representable on the current architecture.
If the prefix uses n bytes, test length > len(buf)-n. Prefix success already
proves n <= len(buf), so subtraction is safe. The tempting check
n+length > len(buf) can overflow when length is near the largest int, even
though conversion itself was valid. Only calculate the end offset after the
remaining-byte check succeeds.
For example, a valid prefix declaring MaxInt bytes with maxLen=MaxInt and no
payload must return ErrNeedMore; it must not wrap an addition and panic while
slicing. No enormous allocation is necessary to test this boundary.
Zero-copy means shared ownership of backing memory
On success, payload := buf[n:end] shares the backing array. Consider:
buf := []byte{2, 'h', 'i', 1, '!'}
payload, _, _ := DecodeFrame(buf, 10)
// payload currently contains "hi".
buf[1] = 'H'
// payload now contains "Hi": it was never an independent copy.
The reverse also holds: writing a payload byte changes buf. If the receive
loop moves trailing bytes to the front with copy, reuses the array for a new
read, or returns it to a pool, saved payload slices may change. Process them
before reuse, transfer buffer ownership, or explicitly copy the payload before
retaining it. Slicing buf = buf[consumed:] alone does not overwrite the old
bytes, but later compaction or reuse can. Aliasing does not provide concurrency
synchronization, and retaining a tiny slice can keep a large array alive.
The decoder cannot promise both zero-copy and independent long-lived payloads; that choice belongs to its caller.
Measure before choosing an optimization
Using encoding/binary is not inherently slower or allocating. Compare a
correct custom parser and a helper-based parser on representative lengths,
fragmented prefixes, incomplete bodies, and rejected sizes. Adapt error
classification before comparing results.
Allocate test inputs outside the timed loop, use a package-level sink so work
remains observable, call b.ReportAllocs(), and run a benchmark repeatedly:
go test -run '^$' -bench DecodeFrame -benchmem -count=5
Use this command in the implementation's test package; the exercise's
AllocsPerRun check enforces no decoder allocations on successful input.
Report both time and allocations with the input size and Go version. Zero
allocations does not prove a parser faster, and a deliberate ownership copy in
the caller can be the correct tradeoff.
Practice before the exercise
- Decode
85 01. What is the payload length? How many total bytes would a complete frame consume? - With
maxLen=4, what does[05]return? What about[80]? - A parser returns an alias then its caller overwrites the receive array. Which component must preserve data needed beyond that overwrite?
Check your reasoning
5 + 128 = 133; with a two-byte prefix, total consumption is 135.[05]is a complete length exceeding the limit:ErrFrameTooLarge.[80]is an incomplete prefix:ErrNeedMore. Both consume zero.- The caller must copy or preserve ownership of the backing memory. A successful parse alone supplies no independent storage lifetime.
Now implement DecodeFrame, then take the checkpoint using fresh byte streams.
If incomplete and malformed inputs are confused, revisit the tenth-byte table;
if a retained payload changes, trace who owns and writes its backing array.
Practice sequence
Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.