ncurses in C, crossterm/ratatui in Rust, and the goroutine-plus-channel client you'd write in Go.Learn Zig Series):Here we go ;-) Two episodes ago we wrote the protocol; last episode we built the server that speaks it -- an accept loop, a mutex-guarded registry, and a broadcast that reaches everyone in the room. But if you tried to actually use that server, you hit the wall I flagged at the end: the only "client" was a test harness firing hand-built byte frames. Nobody wants to chat by encoding tagged unions with their fingers. Today we build the piece a human sits in front of -- the terminal client -- and it turns out to be a surprisingly different animal from the server.
Here's the thing that catches people off guard: the client is arguably the trickier half. The server's hard problem was many connections touching one shared registry, and we solved it with a single lock. The client's hard problem is smaller in scope but sneakier -- it has to do two things at the same time on one screen. It must watch the socket for incoming messages and paint them as they land, and it must watch the keyboard for your keystrokes and let you type a line. Those two jobs both want to write to the terminal, and if you let them do it naively they trample each other: a message arrives while you're halfway through typing "hey are we still on for" and suddenly your line is shredded across three rows. Sound familiar? It's the same shape as the server's concurrency bug, just with the terminal as the shared resource in stead of a hash map.
Let's name the two jobs precisely, because naming them tells us the architecture.
readFrame (episode 95), decode each server message, and display it. This is blocking work: most of the time it's parked waiting for bytes that arrive whenever someone else types.say frame (episode 95's codec again) and send it. This is also blocking work: most of the time it's parked waiting for a keypress.Two blocking loops, both mostly idle, both occasionally needing the screen. That's a textbook case for two threads (episode 30) -- one per job -- with the terminal as shared state protected by a lock, exactly the discipline the server taught us. The reader thread runs in the background; the main thread owns the keyboard. When either wants to draw, it takes the mutex, does its drawing as one indivisible burst, and releases.
Both jobs lean on the codec we already wrote in episodes 95 and 96, so let me pin down the four helpers we'll reuse verbatim -- I won't re-derive them, they're the contract from earlier in this arc:
// From episode 95's codec / episode 96's server -- reused as-is here.
// readFrame : block until one whole length-prefixed frame is read.
// decodeServer : bytes -> ServerMsg tagged union (chat / joined / left).
// encodeClient : ClientMsg (join / say / leave) -> owned frame bytes.
fn readFrame(stream: net.Stream, buf: []u8) ![]u8; // returns a slice into buf
fn decodeServer(frame: []const u8) !ServerMsg; // exhaustive union, ep95
fn encodeClient(alloc: std.mem.Allocator, msg: ClientMsg) ![]u8; // caller frees
That's the whole surface the client touches -- read a frame, decode what the server said, encode what we want to say. Everything new in this episode is around those calls: the terminal, the two threads, and the screen they share.
But before any of that, we have to fight the terminal itself, because by default it is not set up for a program that wants each keystroke as it happens.
A terminal in its normal state runs in what's called canonical (cooked) mode. It buffers a whole line for you, lets the user backspace and edit, and only hands your program the text when Enter is pressed. It also echoes every key back to the screen automatically. That's lovely for a readLine prompt and completely wrong for a chat TUI: we want to see each keypress the instant it lands (so we can react to it), and we want to control the echo ourselves (so the reader thread can erase and redraw the input line at will). The fix is raw mode -- we tweak the terminal's termios settings to turn off canonical buffering and automatic echo.
The single most important rule about raw mode, learned the hard way by everyone who has ever wedged their shell: whatever you turn off, you must turn back on, on every exit path, including the crash paths. Zig's defer (episode 4) is exactly the tool for a promise like that.
const std = @import("std");
const posix = std.posix;
const net = std.net;
// Puts a terminal into raw mode and remembers how to undo it.
const RawMode = struct {
fd: posix.fd_t,
original: posix.termios,
fn enable(fd: posix.fd_t) !RawMode {
const original = try posix.tcgetattr(fd);
var raw = original;
// ICANON off: deliver each byte immediately, don't wait for a newline.
// ECHO off: we paint the input line ourselves, the terminal must not.
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
// Keep ISIG on so Ctrl-C still works -- a user must always be able to bail.
raw.lflag.ISIG = true;
// read() returns as soon as 1 byte is available, with no inter-byte timer.
raw.cc[@intFromEnum(posix.V.MIN)] = 1;
raw.cc[@intFromEnum(posix.V.TIME)] = 0;
try posix.tcsetattr(fd, .FLUSH, raw);
return .{ .fd = fd, .original = original };
}
fn disable(self: RawMode) void {
// Best-effort restore. If this somehow fails, a shell `reset` fixes it,
// but we go out of our way to never leave a terminal in a broken state.
posix.tcsetattr(self.fd, .FLUSH, self.original) catch {};
}
};
The whole thing is thirty lines and yet it's the part I'd tell a newcomer to read twice. We snapshot the original settings, derive a raw copy by flipping three flags, install it, and hand back a value whose only job is to remember the original so disable can put it back. ISIG staying on is a deliberate courtesy -- I keep Ctrl-C alive because a user who panics should always have an escape hatch that doesn't depend on our code being bug-free. And .FLUSH as the tcsetattr action discards any keystrokes already queued when we switch modes, so a mistimed keypress during startup doesn't leak through half-cooked.
Now the design decision that makes this whole client testable -- and it's the same instinct that made last episode's server trustworthy. The server was reasonable because the dangerous logic (the registry) was separable from the sockets. Here, the logic that could actually be wrong is the line editor: appending characters, handling backspace, yielding the finished line. The rendering -- the escape codes that move the cursor and clear the row -- is dumb once the model is right. So we split them cleanly: a pure LineBuffer that knows nothing about terminals, and a renderer that reads it.
// The text the user is currently typing. Pure model: no terminal, no escapes,
// nothing to mock. This is where the only interesting client logic lives.
const LineBuffer = struct {
data: std.ArrayListUnmanaged(u8) = .{},
fn deinit(self: *LineBuffer, alloc: std.mem.Allocator) void {
self.data.deinit(alloc);
}
fn append(self: *LineBuffer, alloc: std.mem.Allocator, byte: u8) !void {
try self.data.append(alloc, byte);
}
fn backspace(self: *LineBuffer) void {
if (self.data.items.len > 0) _ = self.data.pop(); // no-op on empty, never underflows
}
// Hand back an owned copy of the line and clear the buffer for the next one.
fn take(self: *LineBuffer, alloc: std.mem.Allocator) ![]u8 {
const owned = try alloc.dupe(u8, self.data.items);
self.data.clearRetainingCapacity();
return owned;
}
};
Notice take returns an owned copy and clears in one motion, the same "dupe it because it has to outlive its source" move the server made with nicknames last episode. The caller frees the copy; the buffer is immediately ready for the next line without reallocating (clearRetainingCapacity keeps the backing memory). And backspace on an empty buffer is a silent no-op -- not a crash, not an underflow -- because the one thing you can bank on is that a user will mash backspace at an empty prompt.
The Ui wraps the line buffer, the mutex that guards the shared screen, and the output file. Its whole reason to exist is the trick at the heart of a split-screen TUI: when a message arrives, erase the input line, print the message, then redraw the input line underneath it. From the user's point of view their half-typed text stays glued to the bottom while other people's words scroll up above it.
const Ui = struct {
mutex: std.Thread.Mutex = .{},
line: LineBuffer = .{},
alloc: std.mem.Allocator,
out: std.fs.File,
fn init(alloc: std.mem.Allocator, out: std.fs.File) Ui {
return .{ .alloc = alloc, .out = out };
}
fn deinit(self: *Ui) void {
self.line.deinit(self.alloc);
}
// Redraw the prompt + current input. Caller MUST hold the lock.
fn drawInputLocked(self: *Ui) void {
// \r -> column 0, \x1b[K -> clear from cursor to end of line.
self.out.writeAll("\r\x1b[K> ") catch {};
self.out.writeAll(self.line.data.items) catch {};
}
// Print an incoming line ABOVE the input without eating the half-typed text.
fn printLine(self: *Ui, text: []const u8) void {
self.mutex.lock();
defer self.mutex.unlock();
self.out.writeAll("\r\x1b[K") catch {}; // wipe the current (input) row
self.out.writeAll(text) catch {};
self.out.writeAll("\n") catch {};
self.drawInputLocked(); // repaint the prompt on the fresh bottom row
}
};
Two escape sequences carry the entire illusion. \r yanks the cursor to the start of the current line; \x1b[K erases from the cursor to the end of that line (episode 58's terminal-rendering toolkit, reused). So printLine says: jump to column zero, clear whatever input was sitting there, write the incoming message followed by a newline (which scrolls everything up one row), and finally redraw the prompt and the user's in-progress text on the now-empty bottom line. Because the whole sequence runs while the mutex is held, the input thread cannot slip a keystroke's worth of drawing into the middle of it. That is the same guarantee the server's broadcast gave -- one writer's burst is indivisible -- applied to a terminal in stead of a socket.
With printLine in hand, the background reader is almost anticlimactic: loop on the socket, decode each server frame with episode 95's codec, and turn each message kind into a formatted line. That anticlimax is the point -- the hard part already lives in Ui.
// Runs on its own thread. Decodes server messages and paints them until the
// socket dies, then leaves one final notice on screen.
fn readerLoop(ui: *Ui, stream: net.Stream) void {
var buf: [4096]u8 = undefined;
while (true) {
const frame = readFrame(stream, &buf) catch break; // EOF or bad frame -> stop
const msg = decodeServer(frame) catch break;
var out: [4200]u8 = undefined;
const line = switch (msg) {
.chat => |m| std.fmt.bufPrint(&out, "{s}: {s}", .{ m.nick, m.text }) catch continue,
.joined => |m| std.fmt.bufPrint(&out, "*** {s} joined ***", .{m.nick}) catch continue,
.left => |m| std.fmt.bufPrint(&out, "*** {s} left ***", .{m.nick}) catch continue,
};
ui.printLine(line);
}
ui.printLine("*** disconnected from server ***");
}
The switch on msg is exhaustive, and it is exhaustive because episode 95 modelled the server's replies as a tagged union -- the compiler will refuse to build this function the day someone adds a fourth message kind and forgets to display it. That's the recurring dividend of this whole arc: an unhandled protocol case is a compile error here, not a silent blank line in production. When readFrame finally returns an error -- the server shut down, or our own shutdown closed the socket out from under it -- the loop breaks and we leave a single honest notice on the screen in stead of just freezing.
The main thread owns the keyboard. It reads one byte at a time (raw mode guarantees each keypress arrives immediately), and routes it: Enter finishes the line and ships a say, Backspace shrinks the buffer, a printable byte extends it, and everything is redrawn under the lock so the reader thread never catches us mid-edit.
// Handle one input byte. Returns false when the user asked to quit.
fn handleKey(ui: *Ui, stream: net.Stream, byte: u8) !bool {
switch (byte) {
'\r', '\n' => {
ui.mutex.lock();
const line = try ui.line.take(ui.alloc);
ui.drawInputLocked(); // prompt is now empty again
ui.mutex.unlock();
defer ui.alloc.free(line);
if (line.len == 0) return true;
if (std.mem.eql(u8, line, "/quit")) return false;
const frame = try encodeClient(ui.alloc, .{ .say = .{ .text = line } });
defer ui.alloc.free(frame);
stream.writeAll(frame) catch return false; // server gone -> we're done
},
0x7f, 0x08 => { // DEL or Backspace
ui.mutex.lock();
ui.line.backspace();
ui.drawInputLocked();
ui.mutex.unlock();
},
else => {
if (byte >= 0x20) { // ignore stray control bytes for now
ui.mutex.lock();
try ui.line.append(ui.alloc, byte);
ui.drawInputLocked();
ui.mutex.unlock();
}
},
}
return true;
}
I keep the mutex around each buffer-mutation-plus-redraw pair for the same reason the server kept its lock around each registry op: the redraw reads line.data.items, and if the reader thread's printLine interleaved with it we could paint a half-updated line. Small critical sections, no I/O held longer than the draw itself -- the server's lock discipline, wearing terminal clothes.
Now main stitches it together: parse a nickname, connect, do the join handshake (our very first frame must be a join, because episode 96's server hangs up on anything else), flip on raw mode, spawn the reader, and pump the keyboard.
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
var args = std.process.args();
_ = args.next(); // skip program name
const nick = args.next() orelse "anon";
const stream = try net.tcpConnectToHost(alloc, "127.0.0.1", 9000);
// NB: we deliberately do NOT `defer stream.close()` here -- see shutdown below.
// Handshake: announce ourselves. Episode 96 enforces join-first.
const join = try encodeClient(alloc, .{ .join = .{ .nick = nick } });
try stream.writeAll(join);
alloc.free(join);
const stdin = std.io.getStdIn();
var raw = try RawMode.enable(stdin.handle);
defer raw.disable(); // terminal restored no matter how we leave
var ui = Ui.init(alloc, std.io.getStdOut());
defer ui.deinit();
ui.printLine("*** connected -- type and press Enter, /quit to leave ***");
const reader = try std.Thread.spawn(.{}, readerLoop, .{ &ui, stream });
var b: [1]u8 = undefined;
while (true) {
const n = stdin.read(&b) catch break;
if (n == 0) break; // Ctrl-D / EOF
const keep = handleKey(&ui, stream, b[0]) catch break;
if (!keep) break;
}
// Ordered shutdown, discussed below.
stream.close();
reader.join();
}
That last little block is where I want you to slow down, because it's the kind of subtlety that defer alone will quietly get wrong. When the input loop ends -- user typed /quit, or hit Ctrl-D -- we need the reader thread to stop too. But the reader is blocked inside readFrame, parked on a socket that isn't going to deliver anything. If we just reader.join() and wait, we wait forever: the reader is asleep on a read that will never complete.
The unlock is to close the socket first, then join. Closing the stream makes the reader's blocked readFrame return an error, its loop breaks, it prints its farewell line, and the thread exits -- at which point join returns instantly. That's why main closes stream explicitly and does not use defer stream.close(): a deferred close runs at scope exit, which is after the reader.join() we'd have written, and the ordering would deadlock. Naming that ordering out loud, in the open, is the same honesty the server used when it admitted holding a lock across broadcast. The tricky part of concurrent shutdown isn't the closing -- it's the sequence.
raw.disable() and ui.deinit() do run via defer, and here that's exactly right: they have no ordering hazard with the threads, and putting terminal-restore on a defer means even a try that fails mid-function still hands the user back a sane shell. Cleanup that must happen unconditionally goes on defer; cleanup whose order matters relative to another thread gets written out by hand. Knowing which is which is most of what "resource management in a threaded program" actually means.
Same closing move as every episode since number 12: the logic that would actually bite us is the line editor, and it doesn't need a terminal, a socket, or a thread to test. It's a pure LineBuffer, so we drive it directly and assert the invariants.
test "line buffer accumulates, backspaces, and yields an owned cleared line" {
const alloc = std.testing.allocator;
var lb = LineBuffer{};
defer lb.deinit(alloc);
try lb.append(alloc, 'h');
try lb.append(alloc, 'i');
try lb.append(alloc, 'x');
lb.backspace(); // drop the stray 'x'
const line = try lb.take(alloc);
defer alloc.free(line);
try std.testing.expectEqualStrings("hi", line);
// take() must leave the buffer empty and reusable
try std.testing.expectEqual(@as(usize, 0), lb.data.items.len);
}
test "backspace on an empty buffer is a harmless no-op" {
const alloc = std.testing.allocator;
var lb = LineBuffer{};
defer lb.deinit(alloc);
lb.backspace(); // must not underflow or crash
lb.backspace();
try std.testing.expectEqual(@as(usize, 0), lb.data.items.len);
}
Because these run under std.testing.allocator, they also prove there are no leaks (episode 26's allocator-as-a-testing-tool trick): the dupe'd line and the buffer's backing array must both be freed, or the test fails on a detected leak. Correctness and memory hygiene from five assertions, and not a single escape code or file descriptor in sight. The rendering path -- the \r\x1b[K dance -- we verify the honest way, by running the client against a real server and watching the input line stay put while messages scroll. But the logic that has state, and therefore the logic that can drift, is nailed down deterministically. If you wanted to test the render path too, the seam is episode 13's type erasure: make Ui.out a small writer interface so a test injects a buffer in stead of a real file and asserts the exact bytes emitted. For a mini-project the split above earns its keep without that extra layer.
A chat client's performance profile is even gentler than the server's -- one human, one socket, one keyboard -- so the interesting costs are again shapes, not throughput. The mutex is essentially free: it's contended only in the rare instant a keypress and an incoming message land in the same microsecond, and each critical section is a few writeAlls of a handful of bytes. Allocation is one small dupe per line sent and one per frame encoded, both freed immediately -- a busy typist generates a trickle. The one number that would matter on a bad network is the reader thread blocking on readFrame, and that's precisely why it lives on its own thread: no matter how long the socket stalls, your typing stays responsive, because the keyboard loop never waits on the network.
The design choice I'd defend hardest is the same one I defended last episode -- separability. The line-editing model is a pure struct you can test in microseconds; the terminal escapes are dumb output; the socket is a blocking read on a background thread. Each layer is boring on its own, and boring-on-its-own is the only way a program that juggles a keyboard, a screen, and a network at the same time stays comprehensible. Complexity you can name and bound is complexity you can live with -- the whole client fits in your head because no single layer is doing more than one job.
In C, a client like this is tcgetattr/tcsetattr around a termios struct (the exact syscalls we used, just spelled with more ceremony), plus either two pthreads or a single select/poll loop multiplexing stdin and the socket. Many real clients skip threads entirely and use poll -- a legitimate alternative I'll come back to. The classic C footgun here is forgetting to restore the terminal on an error path or a signal, which is how you get the infamous "my shell stopped echoing" bug. Zig's defer raw.disable() makes the correct thing the automatic thing, which is most of the war.
In Rust, you'd reach for crossterm or termion to abstract the raw-mode incantations, and probably ratatui if you wanted a proper framed UI. The borrow checker would force the shared Ui to be an Arc<Mutex<...>> and refuse to compile any path that touched the input buffer without the lock -- the discipline we're holding by hand and convention, turned into a compile error. That's real safety; we're paying attention where Rust would pay the compiler. The async flavour (tokio + crossterm's event stream) scales beautifully but colours everything async for a program that has exactly two tasks.
In Go, this is the language's home turf: a goroutine reading the socket, the main goroutine reading the keyboard, and -- idiomatically -- a chan carrying decoded messages to a single rendering goroutine so nothing shares the screen without coordination. "Share memory by communicating," again. It's genuinely elegant and hard to get wrong, at the price of a runtime and GC you don't see. Our thread-plus-mutex version does the same job with the machinery visible: you can point at the exact bytes, the exact lock, the exact free.
There's an honest alternative I owe you: skip the second thread and use a single poll loop over stdin and the socket, waking on whichever is ready. It's one fewer thread and no mutex at all -- attractive. I chose threads here because they keep each job a plain straight-line function (episode 30's model), which reads more clearly for teaching, and because the client's shared state is a single small buffer that one lock guards trivially. For this program either is defensible; I'd genuinely reach for poll in a client that had to juggle more than two event sources.
Step back at what we built on top of last episode's server. We took the terminal off autopilot with a raw-mode guard that always restores itself, split the client into a pure line-editing model and a dumb renderer, ran a background reader thread that paints incoming frames above your input without ever eating your half-typed line, pumped the keyboard on the main thread, and shut the whole thing down in the one order that doesn't hang -- close the socket, then join the reader. And we proved the only part with real state -- the line buffer -- with deterministic, leak-checked tests and not one terminal in the loop. Start last episode's server, run two copies of this client in two terminals, and you can actually talk. The mini-project is, for the first time, a thing a human uses.
But a room right now has no memory. Walk in five minutes late and you've missed everything said before you arrived -- the server broadcasts to whoever is connected now and forgets the moment the bytes are sent. Real chat remembers: it can hand a newcomer the last stretch of conversation so they're not staring at a blank screen wondering if the room is dead. And a single global room is a thin sort of chat -- people want places, separate conversations that don't bleed into each other. Both of those lean on tools we already own: the hash maps from episode 22 to key conversations by name, the owned-copy discipline from episode 7 to store messages that outlive the connection that sent them, and the same mutex-guarded-shared-state pattern the server already runs on. The client we just built won't need to change much to enjoy them -- which is the quiet reward for keeping every layer small and honest.
The thread through this whole networking arc still hasn't moved since episode 21: bound every length, name your endianness, hold your locks for pointer-shuffling and the briefest of draws, restore every terminal you disturb, and let defer guarantee that whatever goes wrong, you leave the machine the way you found it. We've now got a client a person can actually type into. Next time, we give the room a memory.
Bedankt en tot de volgende keer!