Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History

avatar

Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History

zig.png

What will I learn?

  • Why a chat room with no memory feels broken the moment you walk in late -- and how to give a room a bounded recollection of what was just said;
  • How a ring buffer stores the last N messages in a fixed slab of memory that never grows, no matter how long a room stays alive;
  • How to turn one global room into many named rooms using episode 22's hash maps, with each room owning its own membership list and its own history;
  • Why we can add rooms without touching the wire protocol at all -- the same trick episode 97 used for /quit, now doing real work;
  • How to hand a newcomer the backlog before announcing them, so their first sight of a room is its recent conversation and not a blank screen;
  • Why the whole feature rides on ownership discipline (episode 7): a stored message has to outlive the connection that sent it;
  • How this bounded-history-plus-rooms design compares to what you'd reach for in C, Rust, or Go.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History

Here we go ;-) We now have a chat you can actually use. Episode 95 pinned down the protocol (length-prefixed frames, a tagged union for every message kind), episode 96 built the server that speaks it (an accept loop, a mutex-guarded registry, a broadcast that reaches everyone), and last episode gave us a client a human can sit in front of without the terminal turning to soup. Start the server, open two clients, type -- it works. But it's a thin sort of chat, and I ended episode 97 by naming exactly how: a room right now has no memory, and there's only one of it.

Both of those are the same kind of gap, and both close with tools we already own. Memory means a room should remember the last stretch of conversation so someone who walks in late isn't staring at a blank screen wondering if the place is dead. Multiple rooms means people get places -- separate conversations that don't bleed into each other. The first leans on episode 7's owned-copy discipline (a stored message has to outlive the socket that sent it); the second leans on episode 22's hash maps (look a room up by name). And -- this is the part I like most -- we can bolt both on without touching the wire protocol from episode 95. Not one byte changes on the network. Let me show you why.

