homedark

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,
  .{}, // options TODO
);

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:

// existing setup...

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:

// existing setup...

// I don't know what size this should/has to be??
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.