Timeout example with std.Io

It sounds like we’re getting concurrentTimeout in a future release, but meanwhile, this is what I’ve done to get timeouts with std.Io leveraging std.Io.Select. Let me know if I can improve this in any way! After all, this is a lot of boilerplate to accomplish a simple timeout.

    var attachments_arena: std.heap.ArenaAllocator = .init(gpa);
    defer attachments_arena.deinit();

    const SelectT = union(enum) {
        attachments: []const []const u8,
        timeout: void,
    };
    var buf: [2]SelectT = undefined;
    var select: std.Io.Select(SelectT) = .init(io, &buf);
    defer select.cancelDiscard();

    try select.concurrent(.attachments, downloadAttachments, .{ gpa, attachments_arena.allocator(), io, urls });
    try select.concurrent(.timeout, std.Io.sleep, .{ io, .fromSeconds(10), .awake });

    const files: []const []const u8 = switch (try select.await()) {
        .attachments => |attachments| attachments,
        .timeout => if (select.cancel()) |awaited| switch (awaited) {
            .attachments => |attachments| attachments,
            else => &.{},
        } else &.{},
    };

Also some additional feedback with concurrentTimeout (at least based on the proposal):

Adding another version of error.Canceled (error.Timeout) is a bit… annoying. Specifically, there isn’t much meaningful distinction to me, and as a library author, this now means propagating 2 errors everywhere I used to only propagate 1. This kind of propagation is especially annoying in cases where the error isn’t readily available, and I need to grab it from a reader’s state machine… although I guess now that I’ve already done the work, maybe it won’t be too bad. I know Zig has breaking changes nearly every release, but adding a new error to std.Io.Cancelable does not feel “worth it”. In my opinion, a timeout is just a different flavor of a Cancel, and they should just return error.Canceled, as library authors have already built infrastructure specifically for handling that error.

Another thing that could help library authors is the idea of a way to match if an error is part of an error set. Needing to switch (err) { error.Canceled, error.Timeout => |err| return err, ... } is going to get needlessly verbose and entirely defeats the purpose of Zig having great error handling. Something like switch (err) { std.Io.Cancelable => |err| return err, ... } gives library authors a single identifier they can use to say “these are the errors that should be propagated”. It’d also be extremely useful outside of this more specific case, being able to tell if an I/O error was caused by, say, a JSON parse error vs a network stream error.

I’ve had similar, in general cancelling concurrent tasks in a select/group can be a little bit of a tricky challenge.

The only thing I can offer is a slight “simplification” on the result handling code.

I believe this should work in your case? I use similar here:

var next: ?SelectT = select.await() catch null;
while (next) |result| {
    switch (result) {
       .attachments  => |attachments| return attachments,
       else => {},
    }
    next = select.cancel();
}
return &.{};

edit: @xeondev has a much better suggestion for your use case, in mine I’m having to deal with two concurrents, but yours is just one.

On the other points:

Agree on this, I’ll probably put some feedback on the proposal too.

On this, I’m unsure I agree - an else error handle is already somewhat of a code smell as more errors could be added to the return type and you will not know. Given that errors should be control flow indicators. An error set switch prong would have the same problem, but look less like a code smell.

1 Like

Alternatively, you can use something like std.Io.Event, pass it to the concurrent task and call waitTimeout in the caller task. The callee is expected to call std.Io.Event.set on exit. This saves you one concurrent task (as you won’t have a concurrent sleeper)

5 Likes

this sounds like a great solution to me, thanks for pointing it out.