# Concurrently reading and writing from \`std.crypto.tls.Client\`

**URL:** <https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335>\
**Category:** Help\
**Tags:** standard-library\
**Created:** [August 22, 2026, 9:41pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335 "2026-08-22T21:41:27Z")\
**Posts on this page:** 9\
**Page:** 1

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [August 22, 2026, 9:41pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/1 "2026-08-22T21:41:27Z")

</div>

Is there a way to concurrently, i.e. through `std.Io.concurrent`, read from and write to `std.crypto.tls.Client`’s `writer` and `reader`? No particular requirements, just that blocked reads don’t block writes and vice versa.

---

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [August 22, 2026, 10:59pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/2 "2026-08-22T22:59:05Z")

</div>

“solution” to my particular use case

```zig
        // this might be the worst concurrency code i've ever written

        var buf: [2]Result = undefined;
        var first = std.Io.Select(Result).init(io, &buf);
        defer while (first.cancel()) |x| switch (x) {
            inline else => |m| gpa.free(m catch continue),
        };

        try first.concurrent(.received_message, std.Io.Queue([]u8).getOne, .{ client.outbound_packets, io });
        try first.concurrent(.message_to_send, receiveMessage, .{ reader, io, gpa, client.outbound_packets });

        const result = try first.await();

```

---

<div class="post-metadata">

