Making it harder to swallow `error.Canceled`

Going on a tangent, but lately I have been of the sentiment that the capture-less catch in Zig is a footgun. It’s way too easy to just plonk it after a function call and be done with it without properly considering and/or handling all the possible error values. The fixes in your second PR seem to support this view.

Honestly, I’ve come to the conclusion that I’d either like it to be outright removed from the language, or at least restricted to cases where there is only a single possible error value, with only an explicit catch |e| allowed in all other cases. I believe this might push programmers to handle each and every error case, or at least to think about them harder.


For a potentially even more controversial idea, I’ve been thinking whether catch should perhaps be redesigned completely, becoming its own switch-like construct. Think something like this:

fallibleFunc() catch {
    error.Canceled => return error.Canceled,
    error.SomeOtherFailure => {
        // handle SomeOtherFailure here
    },
    else => |e| {
        // we can still use `else` to handle the rest
    },
};

This way, you could still get a catch-all, but you’d be forced to think slightly harder about it because you’d have to write this beautiful[1] thing:

fallibleFunc() catch {
    else => {
        // some handler code
    },
};

No other forms of catch would be allowed, except maybe a shorthand when there is only a single value in the error set. Although I can immediately tell that just a catch <expression> would cause ambiguity in the syntax, so it would probably have to be something else. Maybe allow orelse for single-error sets?

In any case, there’d still be try when we don’t want to think about it at certain points in the code.


Summarized: since errors are meant to be a control flow mechanism, it might do us some good to be pushed towards that ideal some more.


  1. /s ↩︎

14 Likes

One could disallow captureless catch, and allow catch |_| as a less draconian change.

There is utility to having a named capture. I wouldn’t want to lose that.

1 Like

I don’t want to remove the named capture, quite the opposite :slightly_smiling_face:

Even the extra spicy version I’m proposing would allow naming the capture, just like a switch does, on the prong level. I’d even go as far as allowing for the full feature set of switch, including labeling:

fallibleFunc() c: catch {
    error.Canceled => return error.Canceled,
    error.SomeOtherFailure => {
        // handle SomeOtherFailure here
        continue :c error.WriteFailed; // continue to the else prong
    },
    else => |e| {
        // we can still use `else` to handle the rest
    },
};

Basically, what I’m proposing is to turn catch into a special-purpose switch for errors.

2 Likes

Oh, sorry for double-posting, maybe you meant “unnamed”?

Honestly, the only ones I can think of that are not subject to the problem of making errors too easy to swallow is catch @panic("panik!") and maybe catch unreachable (though for the latter I still think a catch-all catch is too broad and might lead to bad mistakes). I could imagine those being special-cased, though.