What actually changes (and what deliberately doesn't)

Before writing anything, let me pin the contract we're building on. Everything from the first three episodes stays exactly as it was; I'll reuse these by name and not re-derive them:

// From ep95's codec + ep96's server -- reused verbatim, not changed.
//   ServerMsg     : tagged union { chat, joined, left }              (ep95)
//   encodeServer  : ServerMsg -> owned frame bytes, caller frees     (ep95)
//   Client        : one connection { stream, nick (owned copy) }     (ep96)
//
// The ONLY new field on a Client this episode:
//   room : []u8  // owned copy of the name of the room it's currently in

Notice what is not in that list: no new message type, no room field added to the join frame, no protocol version bump. That's a deliberate choice with a nice payoff. A newcomer's client still sends the plain join and plain say frames it always did. When a user wants to switch rooms, they type /join lobby -- and, exactly like the /quit we already handle client-side, the server just reads that as a say whose text happens to start with a slash. The command lives entirely inside the payload of a message the protocol already carries. We get a whole feature for free at the wire level, and any old client can drive it. Having said that, let's build the memory first, because rooms will lean on it.

History: a ring that never grows

A room's memory has one hard requirement that dominates the design: it must be bounded. A popular room can run for weeks and see millions of lines -- if "remember the conversation" meant "keep every message forever" the server would swell until it fell over. So we don't keep everything. We keep the last N, and N is fixed. The data structure for "the most recent N things, oldest ones fall off the back" is a ring buffer (a circular buffer): a fixed array plus two little numbers that say where the live region starts and how long it is.

Here's the crucial simplification I want you to catch: a room's history stores the exact frame bytes we already broadcast. Not parsed structs, not (nick, text) pairs -- the literal encoded chat frame that went out on the wire. That means replaying history to a newcomer is nothing more than re-sending those same bytes, and their client decodes them with episode 95's codec none the wiser that they're minutes old. Memory becomes a slab of []u8 frames, and "replay" becomes a loop of writeAll. Boring, which is the highest compliment I pay a data structure.

const std = @import("std");

// A fixed-capacity ring of the most recent broadcast frames. Oldest frames are
// evicted (and freed) as new ones arrive, so a room's memory is bounded no
// matter how long it lives or how much gets said.
const History = struct {
    const cap = 32;

    frames: [cap][]u8 = undefined,
    start: usize = 0, // index of the oldest live frame
    len: usize = 0,   // number of live frames, 0..cap

    // Store an OWNED copy of `frame`. The caller keeps its own frame to free.
    fn push(self: *History, alloc: std.mem.Allocator, frame: []const u8) !void {
        const owned = try alloc.dupe(u8, frame);
        const slot = (self.start + self.len) % cap;
        if (self.len == cap) {
            // Ring is full: the write slot IS the oldest frame. Free it first.
            alloc.free(self.frames[self.start]); // slot == self.start when full
            self.frames[slot] = owned;
            self.start = (self.start + 1) % cap; // oldest moves forward one
        } else {
            self.frames[slot] = owned;
            self.len += 1;
        }
    }

    fn deinit(self: *History, alloc: std.mem.Allocator) void {
        var i: usize = 0;
        while (i < self.len) : (i += 1) {
            alloc.free(self.frames[(self.start + i) % cap]);
        }
        self.len = 0;
    }
};

The whole trick is the modular arithmetic. slot = (start + len) % cap is always "one past the newest live frame", wrapping around the end of the array back to the front. While the ring is filling up, len grows and start stays put at zero. Once it's full (len == cap), a push has to evict: the slot we're about to write is exactly start (do the algebra -- (start + cap) % cap == start), so we free that oldest frame, overwrite it, and nudge start forward one so the next-oldest becomes the new tail. No allocation happens per message a part from the one dupe for the incoming copy, and no memory is ever moved -- the array sits still while two indices dance around it. That's the ring buffer's entire reason to exist: constant-time push, constant memory, and the eviction comes for free.

The dupe in push is episode 7's discipline showing up again, and it matters here more than anywhere. The frame we're handed was allocated to be broadcast and will be freed the instant that broadcast finishes. If history stored the slice instead of a copy, it would be pointing at freed memory within microseconds -- a use-after-free waiting for the next person to join. So history takes ownership of its own copy, and deinit is obligated to free every live one. Own it, or don't keep it.

Rooms: a registry of registries

Now the second half. Last episode's server had a single membership list -- one room, implicitly. A multi-room server is a registry of registries: a hash map (episode 22) from a room's name to the room itself, and each room bundles its own member list with its own history.

const net = std.net;

// One room: who's in it, and what was recently said in it.
const Room = struct {
    members: std.ArrayListUnmanaged(*Client) = .{},
    history: History = .{},

    fn deinit(self: *Room, alloc: std.mem.Allocator) void {
        self.members.deinit(alloc);
        self.history.deinit(alloc);
    }
};

const Server = struct {
    alloc: std.mem.Allocator,
    mutex: std.Thread.Mutex = .{},
    rooms: std.StringHashMapUnmanaged(*Room) = .{},

    // Find a room by name, creating it (and an owned copy of its key) on first
    // use. Caller MUST hold the lock -- this mutates the shared registry.
    fn roomLocked(self: *Server, name: []const u8) !*Room {
        if (self.rooms.get(name)) |room| return room;
        const room = try self.alloc.create(Room);
        room.* = .{};
        // The map key must outlive the transient `name` slice we were handed.
        const key = try self.alloc.dupe(u8, name);
        try self.rooms.put(self.alloc, key, room);
        return room;
    }
};

The members list holds *Client -- pointers to clients, not copies -- because the client structs are owned by their connection handlers over in episode 96's per-connection code; a room only borrows a reference so it knows who to broadcast to. Two ownership rules collide here and both have to be respected: the room does not own its members (it just points at them), but it does own its history frames and, via the hash map, its own name key. That dupe on the key in roomLocked is the same lesson as history's -- the name we're passed came from decoding a transient frame and will van, so the map must keep its own copy or the key goes dangling the moment the caller's buffer is reused.

Everything touching rooms runs under the one server mutex, exactly the discipline episode 96 established. A roomLocked name says so out loud: hold the lock before you call me. I'd rather bake that contract into the function's name than trust future-me to remember it at 2am.

Broadcasting into a room -- and recording it

With the structures in place, sending a chat message is a small variation on episode 96's broadcast, with one addition: after we send it, we hand a copy to the room's history so the next person to walk in can see it.

// Broadcast a chat line to everyone in the sender's room, and remember it.
// Caller supplies the sender; we look up their room under the lock.
fn handleSay(self: *Server, sender: *Client, text: []const u8) void {
    self.mutex.lock();
    defer self.mutex.unlock();

    const room = self.rooms.get(sender.room) orelse return; // sender vanished?
    const msg = ServerMsg{ .chat = .{ .nick = sender.nick, .text = text } };
    const frame = encodeServer(self.alloc, msg) catch return;
    defer self.alloc.free(frame); // we own this one; history dupes its own

    room.history.push(self.alloc, frame) catch {}; // best-effort memory
    for (room.members.items) |c| {
        c.stream.writeAll(frame) catch {}; // a dead peer gets reaped elsewhere
    }
}

One frame, encoded once, used twice: history dupes it into the ring, then the broadcast loop writes the original to every member. We free our copy on the way out; history keeps and eventually frees its own. The whole thing runs under the lock as one indivisible burst -- the same guarantee episode 96 gave a broadcast, now also covering the history push, so a message can never land in the ring without going out on the wire or vice versa. And note the catch {} on the history push: if we're out of memory the message still gets delivered, we just don't remember it. Memory is a nicety; delivery is the job. Degrade the nicety, never the job.

Switching rooms: the shuffle that touches two lists

Here's the one genuinely fiddly operation, because moving a client between rooms touches two member lists, the registry, and the newcomer's own socket, all of which want to be consistent with each other. So the whole shuffle happens under a single lock hold.

// Move `client` into the room named `name`, creating it if needed. Announces
// the departure to the old room and the arrival to the new one, and hands the
// newcomer the new room's backlog BEFORE anyone is told they arrived.
fn switchRoom(self: *Server, client: *Client, name: []const u8) !void {
    self.mutex.lock();
    defer self.mutex.unlock();

    // Leave the current room, if we're in one.
    if (self.rooms.get(client.room)) |old| {
        removeMember(old, client);
        broadcastLocked(self, old, .{ .left = .{ .nick = client.nick } });
    }

    // Enter the target room (created on first use).
    const room = try self.roomLocked(name);
    try room.members.append(self.alloc, client);

    // Swap the client's remembered room name for an owned copy of the new one.
    self.alloc.free(client.room);
    client.room = try self.alloc.dupe(u8, name);

    // Replay history to the newcomer FIRST, then announce them. Their first
    // sight of the room is its recent talk, and only then "*** you're here ***".
    replayHistoryLocked(room, client);
    broadcastLocked(self, room, .{ .joined = .{ .nick = client.nick } });
}

The ordering in the back half is not accidental, and it's the sort of thing that reads as trivial and bites if you get it backwards. We replay the backlog to the joining client before we broadcast their joined notice to the room. Why that way round? Because the newcomer should see the conversation that was already happening, then see themselves arrive -- that's the natural reading order a human expects. If we announced first and replayed second, the newcomer's own "*** alice joined ***" would appear above the history that predates it, which is backwards in time and quietly confusing. Small detail, real polish. The two helpers do the obvious things -- replayHistoryLocked writes each stored frame to the one client, broadcastLocked encodes a ServerMsg and writes it to every member -- and both assume the lock is already held:

// Re-send a room's remembered frames to a single client, oldest-first.
fn replayHistoryLocked(room: *Room, client: *Client) void {
    const h = &room.history;
    var i: usize = 0;
    while (i < h.len) : (i += 1) {
        const frame = h.frames[(h.start + i) % History.cap];
        client.stream.writeAll(frame) catch return; // socket died mid-replay
    }
}

// Encode one server message and fan it out to every member of `room`.
fn broadcastLocked(self: *Server, room: *Room, msg: ServerMsg) void {
    const frame = encodeServer(self.alloc, msg) catch return;
    defer self.alloc.free(frame);
    for (room.members.items) |c| c.stream.writeAll(frame) catch {};
}

Reading /join out of an ordinary message

And now the payoff I promised at the top. Where does switchRoom get called from? Not from a new protocol message -- from the ordinary say handler, which peeks at the text before broadcasting it:

// Every `say` frame from a client lands here. Most are chat; a few are commands
// smuggled in as text, which is why the protocol never had to learn about rooms.
fn onSay(self: *Server, client: *Client, text: []const u8) !void {
    const cmd = "/join ";
    if (std.mem.startsWith(u8, text, cmd)) {
        const name = std.mem.trim(u8, text[cmd.len..], " ");
        if (name.len > 0) try self.switchRoom(client, name);
        return; // a command is consumed, not echoed as chat
    }
    self.handleSay(client, text); // ordinary line -> broadcast + remember
}

That is the entire integration point. A message starting with /join is a room switch and gets consumed; anything else is chat and gets broadcast. The wire protocol from episode 95 never learned the word "room", and yet rooms work, because we chose to carry the command inside a message the protocol already understands. This is the same move episode 97's client made with /quit -- a slash-prefixed line the transport is blissfully unaware of -- promoted from a client convenience to a real server feature. When you can add capability without touching the format, you keep every old client compatible and you keep the protocol small. That's a trade I'll take almost every time.

Testing the part that can be wrong

Same closing instinct as every episode since number 12: the code that carries state -- and therefore the code that can silently drift -- is the ring buffer's wraparound. Sockets and threads we verify by running the thing; the ring we nail down deterministically, no network in sight. The nastiest case is "push one more than capacity and confirm the oldest fell off and the order is still right", because that's precisely where an off-by-one in the modular arithmetic hides.

test "history evicts the oldest once full and stays oldest-first" {
    const alloc = std.testing.allocator;
    var h = History{};
    defer h.deinit(alloc); // also proves no frame is leaked

    // Push cap+1 distinct frames; the very first one must be evicted.
    var buf: [16]u8 = undefined;
    var i: usize = 0;
    while (i < History.cap + 1) : (i += 1) {
        const s = try std.fmt.bufPrint(&buf, "f{d}", .{i});
        try h.push(alloc, s); // history dupes; our `buf` is reused freely
    }

    try std.testing.expectEqual(History.cap, h.len);
    // Oldest survivor is f1 (f0 evicted); newest is f{cap}.
    try std.testing.expectEqualStrings("f1", h.frames[h.start]);
    const newest = h.frames[(h.start + h.len - 1) % History.cap];
    try std.testing.expectEqualStrings("f32", newest);
}

Because it runs under std.testing.allocator (episode 26's allocator-as-a-leak-detector), this one test proves two things at once: the wraparound math is right and every duped frame -- including the evicted f0 -- was freed. If push forgot to free the frame it overwrites, or if deinit missed a live slot, the test fails on a detected leak rather than passing quietly and rotting in production. Notice too that we reuse a single 16-byte buf for all 33 pushes -- that's the whole point of history taking owned copies: the caller's buffer is disposable the instant push returns.

Performance and design considerations

The costs here are all shapes, not throughput, same as the rest of this arc. History push is O(1): one dupe of a small frame and, at most, one free of the evicted one -- no scanning, no shifting, no reallocation ever, because the ring's backing array never moves. A room switch is O(members) for the two broadcasts plus O(history length) to replay, and both those bounds are small and fixed by design (cap is 32; a room has however many people are in it). The hash map lookup to resolve a room name is amortized O(1), episode 22's contract. The one number a careless design could blow is memory, and the ring is the answer: with cap frames per room, a thousand rooms cost a thousand small bounded slabs, not a thousand ever-growing logs. If you wanted persistence -- history that survives a restart -- the seam is obvious: episode 41's write-ahead log wrote frames to disk, and history is already a stream of frames, so you'd append each pushed frame to a file and replay the tail on startup. I'm not building that today, but it's worth seeing that the design leaves the door open.

The choice I'd defend hardest is storing raw frames in history in stead of parsed messages. It felt almost too lazy when I first wrote it -- surely history should hold structured data? But keeping frames means replay is a byte copy to a socket with zero re-encoding, history is decoupled from the meaning of a message (it'd remember a future message kind without changing a line), and the ownership story is dead simple: history owns some bytes and frees some bytes, full stop. Boring, decoupled, and cheap. Complexity you can name and bound is complexity you can live with.

How this compares to C, Rust, and Go

In C, the ring buffer is the classic char *slots[CAP] with two size_t indices -- the exact shape we wrote, just with malloc/free where we have dupe/free and no compiler watching whether you freed the evicted slot. The eviction free is precisely the line C programmers forget, and it leaks so slowly (one small string per overflow) that it survives code review and shows up as a mystery in production a month later. The rooms map is a hand-rolled hash table or a third-party one like uthash, and the room-name key ownership is another manual strdup you have to remember. Every discipline Zig made explicit, C leaves to your diligence.

In Rust, history would likely be a VecDeque<Vec<u8>> with a push_back / pop_front when it hits capacity -- less arithmetic than our hand-rolled ring, and the borrow checker guarantees you can't hold a reference to an evicted frame, which is the whole class of bug we're avoiding by copying. The rooms registry is a HashMap<String, Room>, and String owning its bytes makes the key-lifetime question we handled manually just disappear. The multi-room shuffle would fight the borrow checker a bit -- mutating two rooms plus a client through the same map at once is exactly the aliasing it polices -- and you'd reach for indices or RefCell to satisfy it. That friction is Rust charging you up front for the safety we're maintaining by lock convention.

In Go, this is a handful of lines: history a slice you reslice, rooms a map[string]*Room behind a sync.Mutex, and /join parsed off the message string. The GC erases every ownership question we spent paragraphs on -- no dupe, no free, no eviction leak -- at the price of a runtime you don't control and pauses you don't schedule. Our version does the same work with the machinery visible: you can point at the frame that gets duped, the slot that gets freed, the lock that's held. For learning, seeing the machinery is the value.

Where this is heading

Step back at what four episodes have added up to. We designed a framed protocol and modelled every message as a tagged union (95), stood up a concurrent server whose one shared registry is guarded by one lock (96), built a terminal client that keeps your half-typed line intact while messages scroll (97), and now given each room a bounded memory and let a single server host as many rooms as people care to name (98) -- all of it without the wire format changing once since episode 95. Start the server, run a few clients, type /join zig in one and /join lobby in another, and they're in separate conversations; walk into a busy room and the last stretch of talk is waiting for you. That's a real chat system, built from parts we understand down to the byte.

The toolkit that got us here -- length-bounded frames, endianness you name out loud, locks held only for the pointer-shuffle and the briefest of writes, hash maps keyed by owned strings, and defer guaranteeing you leave the machine the way you found it -- is not chat-specific in the slightest. It's the same kit every networked service is built from. The chat server was the excuse to assemble it; what we actually built is the muscle memory for pointing that kit at whatever networked problem comes next. And there's a whole territory of those still ahead -- services that don't wait for a human to type, that measure and route and probe rather than converse. We'll aim the same tools at a different shape of problem next time.

De groeten, and happy hacking!

@scipio



0
0
0.000
0 comments