Skip to content
Select theme

Parsing and encoding RESP

Step 3 of 10. Step 2 ended at the tag mini-ch02 with a server that reads one chunk, decodes one frame, and answers PING. This step starts from that tag — git checkout mini-ch02 — and ends at mini-ch03, where a connection keeps the bytes it has read: a frame split across two packets is reassembled instead of refused, a second frame in the same read is answered instead of dropped, and ECHO is the command that proves both.

read does not return “one message”. It returns whatever bytes have arrived — between one and the size of the buffer — and nothing on the wire marks where a frame ends; that is what the length prefixes in RESP are for. One write_all from a client can arrive as three reads, and three write_alls can arrive as one. Step 2’s serve assumed the tidy case, and the two gaps it closed with were both the same missing buffer:

  • a frame split across two packets ended the connection with a frame arrived in pieces;
  • a second frame in the same chunk was dropped, because decode reported how many bytes it used and serve ignored the number.

One buffer fixes both, and it lives on the connection, because “read but not yet a frame” is per-connection state.

Connection owns the stream and a Vec<u8>, and read_frame is the whole step. It appears in full in the block below; its four exits are each a decision step 2 made per read instead of per frame:

  • Ok((frame, used)) — the frame is out. drain(..used) removes exactly its bytes, and whatever follows it in the buffer stays for the next call.
  • Err(DecodeError::Incomplete) — not all here yet, so fall through and read more. An empty buffer is Incomplete too, which is why the first call always reads.
  • Err(DecodeError::Invalid(why)) — every byte is present and they are still not RESP, so waiting cannot help. It becomes an io::Error, the connection closes, and nothing is written back.
  • a read of zero bytes — a clean hangup, reported as Ok(None). A half-arrived frame in the buffer goes with it, because the connection is over either way.

The block below is the reader on its own: the step 2 codec, the new Connection, and a main where a peer thread plays the client. The peer writes a PING in two pieces with a pause between them, then — after a second pause — an ECHO and a PING in a single write, and the main thread reads frames the way a server does: printing each frame, and how many bytes are still buffered behind it.

Array([Bulk([112, 105, 110, 103])]) (0 bytes still buffered)
Array([Bulk([101, 99, 104, 111]), Bulk([104, 101, 108, 108, 111])]) (14 bytes still buffered)
Array([Bulk([112, 105, 110, 103])]) (0 bytes still buffered)

Three frames, and the second line is the pipelining proof: when the ECHO frame is decoded, fourteen bytes are already sitting behind it — the PING that arrived in the same write, waiting for the next call. The first line is the other half: a frame whose bytes arrived in two reads comes back whole. Bulk printing as [112, 105, 110, 103] instead of "ping" is step 2’s point again — the frame layer holds bytes, and only the command layer decides they are text.

Step 2’s decode already returned used, and step 2’s serve threw it away. read_frame drains exactly that many bytes, so the next call starts at the next frame — and when the buffer already holds a whole one, it decodes without touching the socket at all. One test pins that with the socket never read: a Connection whose buffer starts with two whole PING frames answers the first one immediately, and fourteen bytes are still buffered behind it.

The cost of drain is worth naming: every frame slides the remaining bytes down the buffer, so a pipelined burst of n frames copies the tail n times. A cursor into the buffer would avoid the copy. One command at a time it does not matter, and the fix belongs where the bursts do.

The command table gains a second entry, and the second entry is where the shape repeats: both PING-with-an-argument and ECHO want “the one bulk argument”. A small helper extracts it with a slice pattern — [_, Frame::Bulk(message)] => Some(message.as_slice()), and None for everything else — so None covers both wrong arity and an argument that is not a bulk string. The real server’s replies, captured over a raw socket to the redis-server 7.2.4 on this machine (\r\n written out as the two bytes they are):

*2\r\n$4\r\necho\r\n$5\r\nhello\r\n -> $5\r\nhello\r\n
*1\r\n$4\r\necho\r\n -> -ERR wrong number of arguments for 'echo' command