**Author:** ![lalinsky](https://ziggit.dev/user_avatar/ziggit.dev/lalinsky/32/3720_2.png) [@lalinsky](https://ziggit.dev/u/lalinsky)\
**Post date:** [August 23, 2026, 5:09am UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/3 "2026-08-23T05:09:18Z")

</div>

Keep in mind that `std.crypto.tls.Client` is not safe to be used concurrently from two threads. If you use `reader` and `writer` concurrently, you will have them overriding internal fields without any synchronization.

I have a fork of tls.zig that allows me to do this concurrently, but in general it’s not a supported operation in TLS libraries. Even OpenSSL/BoringSSL don’t allow you to use the TLS context concurrently.

If you have an app that needs concurrent network I/O and still do TLS using the std client, you would need two tasks for the reads and writes, and build `std.Io.Reader`/`Writer` based on `std.Io.Queue(u8)` and feed the TLS client from those.

---

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [August 23, 2026, 12:30pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/4 "2026-08-23T12:30:07Z")

</div>

Thanks for the response.

> Keep in mind that `std.crypto.tls.Client` is not safe to be used concurrently from two threads. If you use `reader` and `writer` concurrently, you will have them overriding internal fields without any synchronization.

Yes, that is precisely the problem.

> I have a fork of tls.zig that allows me to do this concurrently, but in general it’s not a supported operation in TLS libraries. Even OpenSSL/BoringSSL don’t allow you to use the TLS context concurrently.

The intended usage of `std.tls.Client` is to use their `std.Io.Reader` and `std.Io.Writer`, which block on network I/O if backed by readers and writers that do network I/O, so the only way to serialize the access to `std.tls.Client` without reaching into implementation details is to hold a mutex while blocked on network I/O meaning that reading blocks writing and vice versa.

> If you have an app that needs concurrent network I/O and still do TLS using the std client, you would need two tasks for the reads and writes, and build `std.Io.Reader`/`Writer` based on `std.Io.Queue(u8)` and feed the TLS client from those.

Sorry, I don’t understand how that would allow for this use case?

---

<div class="post-metadata">

**Author:** ![lalinsky](https://ziggit.dev/user_avatar/ziggit.dev/lalinsky/32/3720_2.png) [@lalinsky](https://ziggit.dev/u/lalinsky)\
**Post date:** [August 23, 2026, 2:01pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/5 "2026-08-23T14:01:39Z")

</div>

> [@cancername](#):
>
> Sorry, I don’t understand how that would allow for this use case?

Yeah, it’s actually more complex. I was actually guiding someone else how to deal with a similar problem, but they ended up using native zio primitives to make this efficient.

The easiest option might be a mutex around the TLS client access, but because it’s blocking API, you would still need custom reader/writer to unlock and relock the mutex around network calls.

So the operation would be like this:

1. lock TLS mutex
2. call TLS reader
3. TLS does its thing
4. it calls the source reader
5. inside the custom source reader, you unlock the mutex
6. then you call the TCP reader
7. you relock the mutex
8. TLS does it’s thing
9. unlock the mutex

The same for writes.

Or, use tls.zig 🙂

---

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [August 23, 2026, 2:05pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/6 "2026-08-23T14:05:19Z")

</div>

Wow, that’s disgusting. Thank you though!

I think I’ll just use the sans-I/O API from tls.zig. It’s unfortunate that the std client isn’t suitable for this.

---

<div class="post-metadata">

**Author:** ![lalinsky](https://ziggit.dev/user_avatar/ziggit.dev/lalinsky/32/3720_2.png) [@lalinsky](https://ziggit.dev/u/lalinsky)\
**Post date:** [August 27, 2026, 5:46am UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/7 "2026-08-27T05:46:53Z")

</div>

After(/if) this change lands, you will be able to use even the blocking API from tls.zig concurrently:

> <https://github.com/ianic/tls.zig/pull/56>
>
> This is a big change, I'm sorry, but there is no way to split it further. My mai…n motivations are
> 
> 1) explicit error sets
> 2) reliable shutdown/close/EOF handling (now it matches the Go TLS client)
> 3) concurrent usage of the reader and writer
> 
> I spent a lot of time thinking about the \`reader.err\` and \`writer.err\` fields. For the transport errors, when the source stream fails, there are three main options for how to handle, keep the \`err\` as \`null\` (\`std.crypto.tls.Client\` does this), store \`ReadFailed\` in \`err\` (\`std.compress\` does this, but not consistently), or rename it and store e.g. \`TransportReadFailed\` in \`err\`. I originally went for \`TransportReadFailed\`, to make it super obvious, but then chickened out and went back to \`ReadFailed\`. I'm hoping the interface will change in the future, but if not, this is a good enough pattern. I'm also open to making it \`null\`, to make it compatible with \`std.crypto.tls\` and makes the user code a bit nicer.
> 
> https://ziggit.dev/t/making-it-harder-to-swallow-error-canceled/16556/58?u=lalinsky
> 
> Details from the commit message:
> 
> \---
> 
> Replace the close-notify flags with an explicit connection state machine. The previous boolean and default alert value could not reliably distinguish a clean shutdown, a pending fatal alert, and a failed connection, and the shared state was unsafe when a reader and writer ran concurrently.
> 
> Declare precise read and write error sets so Reader.err and Writer.err remain switchable, while ReadFailed and WriteFailed continue to expose failures from the underlying transport.
> 
> Distinguish EOF at a TLS record boundary from EOF in the middle of a record. The latter is always TlsConnectionTruncated. Record-boundary EOF is accepted by default because it is common among otherwise compatible servers, while the new strict\_close\_notify option lets callers require a proper TLS shutdown when their protocol needs truncation protection.
> 
> Make close idempotent, defer read-side fatal alerts to the writer, reject writes after terminal failure, and treat user\_canceled as a warning rather than a shutdown. Support one concurrent reader and writer by making state transitions atomic and limiting TLS 1.3 key updates to their respective cipher direction.
> 
> Keep incomplete input and insufficient output space retriable in the nonblocking API. Partial trailing records are left for the next decrypt call, and key-update requests are claimed only once their response is certain to fit.
> 
> Update examples to expose underlying I/O errors and add tests for shutdown orderings, truncation, partial records, key updates, and reader/writer races, including ThreadSanitizer coverage.

---

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [September 3, 2026, 4:05am UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/8 "2026-09-03T04:05:10Z")

</div>

Thanks a lot!

I did move to its non-blocking API and std.Io.Batch in the meantime, I’ll see how it goes!

---

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [October 2, 2026, 7:35pm UTC](https://ziggit.dev/t/concurrently-reading-and-writing-from-std-crypto-tls-client/17335/9 "2026-10-02T19:35:57Z")

</div>

(I am coming back to this because I had another use case for this in different code…)

This went well, and I ended up with a pair of ring buffers for reading and writing respectively and adding those to `std.Io.Batch`, like so:

```zig
    fn readWriteStep(c: *Connection, io: std.Io, timeout: std.Io.Timeout) !void {
        var read_iovecs: [2][]u8 = undefined;
        {
            try c.wireReadBuf().ensureUnusedCapacity(c.buf_allocator, 4096);

            read_iovecs = de.getPushBackSlices(u8, c.wireReadBuf());
            c.batch.addAt(0, .{ .net_read = .{
                .data = &read_iovecs,
                .socket_handle = c.stream.socket.handle,
            } });
        }

        var write_iovecs: [2][]u8 = undefined;
        {
            write_iovecs = de.getPopFrontSlices(u8, c.wireWriteBuf());

            if (write_iovecs[0].len != 0) {
                c.batch.addAt(1, .{ .net_write = .{
                    .data = &write_iovecs,
                    .socket_handle = c.stream.socket.handle,
                } });
            }
        }

        defer c.batch.cancel(io);

        var write_completed = false;
        while (!write_completed) {
            try c.batch.awaitConcurrent(io, timeout);

            while (c.batch.next()) |completion| switch (completion.result) {
                .net_read => |res| {
                    std.debug.assert(completion.index == 0);

                    de.advanceBack(u8, c.wireReadBuf(), res catch |err| switch (err) {
                        else => |e| return e,
                    });
                },
                .net_write => |res| {
                    std.debug.assert(completion.index == 1);

                    de.consumeFront(u8, c.wireWriteBuf(), try res);
                    write_completed = true;
                },
                else => unreachable,
            };
        }
    }

    fn readStep(c: *Connection, io: std.Io, timeout: std.Io.Timeout) !void {
        var read_iovecs: [2][]u8 = undefined;
        {
            try c.wireReadBuf().ensureUnusedCapacity(c.buf_allocator, 4096);

            read_iovecs = de.getPushBackSlices(u8, c.wireReadBuf());
            c.batch.addAt(0, .{ .net_read = .{
                .data = &read_iovecs,
                .socket_handle = c.stream.socket.handle,
            } });
        }
        defer c.batch.cancel(io);

        try c.batch.awaitConcurrent(io, timeout);

        while (c.batch.next()) |completion| switch (completion.result) {
            .net_read => |res| {
                std.debug.assert(completion.index == 0);

                de.advanceBack(u8, c.wireReadBuf(), try res);
            },
            else => unreachable,
        };

        std.debug.assert(c.batch.submitted.head == .none and c.batch.submitted.tail == .none);
        std.debug.assert(c.batch.pending.head == .none and c.batch.pending.tail == .none);
    }

```

Not including cross-version compatibility hacks.
