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
- TCP Clients
- TCP Servers
- IP Address Types
- Stream I/O
- DNS and Host Names
- Unix-Domain Sockets
- Sockets and Datagram APIs
- Common Patterns
- Error Sets
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, inspectreader.err. - On
error.WriteFailed, inspectwriter.err. EndOfStreamis the normal generic-reader signal for an orderly peer close.- Close, shutdown, accept, connect, lookup, bind, and listen all take the explicit
ioused to create or operate the resource.