zig-skills/references/std-net.md

17 KiB

std.Io.net Reference (Zig 0.16.0)

Cross-platform networking abstractions for IP connections, address handling, DNS resolution, Unix-domain sockets, and lower-level socket operations. Zig 0.16 exposes these APIs under std.Io.net; there is no root std.net module.

Primary Zig 0.16 release-note source: https://ziglang.org/download/0.16.0/release-notes.html

Networking operations take an explicit std.Io. Readers, writers, servers, sockets, and streams also use that io value for construction or cleanup.

High-level HTTP clients store it directly:

var client: std.http.Client = .{
    .allocator = allocator,
    .io = io,
};
defer client.deinit();

Table of Contents

API Map

const net = std.Io.net;

net.IpAddress       // tagged union: .ip4 or .ip6
net.Ip4Address      // value-oriented IPv4 bytes + port
net.Ip6Address      // IPv6 bytes + port + flow + interface
net.HostName        // validated DNS host name and lookup/connect APIs
net.UnixAddress     // Unix-domain socket path
net.Socket          // open socket plus resolved/bound address
net.Stream          // reliable connected byte stream
net.Server          // listening socket
net.Protocol        // tcp, udp, and other protocol identifiers

Use IpAddress.connect when an IP is already known. Use HostName.connect when DNS resolution and address fallback are required.

TCP Clients

Connect by Host Name

const std = @import("std");
const net = std.Io.net;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const host: net.HostName = try .init("example.com");

    const stream = try host.connect(io, 80, .{
        .mode = .stream,
        .protocol = .tcp,
    });
    defer stream.close(io);

    var read_buf: [4096]u8 = undefined;
    var write_buf: [1024]u8 = undefined;
    var reader = stream.reader(io, &read_buf);
    var writer = stream.writer(io, &write_buf);

    try writer.interface.writeAll(
        "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n",
    );
    try writer.interface.flush();

    while (reader.interface.take(4096)) |chunk| {
        std.debug.print("{s}", .{chunk});
    } else |err| switch (err) {
        error.EndOfStream => {},
        error.ReadFailed => return reader.err.?,
    }
}

HostName.connect performs lookup and races/falls back across returned addresses. The host's bytes are externally owned, so keep their backing storage alive for the call.

Connect to a Parsed Address

const address = try net.IpAddress.parseIp4("192.168.1.1", 8080);
const stream = try address.connect(io, .{
    .mode = .stream,
    .protocol = .tcp,
});
defer stream.close(io);

IPv6 and Scoped IPv6

// Pure parsing: no interface-name scope lookup.
const loopback = try net.IpAddress.parseIp6("::1", 8080);
const stream = try loopback.connect(io, .{ .mode = .stream, .protocol = .tcp });
defer stream.close(io);

// Resolving `%eth0` / `%eno1` requires Io because the interface name must be
// converted to an operating-system interface index.
const link_local = try net.IpAddress.resolveIp6(io, "fe80::1%eth0", 8080);

Connection Timeout

const host: net.HostName = try .init("example.com");
const stream = try host.connect(io, 443, .{
    .mode = .stream,
    .protocol = .tcp,
    .timeout = .{ .duration = .fromSeconds(5) },
});
defer stream.close(io);

Timeouts can be .none, a relative .duration, or an absolute .deadline.

TCP Servers

Basic Server

const std = @import("std");
const net = std.Io.net;

fn serve(io: std.Io) !void {
    const address: net.IpAddress = .{ .ip4 = .unspecified(8080) };
    var server = try address.listen(io, .{
        .reuse_address = true,
        .kernel_backlog = 256,
    });
    defer server.deinit(io);

    std.debug.print("Listening on port {d}\n", .{server.socket.address.getPort()});

    while (true) {
        const client = try server.accept(io);
        defer client.close(io);
        try handleClient(io, client);
    }
}

fn handleClient(io: std.Io, stream: net.Stream) !void {
    var read_buf: [4096]u8 = undefined;
    var write_buf: [1024]u8 = undefined;
    var reader = stream.reader(io, &read_buf);
    var writer = stream.writer(io, &write_buf);

    const request_line = reader.interface.takeDelimiter('\n') catch |err| switch (err) {
        error.ReadFailed => return reader.err.?,
        error.StreamTooLong => return error.RequestLineTooLong,
    } orelse return;
    std.debug.print("Request from {f}: {s}\n", .{ stream.socket.address, request_line });

    try writer.interface.writeAll("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello");
    try writer.interface.flush();
}

