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, and every operation now takes the Io instance.
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();
}Run it yourself:zig run tcp-echo.zig
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.