⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

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

  1. Parse 127.0.0.1 with port 0, and listen. Port 0 tells the OS to pick a free ephemeral port; server.socket.address carries the resolved one, so nothing hardcodes a port that might be taken.
  2. Spawn a thread for the server half: accept, read a line, reply.
  3. In the main thread, connect to the recorded address, send a line, read the reply.
  4. Join the thread. CI runs exactly this and diffs the output.
Built and run natively by CI, exchanging real bytes over loopback. Browser wasm has no sockets.
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:

ThenNow
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: connect takes an Io.Timeout in its options; reads can use receiveTimeout on the socket level.
  • Structured protocols: length-prefixed frames beat line-delimited text for binary data; see the wire format recipe.