Server.accept(io) returns a Stream, not a separate connection wrapper. The accepted stream contains its socket and address.

Listen Options

const server = try address.listen(io, .{
    .kernel_backlog = 128,
    .reuse_address = true,
    .mode = .stream,
    .protocol = .tcp,
});

IpAddress.ListenOptions contains exactly kernel_backlog, reuse_address, mode, and protocol. It does not contain the older force_nonblocking field.

Ephemeral Port

const address: net.IpAddress = .{ .ip4 = .loopback(0) };
var server = try address.listen(io, .{});
defer server.deinit(io);

const assigned_port = server.socket.address.getPort();

The resolved port is stored on server.socket.address; there is no listen_address field.

IP Address Types

Tagged Union

pub const IpAddress = union(enum) {
    ip4: Ip4Address,
    ip6: Ip6Address,
};

This is a value-oriented union, not an extern sockaddr overlay. OS-specific socket-address conversion is handled below this API.

Construction

const loopback4: net.IpAddress = .{ .ip4 = .loopback(8080) };
const any4: net.IpAddress = .{ .ip4 = .unspecified(8080) };
const loopback6: net.IpAddress = .{ .ip6 = .loopback(8080) };
const any6: net.IpAddress = .{ .ip6 = .unspecified(8080) };

const explicit4: net.IpAddress = .{ .ip4 = .{
    .bytes = .{ 127, 0, 0, 1 },
    .port = 8080,
} };

Ip6Address also has flow: u32 = 0 and interface: net.Interface = .none fields.

Parsing

const addr4 = try net.IpAddress.parseIp4("192.168.1.1", 8080);
const addr6 = try net.IpAddress.parseIp6("2001:db8::1", 8080);
const either = try net.IpAddress.parse("::1", 8080);

// Address plus optional port. IPv6 must be bracketed.
const literal4 = try net.IpAddress.parseLiteral("192.168.1.1:8080");
const literal6 = try net.IpAddress.parseLiteral("[2001:db8::1]:8080");

// Handles an IPv6 interface-name scope and therefore requires Io.
const scoped = try net.IpAddress.resolve(io, "fe80::1%eth0", 8080);

parseLiteral uses port zero when no port is present. parse accepts an explicit port and tries IPv4, then IPv6. resolve adds scoped-IPv6 interface lookup.

Methods and Formatting

var address = try net.IpAddress.parseIp4("127.0.0.1", 8080);
const port = address.getPort();
address.setPort(9090);

const same = address.eql(&other_address);

var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try address.format(&writer);            // omits an IPv6 interface-name scope
try address.formatResolved(io, &writer); // includes a resolvable IPv6 scope

Ip4Address.format and Ip6Address.format include the native-endian port. IpAddress.fromIp6 converts IPv4-mapped IPv6 addresses back to .ip4 where possible.

IPv4 Parse Errors

pub const Ip4Address.ParseError = error{
    Overflow,
    InvalidEnd,
    InvalidCharacter,
    Incomplete,
    NonCanonical,
};

For example, leading-zero forms such as 01.2.3.4 are non-canonical.

Stream I/O

Stream Shape and Lifecycle

pub const Stream = struct {
    socket: net.Socket,

    pub fn close(stream: *const Stream, io: std.Io) void;
    pub fn shutdown(stream: *const Stream, io: std.Io, how: net.ShutdownHow) !void;
    pub fn reader(stream: Stream, io: std.Io, buffer: []u8) Stream.Reader;
    pub fn writer(stream: Stream, io: std.Io, buffer: []u8) Stream.Writer;
};

Do not close a stream twice. Flush a buffered writer before shutdown/close when its bytes must reach the peer.

Reading

var read_buf: [4096]u8 = undefined;
var reader = stream.reader(io, &read_buf);
const r = &reader.interface;

const data = r.take(100) catch |err| switch (err) {
    error.EndOfStream => return,
    error.ReadFailed => return reader.err.?,
};

