Recipe: A TCP Round Trip
The problem
You want a TCP server and a client talking to it, in Zig, with today’s
API. This is the topic where stale tutorials hurt the most: networking
moved from std.net to std.Io.net, every operation now takes the io
instance, and code copied from a last-year guide does not get past the
compiler.
The recipe runs both halves in one process. That is not a toy compromise; it is also how you write an integration test for any protocol code.
The plan
- Parse
127.0.0.1with port 0, andlisten. Port 0 tells the OS to pick a free ephemeral port;server.socket.addresscarries the resolved one, so nothing hardcodes a port that might be taken. - Spawn a thread for the server half:
accept, read a line, reply. - In the main thread,
connectto the recorded address, send a line, read the reply. - Join the thread. CI runs exactly this and diffs the output.
const std = @import("std");
// The server half: accept one client, read one line, echo it uppercased.
// In a real server this loop would run per connection; the shape is the same.
fn serve(server: *std.Io.net.Server, io: std.Io) void {
var conn = server.accept(io) catch return;
defer conn.close(io);
var read_buf: [256]u8 = undefined;
var reader = conn.reader(io, &read_buf);
const line = reader.interface.takeDelimiterExclusive('\n') catch return;
var upper_buf: [256]u8 = undefined;
const upper = std.ascii.upperString(&upper_buf, line);
var write_buf: [256]u8 = undefined;
var writer = conn.writer(io, &write_buf);
writer.interface.print("{s}\n", .{upper}) catch return;
writer.interface.flush() catch return;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [256]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &file_writer.interface;
// Port 0 asks the OS for any free port; the socket records which one
// it got, so nothing here hardcodes a port that might be taken.
const any_port = try std.Io.net.IpAddress.parse("127.0.0.1", 0);
var server = try any_port.listen(io, .{});
defer server.deinit(io);
const addr = server.socket.address;
const thread = try std.Thread.spawn(.{}, serve, .{ &server, io });
// The client half. Everything is the reader/writer interface from
// here on; a socket stream and a file behave the same way.
var stream = try addr.connect(io, .{ .mode = .stream });
defer stream.close(io);
var write_buf: [256]u8 = undefined;
var writer = stream.writer(io, &write_buf);
try writer.interface.writeAll("hello over tcp\n");
try writer.interface.flush();
var read_buf: [256]u8 = undefined;
var reader = stream.reader(io, &read_buf);
const reply = try reader.interface.takeDelimiterExclusive('\n');
try out.print("sent: hello over tcp\n", .{});
try out.print("received: {s}\n", .{reply});
thread.join();
try out.flush();
}What changed from std.net
If you learned Zig networking before the std.Io rework, the map is:
| Then | Now |
|---|---|
std.net.Address.parseIp(...) | std.Io.net.IpAddress.parse(...) |
address.listen(.{}) | address.listen(io, .{}) |
listener.accept() | server.accept(io) |
std.net.tcpConnectToAddress(addr) | addr.connect(io, .{ .mode = .stream }) |
stream.reader() / .writer() | stream.reader(io, &buf) / .writer(io, &buf), then .interface |
The pattern behind every row: blocking operations go through the io
parameter, and buffers are yours to supply.
One interface for sockets and files
stream.reader(io, &buf).interface is the same std.Io.Reader you use
for files and stdin, so takeDelimiterExclusive('\n') reads a line off a
socket exactly as it would off a file. Protocol code written against the
interface does not know or care that a socket is underneath, which is
what makes it testable with std.Io.Reader.fixed in unit tests.
Why a thread
accept blocks until a client arrives, and connect blocks until the
server accepts, so one thread cannot do both halves interleaved without
relying on kernel queue subtleties. A second thread keeps each half
straight-line. On the wasm target none of this compiles (no sockets, no
threads), which is why CI runs this snippet natively.
Variations
- Many clients: wrap the accept-and-serve body in a loop and spawn a thread per connection, or feed accepted streams to a worker queue.
- Timeouts:
connecttakes anIo.Timeoutin its options; reads can usereceiveTimeouton the socket level. - Structured protocols: length-prefixed frames beat line-delimited text for binary data; see the wire format recipe.