⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

What a Socket Is

A socket is a file descriptor with an address attached. That is nearly the whole of it. Once two of them are connected, everything you do is the reader and writer interface from Readers and Writers, the same one a file uses.

Built and run natively by CI, binding a real loopback socket. Browser wasm has no sockets.
const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var buf: [512]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
    const out = &file_writer.interface;

    // An address is just data. Parsing one touches no network, opens
    // nothing, and cannot fail for any reason except bad text.
    const wanted = try std.Io.net.IpAddress.parse("127.0.0.1", 0);
    try out.print("asked for port:  {d}\n", .{wanted.getPort()});

    // Port 0 means "any free port, you pick". The kernel assigns one when
    // the socket is bound, and the socket records it. Hardcoding a port is
    // what makes a test suite fail when two of them run at once.
    var server = try wanted.listen(io, .{});
    defer server.deinit(io);

    const bound = server.socket.address;
    try out.print("kernel gave me:  {s}\n", .{if (bound.getPort() == 0) "port 0" else "a real port"});
    try out.print("still loopback:  {}\n\n", .{bound.getPort() != wanted.getPort()});

    // `.stream` is TCP: an ordered, reliable byte stream with a connection
    // behind it. `.dgram` is UDP, and has none of those three properties.
    var client = try bound.connect(io, .{ .mode = .stream });
    defer client.close(io);

    var accepted = try server.accept(io);
    defer accepted.close(io);

    try out.writeAll("connected. two handles now refer to one conversation.\n\n");

    // From here nothing is socket-shaped. The same reader and writer
    // interfaces a file uses carry the bytes, which is why the protocol
    // chapters that follow can run without a network at all.
    var write_buf: [64]u8 = undefined;
    var writer = client.writer(io, &write_buf);
    try writer.interface.writeAll("ping\n");
    try writer.interface.flush();

    var read_buf: [64]u8 = undefined;
    var reader = accepted.reader(io, &read_buf);
    const line = try reader.interface.takeDelimiterExclusive('\n');
    try out.print("server read:     \"{s}\"\n", .{line});

    // Closing is not optional and not automatic. Every socket is a file
    // descriptor, and a server that leaks them stops accepting connections
    // once it hits the process limit, long before it runs out of memory.
    try out.writeAll("closing: the defers above release three descriptors\n");

    try out.flush();
}

An address is data

IpAddress.parse touches no network. It opens nothing, sends nothing, and fails only if the text is not an address. Nothing happens until you listen, bind or connect.

const wanted = try std.Io.net.IpAddress.parse("127.0.0.1", 0);

Port 0 means “you pick”

Ask for port 0 and the kernel assigns a free one when the socket is bound. The socket records which one it got, at server.socket.address.

This matters more than it looks. A test that hardcodes port 8080 fails when two of them run at once, fails when you left the last run’s server up, and fails on a machine where something else already has 8080. Every snippet in this section binds port 0 and reads back what it was given, which is why they can all run on the same CI machine at the same time.

Stream or datagram

var client = try bound.connect(io, .{ .mode = .stream });

.stream is TCP: ordered, reliable, and connected. .dgram is UDP and gives you none of those three, which UDP Datagrams covers at the end of this section. The mode is chosen once, at the socket, and every difference between the two protocols follows from it.

Then it stops being a socket

var writer = client.writer(io, &write_buf);
try writer.interface.writeAll("ping\n");
try writer.interface.flush();

After the connection exists, nothing in the code is network-shaped. That is not a convenience: it is the reason the six chapters between here and UDP can run in your browser with no sockets at all. A protocol parser written against std.Io.Reader works over a socket, over a file, and over a byte array in a test, and you never write it twice.

Closing is yours to do

Every socket is a file descriptor, and processes have a limit on those. A server that leaks one per connection stops accepting new ones long before it runs out of memory, and the symptom is error.ProcessFdQuotaExceeded on accept rather than anything pointing at the leak. defer at the point of creation is how you never have to think about it again.

Next: A TCP Round Trip, which is this plus an accept loop.