const line = r.takeDelimiter('\n') catch |err| switch (err) {
    error.ReadFailed => return reader.err.?,
    error.StreamTooLong => return error.LineTooLong,
} orelse return;

_ = data;
_ = line;

The generic reader surface reports error.ReadFailed; the network-specific cause is stored in reader.err, with cases such as ConnectionResetByPeer, Timeout, SocketUnconnected, or NetworkDown.

Writing

var write_buf: [1024]u8 = undefined;
var writer = stream.writer(io, &write_buf);
const w = &writer.interface;

try w.writeAll("Hello, World!");
try w.print("Count: {d}\n", .{42});
try w.flush();

The generic writer reports error.WriteFailed; inspect writer.err for the network-specific cause. A successful writeAll may still be buffered until flush.

Half-Close

try stream.shutdown(io, .send); // no more application writes
// Continue reading until EndOfStream if the protocol expects a response.

ShutdownHow is .recv, .send, or .both.

DNS and Host Names

Validation

const host = try net.HostName.init("example.com");
try net.HostName.validate("api.example.com");

const same = host.eql(try .init("EXAMPLE.COM")); // DNS names compare case-insensitively
const child = host.sameParentDomain(try .init("www.example.com"));

HostName retains a borrowed byte slice. Labels and total length are validated; the maximum is net.HostName.max_len.

Queue-Based Lookup

const host: net.HostName = try .init("example.com");
var result_storage: [16]net.HostName.LookupResult = undefined;
var results: std.Io.Queue(net.HostName.LookupResult) = .init(&result_storage);

try host.lookup(io, &results, .{ .port = 443 });

while (results.getOne(io)) |result| switch (result) {
    .address => |address| std.debug.print("address: {f}\n", .{address}),
    .canonical_name => |name| std.debug.print("canonical: {s}\n", .{name.bytes}),
} else |err| switch (err) {
    error.Closed => {},
    error.Canceled => return err,
}

lookup adds zero or more .address results and exactly one .canonical_name, then closes the queue even on error. Capacity 16 guarantees the call itself need not block waiting for a consumer.

Connect with Lookup and Fallback

const host: net.HostName = try .init("example.com");
const stream = host.connect(io, 443, .{
    .mode = .stream,
    .protocol = .tcp,
}) catch |err| switch (err) {
    error.UnknownHostName, error.NoAddressReturned => return error.DnsFailure,
    error.ConnectionRefused => return error.ServerUnavailable,
    else => return err,
};
defer stream.close(io);

For advanced callers, HostName.connectMany asynchronously attempts all resolved addresses and writes successes or per-address connection errors to a caller-provided queue.

Unix-Domain Sockets

Support and Client

if (net.has_unix_sockets) {
    const address = try net.UnixAddress.init("/var/run/app.sock");
    const stream = try address.connect(io);
    defer stream.close(io);

    var read_buf: [4096]u8 = undefined;
    var reader = stream.reader(io, &read_buf);
    _ = &reader;
}

UnixAddress borrows its path and rejects paths longer than UnixAddress.max_len. isAbstract() detects an empty/leading-NUL abstract address representation.

Server

const socket_path = "/tmp/my.sock";
std.Io.Dir.deleteFileAbsolute(io, socket_path) catch |err| switch (err) {
    error.FileNotFound => {},
    else => return err,
};
defer std.Io.Dir.deleteFileAbsolute(io, socket_path) catch {};

const address = try net.UnixAddress.init(socket_path);
var server = try address.listen(io, .{ .kernel_backlog = 128 });
defer server.deinit(io);

while (true) {
    const client = try server.accept(io);
    defer client.close(io);
    // Handle one client in this loop body.
}

Unix listen options contain only kernel_backlog; IP reuse_address options do not apply to this type.

Sockets and Datagram APIs

IpAddress.bind is the non-streaming counterpart to listen:

const address: net.IpAddress = .{ .ip4 = .unspecified(5353) };
const socket = try address.bind(io, .{
    .mode = .dgram,
    .protocol = .udp,
    .allow_broadcast = false,
});
defer socket.close(io);