The clone matches both, wording included. One divergence, small and named rather than hidden: send an integer as the argument and the real server answers -ERR Protocol error: expected '$', got ':' (captured the same way), while the clone’s argument returns None and the wrong-args error goes out — the wrong reason, for a frame whose arity was right. No client library sends that frame, and tightening it can wait for a step with more commands than two.

The tag mini-ch03 is one file, and this is it byte for byte. The codec is unchanged from step 2; Connection, the rewritten serve, and ECHO are the new parts.

Thirteen tests, up from seven: the step 2 set, carried over, plus six for the new behaviour. One decodes the captured ECHO hello wire form and re-encodes the reply to $5\r\nhello\r\n. One refuses ECHO with one argument and with three, checking the real server’s wording. One speaks over a real socket with the request cut inside the payload — two writes, a pause, then the reply. One writes two frames in a single write_all and reads two replies. One starts Connection with two frames already buffered and checks the second survives the first. One sends ?\r\n and checks the connection closes with no reply.

$ cargo test
Compiling mini-redis v0.1.0
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
Running unittests src/main.rs (/Volumes/RustBuild/tracks/debug/deps/mini_redis-f7b0b7e7128a87e0)
running 13 tests
test tests::a_frame_in_two_pieces_is_incomplete_not_invalid ... ok
test tests::bytes_that_are_not_resp_are_invalid ... ok
test tests::echo_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::echo_round_trips_through_the_codec ... ok
test tests::every_frame_type_round_trips ... ok
test tests::one_decode_consumes_exactly_one_frame ... ok
test tests::ping_with_an_argument_echoes_it ... ok
test tests::the_exact_bytes_the_real_redis_cli_sends_for_ping ... ok
test tests::a_buffered_second_frame_needs_no_read ... ok
test tests::a_protocol_error_ends_the_connection_without_a_reply ... ok
test tests::ping_over_a_real_socket ... ok
test tests::pipelined_commands_are_all_answered ... ok
test tests::a_frame_split_across_two_reads_is_reassembled ... ok
test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s

The order the tests print in varies between runs; the set does not. The Running line names this machine’s shared build cache; elsewhere it reads target/debug/deps/mini_redis-….

cargo run on this machine stops before it listens, because a real redis-server already owns 127.0.0.1:6379:

$ cargo run
Running `/Volumes/RustBuild/tracks/debug/mini-redis`
Error: Os { code: 48, kind: AddrInUse, message: "Address already in use" }

To check the clone without disturbing the server that owns the port, the same binary was rebuilt on a scratch port and the real redis-cli pointed at it:

$ redis-cli -p 7778 ping
PONG
$ redis-cli -p 7778 echo hello
hello
$ redis-cli -p 7778 echo
ERR wrong number of arguments for 'echo' command
$ redis-cli -p 7778 echo a b
ERR wrong number of arguments for 'echo' command

And the two gaps from step 2, closed over raw sockets to that same server — first a request cut inside the payload with a pause between the halves, then two frames in one write:

*2\r\n$4\r\necho\r\n$5\r\nhel (a pause) lo\r\n -> $5\r\nhello\r\n
*1\r\n$4\r\nping\r\n*2\r\n$4\r\necho\r\n$5\r\nhello\r\n -> +PONG\r\n$5\r\nhello\r\n
  • Inline commands are still not spoken. A bare PING\r\n with no array around it is legal RESP2 and the real server answers it; the clone accepts arrays only, the dialect every client library uses. Unchanged from step 2.
  • The buffer has no cap. It grows with what the client sends. The real server caps a single bulk string at proto-max-bulk-len — 536870912 on this machine, read with redis-cli CONFIG GET — and keeps per-client query buffers besides. The clone will buffer whatever arrives, which is fine for a tutorial port and not fine for a public one.
  • A hangup mid-frame is silent. The partial bytes in the buffer are discarded with the connection, and nothing is logged about them: read_frame reports Ok(None), serve returns Ok(()), and main only prints on an error.
  • Connection is synchronous. Step 4 puts one task on each connection, which means an async reader with the same loop shape — the Incomplete/Invalid split survives the move, because it is about the buffer, not about the socket.

Step 4 handles concurrent connections: one task per client, shared state behind it, and the track’s first third-party crate. It is not written yet, so it gets no link — the track overview carries the full list as articles land.