:path (/package.Service/Method) and dispatches to a handler;grpc-status works;grpc-c, tonic in Rust, and Go's first-class google.golang.org/grpc.Learn Zig Series):Last episode we hand-built MessagePack -- the format byte, the fixint/fixstr/fixarray ranges that pack small values into a single byte, a recursive Value tagged union, and an encoder/decoder pair that walks an arbitrary nested tree. The three exercises pushed that decoder from "handles the small cases" to "handles real, big, leak-free documents". Here are the solutions, each reusing the Decoder, Value, the markers (STR8, STR16, ARRAY16, ...) and the take/readBig helpers from episode 91.
Exercise 1: Add the str32 and array32 paths
The 32-bit markers are not a new idea -- they are the same shape as str16/array16, just reading a u32 length big-endian in stead of a u16. Two new branches in the decode switch and we cover the full length range the spec allows:
const STR32: u8 = 0xdb;
const ARRAY32: u8 = 0xdd;
// inside Decoder.decode's switch, alongside STR16 and ARRAY16:
// STR32 => .{ .str = try self.take(try self.readBig(u32)) },
// ARRAY32 => try self.decodeArray(alloc, try self.readBig(u32)),
test "str32 path actually fires" {
const alloc = std.testing.allocator;
const big = try alloc.alloc(u8, 70_000); // forces a length > 0xffff -> str32
defer alloc.free(big);
@memset(big, 'z');
var out: std.ArrayListUnmanaged(u8) = .{};
defer out.deinit(alloc);
try writeStr(&out, alloc, big); // writeStr already promotes to STR32 for len > 0xffff
try std.testing.expectEqual(@as(u8, STR32), out.items[0]); // proves the marker was chosen
var dec = Decoder{ .buf = out.items };
const back = try dec.decode(alloc);
try std.testing.expectEqual(@as(usize, 70_000), back.str.len);
}
The assertion on out.items[0] is the part that matters -- it proves the str32 path fired, not merely that a 70k string round-tripped (which a buggy str16 truncation could fake). Test the branch, not just the happy outcome.
Exercise 2: Write the recursive freeValue
A self-describing decoder that allocates a tree owes you a function that frees the tree. The rule is the one from episode 7, applied recursively: free the children before the slice that owns them, and do not free the str/bin slices because they borrow from the input buffer:
pub fn freeValue(alloc: std.mem.Allocator, v: Value) void {
switch (v) {
.array => |items| {
for (items) |child| freeValue(alloc, child); // leaves first
alloc.free(items); // then the branch that owns them
},
.map => |pairs| {
for (pairs) |p| {
freeValue(alloc, p.key);
freeValue(alloc, p.value);
}
alloc.free(pairs);
},
// str and bin borrow from the input buffer -- NOT ours to free
else => {},
}
}
test "nested document frees with no leaks" {
const alloc = std.testing.allocator; // reports any leak at test end
const inner = [_]Value{ .{ .str = "zig" }, .{ .str = "msgpack" } };
const pairs = [_]Value.Pair{
.{ .key = .{ .str = "tags" }, .value = .{ .array = &inner } },
};
var out: std.ArrayListUnmanaged(u8) = .{};
defer out.deinit(alloc);
try encode(&out, alloc, .{ .map = &pairs });
var dec = Decoder{ .buf = out.items };
const back = try dec.decode(alloc);
freeValue(alloc, back); // if this misses an allocation, testing.allocator screams
}
The order is not cosmetic. Free the items slice first and you've thrown away the pointers you needed to reach the children -- a use-after-free waiting to happen, or a leak if you're lucky. Leaves first, branch second, every time.
Exercise 3: Convert msgpack to JSON
This one proves the punchline from last episode -- msgpack and JSON share one data model, so a Value maps onto JSON text mechanically. It also doubles as a debugging tool you'll actually reach for:
pub fn toJson(w: anytype, v: Value) !void {
switch (v) {
.nil => try w.writeAll("null"),
.boolean => |b| try w.writeAll(if (b) "true" else "false"),
.uint => |n| try w.print("{d}", .{n}),
.int => |n| try w.print("{d}", .{n}),
.float => |f| try w.print("{d}", .{f}),
.str, .bin => |s| try w.print("\"{s}\"", .{s}), // NB: real code must escape quotes/backslashes
.array => |items| {
try w.writeByte('[');
for (items, 0..) |item, i| {
if (i != 0) try w.writeByte(',');
try toJson(w, item);
}
try w.writeByte(']');
},
.map => |pairs| {
try w.writeByte('{');
for (pairs, 0..) |p, i| {
if (i != 0) try w.writeByte(',');
try toJson(w, p.key);
try w.writeByte(':');
try toJson(w, p.value);
}
try w.writeByte('}');
},
}
}
The anytype writer is the episode-13 trick again -- toJson works against any type with a print/writeAll, so it pipes into a file, a socket, or an in-memory buffer without caring which. The honest caveat is in the comment: a production stringifier escapes ", \, and control characters, which I've skipped to keep the recursion legible. Three exercises, and our msgpack codec went from "decodes small blobs" to "decodes anything, frees cleanly, and prints itself". Now for the thing episode 91 pointed at when I closed -- speaking these serialized payloads in full sentences.
Here we go ;-) Two episodes ago we built protobuf; last episode, MessagePack. At the close of episode 91 I said serialization is only ever half a conversation -- a format tells you how to turn a Person into bytes, but says nothing about how a client asks a remote server to do something and gets an answer back. gRPC is the half we've been missing, and the lovely surprise is how little of it is actually new. It is protobuf (episode 90) for the payload, HTTP/2 (episode 85) for the transport, and a thin calling convention -- maybe a hundred lines of glue -- holding the two together.
That's the whole thing, and it's worth saying out loud because gRPC has a reputation for being heavyweight and mysterious. It isn't. Google open-sourced it in 2015 (the "g" is for gRPC, recursively, though everyone assumes Google), and it powers a staggering amount of internal service-to-service traffic at basically every large shop. But peel the generated code and the marketing away and you find three things you already understand sitting in a trenchcoat. We're going to build the trenchcoat.
A gRPC call is a single HTTP/2 request-response exchange with three conventions layered on top. First, the URL path encodes which method you're calling: POST /package.Service/Method. There's no REST-style noun-and-verb guessing -- the path is the fully-qualified procedure name. Second, the request and response bodies aren't raw protobuf; each message is wrapped in a five-byte frame so the receiver knows where one message ends and the next begins. Third -- and this is the part people miss -- the call's success or failure is reported in HTTP/2 trailers, the headers that come after the body, not in the HTTP status line.
That third point is the clever bit. An ordinary HTTP response commits to its status code before it sends a single byte of body. But an RPC might fail partway through producing its result -- a database goes away on message 900 of a 1000-message stream. HTTP/2 trailers let the server say "200 OK, here comes the body... ...actually, grpc-status: 14, unavailable". The transport succeeded; the call failed; and gRPC can tell you both, in order, on one stream. Having said that, let's build it from the bytes up.
Every gRPC payload -- request or response, one message or a stream of them -- is a sequence of Length-Prefixed-Messages. The frame is dead simple: one byte saying "is this compressed?", four bytes of big-endian length, then that many bytes of protobuf. Five bytes of header, no more:
const std = @import("std");
// gRPC Length-Prefixed-Message:
// [compressed: u8][length: u32 big-endian][message: length bytes]
pub fn writeMessage(
list: *std.ArrayListUnmanaged(u8),
alloc: std.mem.Allocator,
payload: []const u8,
) !void {
try list.append(alloc, 0); // 0 = not compressed (1 would mean "use the declared encoding")
var len_buf: [4]u8 = undefined;
std.mem.writeInt(u32, &len_buf, @intCast(payload.len), .big); // network byte order, as always
try list.appendSlice(alloc, &len_buf);
try list.appendSlice(alloc, payload); // the protobuf bytes from episode 90, verbatim
}
If that std.mem.writeInt(..., .big) looks familiar, it should -- it's the exact endianness discipline from protobuf's fixed64, msgpack's integers, the HTTP/2 frame header, DNS. Big-endian everywhere a network is involved, named explicitly so cross-compilation (episode 35) never bites. Reading a message back is the mirror, and like every length-prefixed reader we've written, the bounds check is the whole job:
const FrameReader = struct {
buf: []const u8,
off: usize = 0,
// Returns the next protobuf payload, or null when the buffer is exhausted.
fn next(self: *FrameReader) !?[]const u8 {
if (self.off == self.buf.len) return null; // clean end of stream
if (self.off + 5 > self.buf.len) return error.Truncated; // header doesn't fit
const compressed = self.buf[self.off];
if (compressed != 0) return error.CompressionUnsupported; // we only do identity here
const len = std.mem.readInt(u32, self.buf[self.off + 1 ..][0..4], .big);
self.off += 5;
if (self.off + len > self.buf.len) return error.Truncated; // declared length runs past the end
defer self.off += len;
return self.buf[self.off..][0..len];
}
};
Notice this reader yields a sequence of messages -- call next in a loop until it returns null. For a unary call that loop runs exactly once. For a streaming call it runs many times. The framing is identical either way, which is exactly why gRPC's four call shapes share one wire format. That's the design paying off: stream-ness is a property of how many frames you send, not of a different protocol.
gRPC has its own status code space -- 17 codes, 0 is OK, and the rest mirror the kinds of failures you'd expect (NOT_FOUND, PERMISSION_DENIED, UNAVAILABLE, ...). This is a perfect fit for a Zig enum with explicit values, and it pairs naturally with an error set so a handler can just return error.NotFound and let the framework translate:
pub const Status = enum(u8) {
ok = 0,
cancelled = 1,
unknown = 2,
invalid_argument = 3,
deadline_exceeded = 4,
not_found = 5,
already_exists = 6,
permission_denied = 7,
unavailable = 14,
unauthenticated = 16,
// ... the full table has 17 entries; these are the ones you'll actually return
};
pub const RpcError = error{ NotFound, InvalidArgument, PermissionDenied, Unavailable, Internal };
fn statusForError(e: RpcError) Status {
return switch (e) {
error.NotFound => .not_found,
error.InvalidArgument => .invalid_argument,
error.PermissionDenied => .permission_denied,
error.Unavailable => .unavailable,
error.Internal => .unknown,
};
}
This is the episode-4 philosophy in its element. Errors are values, the error set is exhaustive, and the compiler forces statusForError to handle every member -- add a new RpcError variant and the build breaks until you map it to a status. No silent "oh, that failure mode falls through to 500". The mapping is total because Zig makes it total.
A gRPC server is, at heart, a router keyed on /package.Service/Method. We'll model a handler as a function from request bytes to response bytes (each side being one protobuf message), and register handlers in a hash map -- the episode 22 StringHashMap, doing exactly what it was born for:
pub const Handler = *const fn (
alloc: std.mem.Allocator,
request: []const u8, // a single protobuf-encoded request message
) RpcError![]u8; // a single protobuf-encoded response, owned by the caller
pub const Service = struct {
routes: std.StringHashMapUnmanaged(Handler) = .{},
alloc: std.mem.Allocator,
pub fn register(self: *Service, path: []const u8, handler: Handler) !void {
try self.routes.put(self.alloc, path, handler);
}
// Look up the handler for a fully-qualified path like "/greeter.Greeter/SayHello".
pub fn lookup(self: *Service, path: []const u8) ?Handler {
return self.routes.get(path);
}
pub fn deinit(self: *Service) void {
self.routes.deinit(self.alloc);
}
};
There's nothing exotic here, and that's the point I keep hammering across this arc: the impressive-sounding system is built from the unglamorous parts you already own. A gRPC dispatcher is a hash map from string to function pointer. The protobuf "stub" that codegen normally writes for you is just a typed wrapper that encodes the arguments and decodes the result around one of these handlers.
Let's wire up the canonical example: a Greeter service with a SayHello method. The request carries a name, the response carries a greeting -- both protobuf messages with a single string field (field number 1, wire type len, exactly as episode 90 taught). I'll reuse writeLenField/readLen from the protobuf episode rather than re-derive them:
// HelloRequest { string name = 1; } HelloReply { string message = 1; }
fn sayHello(alloc: std.mem.Allocator, request: []const u8) RpcError![]u8 {
// Decode the single field-1 string out of the request.
var name: []const u8 = "";
var off: usize = 0;
while (off < request.len) {
const d = decodeTag(readVarint(request, &off) catch return error.InvalidArgument);
if (d.field == 1) {
name = readLen(request, &off) catch return error.InvalidArgument;
} else {
skipField(request, &off, d.wire_type) catch return error.InvalidArgument;
}
}
if (name.len == 0) return error.InvalidArgument; // empty name is a client error, not a crash
// Build the reply string, then encode it as a HelloReply.
const greeting = std.fmt.allocPrint(alloc, "Hello, {s}!", .{name}) catch return error.Internal;
defer alloc.free(greeting);
var out: std.ArrayListUnmanaged(u8) = .{};
errdefer out.deinit(alloc);
writeLenField(&out, alloc, 1, greeting) catch return error.Internal;
return out.toOwnedSlice(alloc) catch return error.Internal;
}
See how the handler stays in pure protobuf-and-business-logic land -- it never touches HTTP/2, never sees a frame, never knows a trailer exists. That separation is deliberate: the transport wraps the handler, the handler just turns one message into another or returns an RpcError. An empty name maps to invalid_argument, a formatting failure to internal, and the framework -- next section -- turns those into the right bytes on the wire.
Now we connect the handler to HTTP/2. A gRPC request arrives as a HEADERS frame (with :method POST, :path /greeter.Greeter/SayHello, content-type application/grpc) followed by one or more DATA frames carrying length-prefixed messages. The response is the inverse: a HEADERS frame, DATA frames with the framed reply, and then a trailing HEADERS frame carrying grpc-status. Here's the server side, leaning on the writeFrame and frame-type constants we built in episode 85:
// Handle one decoded gRPC request on an HTTP/2 stream and write the full response.
fn serveCall(
svc: *Service,
stream_id: u31,
path: []const u8,
request_body: []const u8, // the DATA frame payload (one or more framed messages)
out: anytype, // a writer over the HTTP/2 connection
alloc: std.mem.Allocator,
) !void {
// 1. Response HEADERS: 200 OK + content-type, never END_STREAM (trailers come later).
try writeHeadersFrame(out, stream_id, &.{
.{ ":status", "200" },
.{ "content-type", "application/grpc+proto" },
}, .{ .end_stream = false });
// 2. Route and run the handler over the (single, for unary) request message.
var fr = FrameReader{ .buf = request_body };
const handler = svc.lookup(path) orelse return writeTrailers(out, stream_id, .unimplemented, "no such method");
var status: Status = .ok;
if (try fr.next()) |msg| {
if (handler(alloc, msg)) |reply| {
defer alloc.free(reply);
var framed: std.ArrayListUnmanaged(u8) = .{};
defer framed.deinit(alloc);
try writeMessage(&framed, alloc, reply); // wrap reply in a Length-Prefixed-Message
try writeDataFrame(out, stream_id, framed.items, .{ .end_stream = false });
} else |err| {
status = statusForError(err); // handler failed: skip DATA, report via trailer
}
} else {
status = .invalid_argument; // a unary call with no message is malformed
}
// 3. Trailing HEADERS frame: grpc-status closes the stream. THIS is where the call's verdict lives.
try writeTrailers(out, stream_id, status, "");
}
fn writeTrailers(out: anytype, stream_id: u31, status: Status, msg: []const u8) !void {
var status_buf: [4]u8 = undefined;
const status_str = std.fmt.bufPrint(&status_buf, "{d}", .{@intFromEnum(status)}) catch unreachable;
try writeHeadersFrame(out, stream_id, &.{
.{ "grpc-status", status_str },
.{ "grpc-message", msg },
}, .{ .end_stream = true }); // END_STREAM on the trailers closes the gRPC call
}
That writeTrailers is the heart of the whole protocol. The status goes out as an ASCII-decimal header value (grpc-status: 0 for success), in a HEADERS frame flagged END_STREAM. The client reads the body, then reads the trailer, then knows the verdict. Notice the failure path never writes a DATA frame at all -- a failed RPC has no reply message, only a status. And unimplemented for an unknown method is itself just another status code, delivered through the same trailer machinery. One mechanism, every outcome.
Everything above was a unary call: one request message, one reply. gRPC's other three shapes change nothing about the framing and only relax how many messages flow each way. Server-streaming: the client sends one request, the server writes many DATA frames (many framed messages) before its trailer -- think "subscribe to updates". Client-streaming: the client sends many messages, the server replies once -- think "upload these 10,000 rows, give me a summary". Bidirectional: both sides stream independently over the one HTTP/2 stream, which is exactly what HTTP/2's full-duplex flow control was built for (episode 85 again -- this is why gRPC chose HTTP/2 and not HTTP/1.1).
In our model, the only thing that changes is the handler signature. A streaming handler doesn't return one []u8; it gets a writer and emits as many messages as it likes:
pub const ServerStreamHandler = *const fn (
alloc: std.mem.Allocator,
request: []const u8,
emit: *const fn (msg: []const u8) anyerror!void, // call once per response message
) RpcError!void;
// Example: stream the numbers 1..n back, each as its own framed protobuf message.
fn countTo(alloc: std.mem.Allocator, request: []const u8, emit: *const fn ([]const u8) anyerror!void) RpcError!void {
_ = alloc;
const n = readSingleVarint(request) catch return error.InvalidArgument;
var i: u64 = 1;
while (i <= n) : (i += 1) {
var buf: [10]u8 = undefined;
const encoded = encodeVarintInto(&buf, i); // one tiny protobuf message
emit(encoded) catch return error.Internal; // each emit becomes one more DATA frame
}
}
The emit callback wraps each message with writeMessage and ships a DATA frame, exactly as the unary path did for its single reply -- then the trailer closes the stream once the handler returns. Client-streaming inverts it (the handler loops over FrameReader.next to consume an inbound stream), and bidi does both at once. Bam, jonguh -- four "different" RPC styles, one framing, one transport, one trailer. The complexity was always in the marketing, not the bytes.
The highest-value test, as ever, is the round-trip -- but for an RPC we round-trip through the whole stack: frame the request, route it, run the handler, frame the reply, unframe it, decode it. An in-memory buffer stands in for the socket so the test needs no network at all (episode 12's discipline, applied to a server):
test "unary SayHello round-trips through the service" {
const alloc = std.testing.allocator;
var svc = Service{ .alloc = alloc };
defer svc.deinit();
try svc.register("/greeter.Greeter/SayHello", sayHello);
// Build a HelloRequest { name = "scipio" } and frame it like a DATA payload.
var req: std.ArrayListUnmanaged(u8) = .{};
defer req.deinit(alloc);
try writeLenField(&req, alloc, 1, "scipio");
var framed_req: std.ArrayListUnmanaged(u8) = .{};
defer framed_req.deinit(alloc);
try writeMessage(&framed_req, alloc, req.items);
// Run the handler directly over the first framed message.
var fr = FrameReader{ .buf = framed_req.items };
const msg = (try fr.next()).?;
const reply = try sayHello(alloc, msg);
defer alloc.free(reply);
// Decode the HelloReply and check the greeting.
var off: usize = 0;
const d = decodeTag(try readVarint(reply, &off));
try std.testing.expectEqual(@as(u64, 1), d.field);
const greeting = try readLen(reply, &off);
try std.testing.expectEqualStrings("Hello, scipio!", greeting);
}
test "missing method yields unimplemented" {
const alloc = std.testing.allocator;
var svc = Service{ .alloc = alloc };
defer svc.deinit();
try std.testing.expect(svc.lookup("/greeter.Greeter/Nope") == null); // router miss -> .unimplemented on the wire
}
The first test exercises the data path end to end; the second pins the error path. And as always, the place to point a fuzzer (episode 12) is FrameReader.next -- a u32 length claiming four gigabytes with three bytes following must yield error.Truncated, not a wild read. Because every framed read funnels through that one bounds check, a hostile client gets a clean rejection in stead of a crash.
gRPC's performance story is mostly inherited, which is good news -- the work was done in the episodes this one stands on. The protobuf payload is already compact (episode 90), HTTP/2 multiplexes many calls over one connection without head-of-line blocking at the stream layer (episode 85), and HPACK header compression means the repetitive :path/content-type headers cost almost nothing after the first call. The framing tax we added is exactly five bytes per message, which is noise.
Where you can actually move the needle is allocation, same as every episode in this arc. A busy server encodes a reply, frames it, and ships it -- three buffers if you're careless, one reusable buffer with clearRetainingCapacity if you've internalised episode 26. The streaming handlers are where this matters most: a server-streaming call emitting 100,000 messages should not allocate 100,000 times, it should warm one frame buffer and reuse it per message. And the rule that closes every one of these performance sections stands unmoved -- profile before you optimise (episode 34), because the syscall pushing bytes onto the socket will out-cost your varint loop every single time. Measure, then tune ;-)
In Go, gRPC is practically a first-class citizen: google.golang.org/grpc plus the protoc-gen-go-grpc plugin generates typed client and server stubs straight off your .proto, and the runtime owns the HTTP/2 transport, the framing, the trailers -- everything we just wrote by hand. It's the smoothest experience of the three, and the GC owns every decoded message, which is the ownership question Zig made us answer with explicit alloc.free.
In Rust, tonic builds on hyper and tower, giving async gRPC with the borrow checker enforcing the message lifetimes we tracked by hand. It's fast and safe, at the usual cost of async colouring and a non-trivial fight with the type system when a streamed value needs to outlive its request.
In C, you've got grpc-c -- a substantial C++ core with a C surface -- and it is genuinely heavy. Every buffer, every length, every trailer is yours to manage, and a forgotten bounds check on a framed length is, once again, a remote read primitive (the same footgun we've dodged in every episode of this arc with one error.Truncated guard).
Zig sits where it always sits: we wrote the framing, the routing, the status mapping, and the trailer logic in a couple hundred legible lines, every allocation visible, every bounds check ours, no codegen step and no hidden runtime owning the transport. For production you'd reach for a real HTTP/2 stack rather than maintain one -- but you now know precisely what gRPC asks of that stack, and why each of the three layers is shaped the way it is. That clarity is the recurring payoff of building these from scratch.
Step back and look at what the last three episodes assembled. Protobuf gave us the payload. MessagePack gave us the self-describing alternative. And gRPC, today, gave us the call -- the convention that turns "bytes on a wire" into "ask a remote machine to run a procedure and hand back the answer". We did it with a five-byte frame, a hash-map router, an exhaustive status enum, and HTTP/2 trailers, every piece resting on transport and serialization work you'd already done with your own hands.
Which raises the obvious next question, the one every networked system eventually has to answer: a single connection between two machines is fine, but the real world has many machines, behind firewalls, on networks that don't want to talk to each other directly. Getting a packet from a client to a server when neither one has a clean public address is its own craft, full of clever tricks that sound like dark magic until you see the bytes. The vocabulary we've built -- framing, big-endian headers, bounds-checked reads, treating the peer as hostile -- is exactly the vocabulary that next family of problems is written in. We're about to leave the cosy world of "two endpoints already connected" and start working out how they find each other in the first place.
The thread running through all of it hasn't changed since episode 21: a protocol is a contract written in bytes. Check every length like the sender is out to get you, name your endianness so the target never surprises you, and let the type system carry the meaning so the compiler catches what you'd otherwise ship. gRPC felt like a big intimidating system from the outside. From the inside it's three things you already knew, wearing a trenchcoat -- and now you've seen under the coat.
Implement client-streaming. Add a handler shape that consumes many request messages and returns one reply. Use FrameReader to loop over every framed message in the request body (think "sum all these numbers"), and write a round-trip test that frames three request messages back-to-back and asserts the single reply is correct. The framing is already done -- this is purely about looping next until it returns null.
Honour the grpc-timeout header. Real gRPC clients send a grpc-timeout header (e.g. 100m for 100 milliseconds). Parse that header in serveCall, and if the handler would exceed it, abandon the call and write grpc-status: 4 (deadline_exceeded) in the trailer instead of a reply. Test it with a handler that deliberately overruns a tiny deadline.
Add a typed client stub. Write callSayHello(alloc, name: []const u8) ![]u8 that encodes a HelloRequest, frames it, (in a test, hands it straight to the service rather than a socket), reads the reply trailer, and -- crucially -- returns error.NotFound/etc. when grpc-status is non-zero. This is what codegen normally writes for you; doing it once by hand shows you exactly what a generated stub is hiding.