BindOptions contains ip6_only, allow_broadcast, required mode, and optional protocol. Socket also exposes message send/receive operations, shutdown, option accessors, and closeMany; use those when datagram boundaries or raw socket features matter.

The listening API intentionally has a smaller option set than bind. In particular, IpAddress.ListenOptions has no ip6_only flag, so do not assume an IPv6 listener is unconditionally dual-stack on every target. Use explicitly managed IPv4/IPv6 listeners when that behavior must be controlled.

Common Patterns

Echo One Connection

fn echoConnection(io: std.Io, stream: net.Stream) !void {
    var read_buf: [4096]u8 = undefined;
    var write_buf: [4096]u8 = undefined;
    var reader = stream.reader(io, &read_buf);
    var writer = stream.writer(io, &write_buf);

    _ = reader.interface.streamRemaining(&writer.interface) catch |err| switch (err) {
        error.ReadFailed => return reader.err.?,
        error.WriteFailed => return writer.err.?,
    };
    try writer.interface.flush();
}

For a concurrent server, schedule each accepted stream through the active std.Io implementation and make one owner responsible for closing it.

Address and Host Validation

fn isValidIpAddress(text: []const u8) bool {
    _ = net.IpAddress.parse(text, 0) catch return false;
    return true;
}

fn isValidHostName(text: []const u8) bool {
    net.HostName.validate(text) catch return false;
    return true;
}

Parsing an IP is a pure syntax/canonicality check. Host-name validation does not perform DNS lookup.

Index-Based Connection Pool

const Pool = struct {
    const Entry = struct { stream: net.Stream, in_use: bool };

    entries: std.ArrayList(Entry) = .empty,
    allocator: std.mem.Allocator,
    io: std.Io,

    fn acquire(self: *Pool, address: *const net.IpAddress) !usize {
        for (self.entries.items, 0..) |*entry, index| {
            if (!entry.in_use) {
                entry.in_use = true;
                return index;
            }
        }

        const stream = try address.connect(self.io, .{ .mode = .stream, .protocol = .tcp });
        errdefer stream.close(self.io);
        try self.entries.append(self.allocator, .{ .stream = stream, .in_use = true });
        return self.entries.items.len - 1;
    }

    fn get(self: *Pool, index: usize) *net.Stream {
        return &self.entries.items[index].stream;
    }

    fn release(self: *Pool, index: usize) void {
        self.entries.items[index].in_use = false;
    }

    fn deinit(self: *Pool) void {
        for (self.entries.items) |entry| entry.stream.close(self.io);
        self.entries.deinit(self.allocator);
        self.* = undefined;
    }
};

Indices are used because growing an ArrayList can invalidate pointers into its storage. A production pool also needs protocol-aware liveness checks, concurrency control, capacity limits, idle expiry, and a policy for discarding failed streams.

Prefer Protocol-Specific Clients

Direct stream examples are useful for custom protocols and learning the I/O model. For HTTP, use std.http.Client: it handles framing, redirects/options, response-body lifecycle, proxies, and TLS concerns that a raw GET snippet does not.

Error Sets

IP Connection Errors

net.IpAddress.ConnectError includes address/family and resource failures plus network outcomes such as:

error.ConnectionRefused
error.ConnectionResetByPeer
error.HostUnreachable
error.NetworkUnreachable
error.NetworkDown
error.Timeout
error.WouldBlock
error.AccessDenied

It also includes cancellation and implementation-specific unexpected I/O errors. Match only cases the caller can handle meaningfully and propagate the rest.

Host Lookup and Connect Errors

net.HostName.LookupError  // UnknownHostName, NameServerFailure,
                          // NoAddressReturned, configuration/DNS record errors, ...

net.HostName.ConnectError // LookupError || net.IpAddress.ConnectError

The old GetAddressListError and TcpConnectToHostError aliases are not Zig 0.16 APIs.

Stream Error Translation

Stream.Reader and Stream.Writer deliberately adapt network errors to the generic std.Io.Reader/std.Io.Writer interfaces:

  • On error.ReadFailed, inspect reader.err.
  • On error.WriteFailed, inspect writer.err.
  • EndOfStream is the normal generic-reader signal for an orderly peer close.
  • Close, shutdown, accept, connect, lookup, bind, and listen all take the explicit io used to create or operate the resource.