{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreietq4cdrqy7pfhrpjxv7jrvdddhcfjihts446eg7scn7wl7revgay",
"uri": "at://did:plc:wziiohjsf6mmaqbibugrffla/app.bsky.feed.post/3lwytej5h7d62"
},
"path": "/Im-Too-Dumb-For-Zigs-New-IO-Interface/",
"publishedAt": "2026-04-16T13:12:43.791Z",
"site": "https://www.openmymind.net",
"tags": [
"this performance issue",
"mix of types",
"only one place",
"@import"
],
"textContent": "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.\n\nI'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.\n\nZig has never been known for its documentation, but if we look at the documentation for `tls.Client.init`, we'll find:\n\n\n 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.\n\nSo 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:\n\n\n 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 );\n\nNote 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.\n\nSpeaking 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:\n\n\n 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);\n\nHere's where the code stands:\n\n\n 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 {}; }\n\nBut 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.\n\nLet's give it what it wants AND send some data:\n\n\n // 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\");\n\nNow, 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:\n\n\n // 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\");\n\nGreat, 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:\n\n\n 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]});\n\nIf 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`.\n\nHere's my current version (it doesn't work, but it doesn't crash!):\n\n\n 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]}); }\n\nWhen 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.\n\nI'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.\n\nLeave a comment",
"title": "I'm too dumb for Zig's new IO interface",
"updatedAt": "2025-08-22T00:00:00.000Z"
}