I absolutely agree with this, with one caveat. I like my catch unreachable when I absolutely know there will be no error. But I suppose that I could replace catch unreachable with comptime try in most of my use cases, if not all (now that I am thinking about it, I should probably do that anyway, since if there was error after all, it would be caught at comptime instead of runtime). Other than that I also would hate OutOfMemory being even more verbose. I’ve seen codebases do catch fatal.oom(), but I do catch |err| fatal.oom(err) - just to ensure no other error is ever swallowed. I wish it could be less verbose. I would absolutely hate having to do even more verbose catch { error.OutOfMemory => fatal.oom() } way too often. Still, being able to switch on catch directly would be really nice instead of having to do the catch |err| switch(err) { dance that appears way more often than anything else. Also I find catch |err| { log.err(...) } pattern frequent enough. I would also dislike that becoming even more verbose…

I find it funny that zig wants you to handle your errors properly, but then makes it so annoying to write catch |err| switch(err) { all the time. Maybe I am just doing things wrong…

So if you ask me

  • no captureless catch - win
  • switching directly on catch - absolute win, but I can see that it would make the language more complex. Maybe not much if captureless catch was removed though.
  • catch capture (without switching directly) - probably keep
4 Likes

I don’t think comptime try foo() has the semantics you want. if i understand correctly, it should attempt to call foo() at compile time, which should usually fail for runtime code.

It has exactly the semantics I want. I won’t be doing catch unreachable if I can’t know it won’t fail. Eg I will be doing it for IpAddress.parse("127.0.0.1") but not for other non-static things…

Checking my recent code for captureless catches, found a few cases:

  • catch unreachable, for things like printing into a buffer which is oversized and can never fail printing.
  • catch {} (and one catch 0, to unify with the erroring function’s return type, which is promptly discarded). Used in places where an error has already occurred, and, eg, sending the http error is best effort but unimportant because the client being gone might be the cause of the error.
  • reader.take(4) catch return error.NotQoiFile, when reading a file format header from a fixed buffer. It’s marginal, but actually yeah properly checking the error type is a bit better here:
    const header = reader.take(4) catch |err| switch (err) {
        error.EndOfStream => return error.NotQoiFile,
        error.ReadFailed => unreachable,
    };

So, as a sample size of 1 smallish project, that change would be a bit annoying in a bunch of places, and maybe encouraged me to write slightly better code in one. Overall I think I’m slightly in favor, as long as you could use catch |_|.

Hey Scott, I was just looking at your WebSocket PR, and you basically introduced new cases of this behavior. I was surprised about that, given your posts in this topic. https://codeberg.org/ziglang/zig/pulls/36734

Maybe I’m too obsessed about this, because I use Zig for software that runs 24/7 and it depends on cancellation working reliably. The error.Canceled error is only delivered once. If lost, it will not get re-delivered. Task that ignores cancellation == resource leak.

Seeing these errors all over any Zig codebase, I really wonder if I should just change the behavior in zio and return error.Canceled from every yield point after cancellation, essentially breaking the std.Io contract, but on the other hand, making tasks cancel properly.

I was surprised about that, given your posts in this topic.

Well, the explanation for this is pretty simple: Looking back on it, that mistake was introduced back in May, when 0.16.0 was two weeks old and I was using Io for the first time. https://codeberg.org/ScottRedig/websocket_test/commit/2bd75813b9e3f0e4041f5a2210a84e4d40707796

On a different note, I followed the standard for the std with my PR. However in my internal project code I’ve started using the error.checkFoo pattern I suggested above and found it to be effective.

1 Like

Seeing these errors all over any Zig codebase, I really wonder if I should just change the behavior in zio and return error.Canceled from every yield point after cancellation, essentially breaking the std.Io contract, but on the other hand, making tasks cancel properly.

Thinking about this further: I think improper handling of Canceled generally is a bug, so returning error.Canceled again is just asking for bugs to compound. However, this improper behavior should be runtime detectable: A thread which is canceled shouldn’t be doing anything except required cleanup and shutting down as quickly as possible. Any cleanup that goes through normally cancel-able operations should also have CancelProtection, so the cleanup itself isn’t only partially completed. Ergo, any cancelable operations performed after a cancel request that are not guarded by CancelProtection should be illegal behavior. So, at a minimum, crash on debug builds.

Also, something like checkAllAllocationFailures but for Canceled would be nice.

4 Likes

Well, one problem with that would be when one needs to send a packet or two over the network to properly cancel.
Or write something into a file.
So that would make that use case kinda problematic, I guess.

I’d be up to pair on this.

I find your attitude here frustrating. I would love it if you tried to collaborate more instead of threatening to split the ecosystem. Please remember that we have the same goals. A healthy community needs to be able to maintain a collaborative spirit even in the face of disagreement about technical topics (such as whether std lib reader/writer error set should have Canceled in it).

Also your comment about Zig being doomed to stay as a hobby project, same thing. When I read such bitter remarks from you it makes me feel like you’re trying to hurt the project instead of help it. Aren’t we working towards the same objectives here?

15 Likes

From my experience of looking at the history of many open source projects, BDFL run projects are quite prone to bitterness or frustration in (potential and/or existing) contributors when there are problems which said contributors see as highly important while the problems are in their opinion not taken serious enough by the BDFL.

This happens even more so when the projects reaches a certain size where the BDFL technically doesn’t have much time anymore next to their managerial duties (of organising contributors and their contributions etc.), but the BDFL still wants to be very hands on developing things themselves.

lalinsky here seems to see the problem of the ease of swallowing error.Canceled as very urgent because of the project he is working on (where it seems to be a very urgent problem).
Meanwhile from the outside it starts to see like you are starting to be stretched thin between the stuff you want to work on in Zig and the PRs getting made.

7 Likes

Here’s something I started to do to prevent accidental swallowing over the last few weeks: creating new functions together with new readers/writers instances.

That way I have exactly one place where I could swallow an error.ReadFailed/error.WriteFailed, since I only pass the reader/writer to one function, that’s it.

So instead of writing this:

fn save(io: std.Io, path: []const u8, data: []const u8) !void {
    const file = try std.Io.Dir.cwd().createFile(io, path, .{});
    defer file.close(io);
    var writer: std.Io.File.Writer = .init(file. io, &.{});
    writer.interface.writeAll(data) catch return writer.err.?;
    writer.interface.flush() catch return writer.err.?;
}

I write this:

fn save(io: std.Io, path: []const u8, data: []const u8) !void {
    const file = try std.Io.Dir.cwd().createFile(io, path, .{});
    defer file.close(io);
    var writer: std.Io.File.Writer = .init(file. io, &.{});
    struct {
        pub fn saveImpl(out: *std.Io.Writer, bytes: []const u8) !void {
            try out.writeAll(bytes);
            try out.flush();
        }
    }.saveImpl(&writer.interface, data) catch return writer.err.?;
}

And I do that pattern for (nearly) every single time I create a new reader/writer.

That way the only reason why such a wrapper function return error.ReadFailed or error.WriteFailed, is because it received one.

I think there are better patterns out there for this (including preventing to repeat the error unwrap code), but so far I haven’t found them.

But in general I think we need a linter which complains if a function takes a pointer to a std.Io.Reader/std.Io.Writer and uses try on a function with one of them as a parameter. I’m not sure the current design can be changed to not need such a linter.

3 Likes

I think this is a fair take but I also think that @lalinsky has created PRs which were then merged to solve some issues, but then for others just chosen to grouch about it on every thread related to the issue. Just like a BDFL project can be heavily swayed by the leader, so too can any project be heavily impacted by somebody choosing to rain on the parade.


your function pattern is really interesting! I wonder how much trouble you might save yourself by instead explicitly writing down the error set for your function and not allowing WriteFailed?

2 Likes

Only half related as the example is Io related, but is ecosystem fracturing a real fear? I ask because, I thought at least with Python, there was fracturing, but as the stdlib’s asyncio grew mature into a very easy to use form (regardless of whether people thing it’s a good abstraction), the stdlib’s version won out. I.e., by virtue of being the stdlib, even amidst a fractured ecosystem, it has the benefit of legitimacy. Thus, since it’d have the benefits of seeing what variants wind up working vs. not working, once it consolidates the findings into its own solution, it would naturally win out and re-unify the ecosystem. Obviously that would take years, as it’d take time to see what variants stand the test of time.

I do get that a lot of variants are already tested from which to draw inspiration from other programming languages and the like. So perhaps it’s a moot point, but I thought seeing how variants arise and develop in zig itself would show which are conducive towards zig itself. I also get that zig seems to have a desire for each step to be purposeful and solid when designing things. Consequently, a lot of thought goes in before making a change. However, once that thought is placed, zig doesn’t seem to be opposed to mass changes so long as it’s deemed a good change (i.e. introducing writer/read like this, the Io interface, etc.) Thus, if it did wait for the ecosystem as a whole to have something win out (imagine that story where you have a jar with poison bugs, and the winner is the best), zig doesn’t seem like it’d be paralyzed, but would be willing to adapt and make changes in light of those results, rooted in actual usage data.

2 Likes

I generally write down the error code.
But I (surprisingly often) have multiple writers in the function chain and with it two possible sources of an error.WriteFailed (same for readers respectively).

Additionally my pattern helps with needing to write the “catch” code only once. While in this example it’s rather easy, I often have a multiple line block.

I’m trying to collaborate, I really started these discussions with the goal of making Zig better. But what I see as fundamental issue, you see as lack of disciplíne. I can’t help with that. The reason why I’m looking for a systematic change, is because dealing with the current status quo is extremely tiring, if I want my software to be perfect. And you seem happy with the current design. So at least I’m trying to bring attention to these problems, so people see the patterns not to repeat in their codebases. I challenged someone to refactor std.http to avoid these issues, and then I see a PR that introduces more of them. And from an experienced Zig programmer, that shows how easy is it to misuse the API. I’m sorry if it feels like I’m attacking your work. That’s not my intention. My intention is to write software that works and help others do the same.

When the new readers/writers were introduced, I didn’t like the some concrete decisions, but I liked the idea overall. I was really invested in turning Zig into language, that feels just right for writing servers software. I spent over a year of really intensive work on zio, a foundation that many Zig servers now use in production. It opened new options, because with zio, you are no longer stuck to the networking model of a particular framework, you can use libraries from the ecosystem, they work together. That was true even before std.Io, just thanks to your reader/writer design.

I wrote many client libraries for databases myself, and they all were written with sans-io core, written on just readers/writers for as much code as possible. And over the last months, since I’ve seen your answer here about brushing teeth, I worked on reversing that. I now store concrete readers/writers, purely just to have full access to the inner readers/writers and can recover the inner errors. I do comptime gymnastics to make sure my public APIs don’t leak ReadFailed that the user can’t act on (they don’t own the reader).

That’s the problem, I see Zig as being mishandled and I have hard time letting it go. You created an amazing language. But it’s time to stop the experimental phase and start treating users more seriously. That doesn’t mean to stop experiments, just to introduce breaking changes in a more controlled manner. There are things that deserve a full breaking change, like the reader/writer, but std.Io could have been released in parallel with the existing stdlib, people would be excited to try it, and you would not lose people for who it’s a deal breaker. And it applies to many smaller things as well. Large projects do this all the time, they still experiment, but they don’t break backwards compatibility so readily. This takes discipline, having some release plan, not merging large changes at the end of the window. And testing should be taken more seriously. Zig 0.16 shipped with a lot of bugs in stdlib, because it’s largely untested. That’s a system change, not just lack of tests, because testing the stdlib properly would likely mean splitting compiler testing and stdlib testing. It’s common for hobby programmers to follow Zig master, not the last release, because it includes critical bug fixes. That also splits the ecosystem. I’m saying all this, because I want Zig to improve.

Yesterday I saw a crazy metric here in Ziggit, apparently I was here every single day for the last 365 days. I don’t know how that happened, but I guess it’s true. If that and my work on improving the networking ecosystem doesn’t show that I want people to succeed using Zig, I don’t know what would.

23 Likes