I'm too dumb for Zig's new IO interface
Aug 22, 2025
You might have heard that Zig 0.15 introduces a new IO interface, with the focus for this release being the new std.Io.Reader and std.Io.Writer types. The old "interfaces" had problems. Like this performance issue that I opened. And it relied on a mix of types, which always confused me, and a lot of anytype
- which is generally great, but a poor foundation to build an interface on.
I've been slowly upgrading my libraries, and I ran into changes to the tls.Client
client used by my smtp library. For the life of me, I just don't understand how it works.
Zig has never been known for its documentation, but if we look at the documentation for tls.Client.init
, we'll find:
pub fn init(input: *std.Io.Reader, output: *std.Io.Writer, options: Options) InitError!Client
Initiates a TLS handshake and establishes a TLSv1.2 or TLSv1.3 session.
So it takes one of these new Readers and a new Writer, along with some options (sneak peak, which aren't all optional). It doesn't look like you can just give it a net.Stream
, but net.Stream
does expose a reader()
and writer()
method, so that's probably a good place to start:
const stream = try std.net.tcpConnectToHost(allocator, "www.openmymind.net", 443);
defer stream.close();
var writer = stream.writer(&.{});
var reader = stream.reader(&.{});
var tls_client = try std.crypto.tls.Client.init(
reader.interface(),
&writer.interface,
.{},
);
Note that stream.writer()
returns a Stream.Writer
and stream.reader()
returns a Stream.Reader
- those aren't the types our tls.Client
expects. To convert the Stream.Reader
to an *std.Io.Reader
, we need to call its interface()
method. To get a *std.io.Writer
from an Stream.Writer
, we need the address of its &interface
field. This doesn't seem particularly consistent. Don't forget that the writer
and reader
need a stable address. Because I'm trying to get the simplest example working, this isn't an issue - everything will live on the stack of main
. In a real word example, I think it means that I'll always have to wrap the tls.Client
into my own heap-allocated type; giving the writer and reader have a cozy stable home.
Speaking of allocations, you might have noticed that stream.writer
and stream.reader
take a parameter. It's the buffer they should use. Buffering is a first class citizen of the new Io interface - who needs composition? The documentation does tell me these need to be at least std.crypto.tls.max_ciphertext_record_len
large, so we need to fix things a bit:
var write_buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var writer = stream.writer(&write_buf);
var read_buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var reader = stream.reader(&read_buf);
Here's where the code stands:
const std = @import("std");
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
const allocator = gpa.allocator();
const stream = try std.net.tcpConnectToHost(allocator, "www.openmymind.net", 443);
defer stream.close();
var write_buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var writer = stream.writer(&write_buf);
var read_buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var reader = stream.reader(&read_buf);
var tls_client = try std.crypto.tls.Client.init(
reader.interface(),
&writer.interface,
.{
},
);
defer tls_client.end() catch {};
}
But if you try to run it, you'll get a compilation error. Turns out we have to provide 4 options: the ca_bundle, a host, a write_buffer
and a read_buffer
. Normally I'd expect the options parameter to be for optional parameters, I don't understand why some parameters (input and output) are passed one way while writer_buffer
and read_buffer
are passed another.
Let's give it what it wants AND send some data:
var bundle = std.crypto.Certificate.Bundle{};
try bundle.rescan(allocator);
defer bundle.deinit(allocator);
var tls_client = try std.crypto.tls.Client.init(
reader.interface(),
&writer.interface,
.{
.ca = .{.bundle = bundle},
.host = .{ .explicit = "www.openmymind.net" } ,
.read_buffer = &.{},
.write_buffer = &.{},
},
);
defer tls_client.end() catch {};
try tls_client.writer.writeAll("GET / HTTP/1.1\r\n\r\n");
Now, if I try to run it, the program just hangs. I don't know what write_buffer
is, but I know Zig now loves buffers, so let's try to give it something:
var write_buf2: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var tls_client = try std.crypto.tls.Client.init(
reader.interface(),
&writer.interface,
.{
.ca = .{.bundle = bundle},
.host = .{ .explicit = "www.openmymind.net" } ,
.read_buffer = &.{},
.write_buffer = &write_buf2,
},
);
defer tls_client.end() catch {};
try tls_client.writer.writeAll("GET / HTTP/1.1\r\n\r\n");
Great, now the code doesn't hang, all we need to do is read the response. tls.Client
exposes a reader: *std.Io.Reader
field which is "Decrypted stream from the server to the client." That sounds like what we want, but believe it or not std.Io.Reader
doesn't have a read
method. It has a peak
a takeByteSigned
, a readSliceShort
(which seems close, but it blocks until the provided buffer is full), a peekArray
and a lot more, but nothing like the read
I'd expect. The closest I can find, which I think does what I want, is to stream it to a writer:
var buf: [1024]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const n = try tls_client.reader.stream(&w, .limited(buf.len));
std.debug.print("read: {d} - {s}\n", .{n, buf[0..n]});
If we try to run the code now, it crashes. We've apparently failed an assertion regarding the length of a buffer. So it seems like we also have to provide a read_buffer
.
Here's my current version (it doesn't work, but it doesn't crash!):
const std = @import("std");
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
const allocator = gpa.allocator();
const stream = try std.net.tcpConnectToHost(allocator, "www.openmymind.net", 443);
defer stream.close();
var write_buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var writer = stream.writer(&write_buf);
var read_buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var reader = stream.reader(&read_buf);
var bundle = std.crypto.Certificate.Bundle{};
try bundle.rescan(allocator);
defer bundle.deinit(allocator);
var write_buf2: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var read_buf2: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var tls_client = try std.crypto.tls.Client.init(
reader.interface(),
&writer.interface,
.{
.ca = .{.bundle = bundle},
.host = .{ .explicit = "www.openmymind.net" } ,
.read_buffer = &read_buf2,
.write_buffer = &write_buf2,
},
);
defer tls_client.end() catch {};
try tls_client.writer.writeAll("GET / HTTP/1.1\r\n\r\n");
var buf: [std.crypto.tls.max_ciphertext_record_len]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const n = try tls_client.reader.stream(&w, .limited(buf.len));
std.debug.print("read: {d} - {s}\n", .{n, buf[0..n]});
}
When I looked through Zig's source code, there's only one place using tls.Client
. It helped to get me where where I am. I couldn't find any tests.
I'll admit that during this migration, I've missed some basic things. For example, someone had to help me find std.fmt.printInt
- the renamed version of std.fmt.formatIntBuf
. Maybe there's a helper like: tls.Client.init(allocator, stream)
somewhere. And maybe it makes sense that we do reader.interface()
but &writer.interface
- I'm reminded of Go's *http.Request
and http.ResponseWrite
. And maybe Zig has some consistent rule for what parameters belong in options. And I know nothing about TLS, so maybe it makes complete sense to need 4 buffers. I feel a bit more confident about the weirdness of not having a read(buf: []u8) !usize
function on Reader
, but at this point I wouldn't bet on me.