I want to echo @Tom-Phinney’s suggestion that this could be a library which stands independently. It could even be pretty ergonomic by implementing From in both directions (io::Error <-> OpenFileError/ConnectError/WriteError/etc). There’s a lot of skepticism in this thread about whether classifying IO errors is feasible or desireable; implementing such a library and hardening or simplifying some in-the-wild application error handling using it would be great proof.
I’m with @canndrew on this, at least in spirit/intent. If nothing else, operation-specific error types would make it easier for users to figure out the precise set of possible errors. Then, the layers of detail/drilldown can be used to be as precise (or not) as needed/desired. So essentially, this pushes the burden of figuring out the exact (or as close to it as possible) set of errors on the lib authors, in this case tokio.
I do agree it’s a challenge and will not be truly exhaustive in the face of future platforms or buggy drivers, but it would make it easier for users since they won’t have to individually repeat this mapping exercise, likely getting it wrong in the process.
I suspect this wasn’t done in tokio, or std for that matter, because of how labor intensive this process is. And it becomes quite an opinionated API in the process, in terms of how categorizations and hierarchies are defined. That may not be a bad thing but I can see the hesitation to do that.
Interesting. I also like the idea of being able define nested enums. Possible syntax:
enum Foo {
Bar,
Wow,
enum Smeg {
Flim,
Flam,
},
}
let x: Foo::Smeg = Foo::Smeg::Flam;
let y: Foo = x;
I don't know why you'd assume such a poor implementation of this idea. The
third level of OpenFileError
is NotFoundError
which distinguishes between
cases like path/to/file
not being openable because to
isn't a directory or
because the whole file name was too long. I find it hard to imagine cases where
people would actually care about this distinction. Either way you're
clearly just dealing with an incorrect file name.
I find it easy to imagine scenarios where people want to recovery from UDP "destination unreachable" errors though. Like, whenever you're using a UDP socket to talk to more than one host but you don't want to crash if one of the hosts is down. That seems like a very typical use case for UDP actually.
The idea is to divide and organize errors based on how people are likely to want
to respond to them. UdpSocket::recv_from
errors which don't mean your socket
has become unusable - and after which you can just call recv_from
again to
keep receiving packets - seem like a top-level category to me.
I was under the impression that tokio
could handle 1,000,000 concurrent
tasks.
You can't think of examples of where programs might want to use all the resources available? I'm not saying users shouldn't raise those limits, but maybe sometimes it's best for the program to slow down and notify the user rather than just crashing.
No, but they return something useless, type-wise. To quote myself:
If
HashMap::get
returnedResult<&T, io::Error>
do you think people would reliably handle the case where an entry is missing? Because I think lots of people would either (a) completely neglect this edge case in their code and end up throwingio::Error
s up the stack instead of doing whatever they should have done ...
Doesn't this sound a lot like Python? Where you're not forced to consider the
case where an entry is missing and instead you just end up throwing a
KeyError
up the stack?
Yes, the second one does. The form of the error guides people in how to handle it. That's kinda my whole point.
@Tom-Phinney, @birkenfeld, @danburkert
I originally started writing a crate which wraps tokio
's types and changes the
error types of all the methods. I figured it was worth trying to get this into
tokio
itself though, otherwise I might end up being the sole user and
maintainer of it, it wouldn’t get sufficient feedback, it wouldn’t be able to influence other libraries to clean up
their error handling, there would be compatibility speed bumps with vanilla
tokio, etc.
In the likely event that this pseudo-RFC doesn’t end up going anywhere I’ll probably go back to working on that crate, or at least adding stuff to it as I need it.
I don't understand what io::Error has to do with the similarities between Python's dict and Rust's HashMap
- they're already quite similar. x[i]
throws an exception/panics if the key is not present, and x.get(i)
returns None
if the key is not present.
I think the Python comparisons are really not needed here - I think the gist of the issue @canndrew describes is fairly well understood, and the Sender example in the OP is a nice distilled example of what he’s after. The issue, of course, is that the sender truly has only two** modes of failure and it can guarantee that because it owns all the code - there’s no OS/platform to worry about failing in umpteen different ways. So the surface areas are very different.
** - if we had fallible allocators, OOM would perhaps be a third. Fallible allocations is a separate can of worms, and would play a role when discussing a system running at/close to the limits.
With again very deliberately bad names, another semi proposal that takes a middle ground:
pub enum IoError<Recoverable: Into<io::Error>> {
LikelyRecoverable(Recoverable),
LikelyFatal(io::Error),
}
I agree with the core motivation of the proposal: provide an error type that more easily lends itself to handling likely recoverable errors.
The way I see it, there are three classes of IO errors. 1) Errors that are an expected case, and can be handled where (or close to where) they’re raised. 2) Errors that signal unrecoverable failure, and should bubble up to the task organizer level, which cancels the task, logs it, and moves on to the next one. 3) Errors that signify an environment/expectation mismatch. In this case, you’re best off just stopping as soon as possible, because you’re not getting any work done as is.
This proposal is about allowing the programmer to discover these classes through the design of the IO functions rather than by knowing (or guessing) what io::Error
s they might have enough information to handle.
Again, you’re not going to be able to guess on behalf of the application which errors it wants to recover from, which it wants to bubble up, and which it wants to abort over. Different applications have different requirements.
As an example: if you’re writing a CLI application and opening a file specified on the command line, you’ll want to treat all errors as fatal, report it to the user along with the corresponding filename, and exit. If you’re a GUI application providing a file chooser, you might report errors to the user but keep the application running so they can open another file (or keep working with the file they’re working with). If you’re opening a configuration file, you’ll ignore ENOENT
and treat it as the absence of a configuration file, but report any other error to the user. If you’re an application like sftp or rsync, you should report any errno code back to the user over the wire, and keep going. If you’re an application like find
or cp -a
, you’ll want to print errors, keep going, and emit an appropriate error code later. If you’re a long-running daemon, you might want to abort the current request but recover for subsequent requests. If you’re a one-off tool to connect to a service you might want to fail on a connection failure, but if you’re a long-running sync daemon you could log the error and keep trying. If you’re a tool like dd_rescue
, you might ignore all kinds of mysterious hardware failure error codes and keep trying to read data off a device.
Just report an error the user can easily introspect, and make it easy to handle the subset of cases you know how to recover from (and easy to get the underlying errno
). Anything else would put application-specific logic into what should be a fairly direct library.
Directness is in the eye of the beholder. To someone familiar with IO and all of its myriad failure modes, a flat list of errors is preferred, due to the ease of inspection. The unfamiliar desire classifications, so that the error model fits into their heads. Putting aside the question of what the error model should be, consider that io::ErrorKind
has 18 variants, any of which could be returned by an io
method. The existing mental model is difficult.
I think @vitalyd identified one of the key motivations for this idea (emphasis added):
This is alluded to in @canndrew's problem description:
The question, in my mind, is how likely is a user to make these mistakes?
Applying hypotheticals and lived experience works well for novel features. As this idea concerns refining an existing system, might I suggest scanning existing crates for commonly-used patterns? Knowing which errors are handled and which are bubbled should provide some focus to the discussion and address @josh's concern that the resulting design may be too opinionated. Further, identifying anti-patterns and finding mistakes can provide evidence that the motivation suggested by @vitalyd is correct.
“with fire” is perhaps a bit too dramatic, but in general I agree io::Error
could be nicer.
Separate internal ErrorKind
is slightly annoying to work with. I do check for NotFound
, PermissionDenied
and AlreadyExists
, as these are very common, and very clear failure modes. It’d be nice to match
them in the same match
as Ok()
rather than having to call err.kind()
separately.
Another problem is that io::Error
does some weird stuff internally which makes it impossible to Clone
. I wanted to cache (memoize) Result
s of some I/O operations, and I couldn’t cache the actual error.
I’d be all for a helper library that makes it easier to classify io::Error
via some common patterns; I just don’t want to see io::Error
itself (or a replacement) make it more difficult to get at the underlying error.
Any reorganized IO error hiearchy would surely be Into<io::Error>
, as that is the stdlib IO error. I don’t think anyone wants to take away stdlib compatibility just for the sake of ideal error hiearchy. Any functionality on io::Error
should still be provided. It’s just about providing a more consumable interface where you don’t have to know all the details to handle some expected failure cases.
I also want to point out that “organizing” io::ErrorKind
variants has nothing to do with how callers decide to handle them or propagate, which @josh happened to mention above:
The mapping is more likely to put the caller into a “pit of success” than letting them look at the swiss cheese of all variants and trying to figure out which are possible for some operation. I mention swiss cheese as it’s common to use that phrase in the db world - a table with many columns, only some of which are applicable to a given row type, is full of holes - NULL values. You can think of ErrorKind variants that don’t make sense universally as analogous.
I also think the copy
example mentioned upthread is a good one, even though it’s not exactly related to io::Error
itself. I don’t think we’ve talked about that much, but perhaps that pattern should be discouraged (ie one error to represent multiple individually-fallible errors).
Let's not hijack this discussion about error handling principles in order to introduce another mysterious and very niche feature into the language. You can already do this with a newtype variant, there's no need for special syntax.
My own annoyances with io::Error
run in a very different direction: it feels too verbose to do something like throwing unless it is one specific error kind, which I more commonly want to do. I also recall the constructor for manufactored io::Error
s being a bit difficult to use.
POSIX specifically allows functions to return error codes outside the POSIX-specified ones for platform-specific failure modes:
Implementations may support additional errors not included in this list, may generate errors included in this list under circumstances other than those described here, or may contain extensions or limitations that prevent some errors from occurring.
The ERRORS section on each reference page specifies which error conditions shall be detected by all implementations ("shall fail") and which may be optionally detected by an implementation ("may fail").
Your assumptions about what the ERRORS section means are incorrect, and would lead to fragile (broken) programs. It's entirely legal for open(2)
to produce ECONNREFUSED
- consider, for example, the /dev/tcp interface that some systems borrow from Plan 9.
The platform interface is dynamically-typed, and Rust can't change that. Rust pretending the interface is statically typed would result in programs that are incorrect at the boundary.
Ugh, well that sucks...
Okay maybe panicking is out of the question entirely, but having the errors structured based on how and how likely you have to handle them would still be very useful.
Alternatively are there any multi-billionaires here who want to fund a project to reinvent computers from the silicon up, making use of 70-odd years of experience?
You may be interested in the RISC-V ISA, the CHERI capability ISA extensions, the formally-verified seL4 microkernel, and the Robigalia project.
EDIT: Though I'll note that, at the inter-process boundary, everything is at best gradually typed - the incoming data cannot be statically known to conform to type invariants, and so the "cast insertion" discipline of gradual typing (which either produces validly typed data or a runtime type error) is the best that can be done.
I bet Perl's tied hashes can do this
To my shame, I have done this - I wrote a Perl program that connected to a manga website, and exposed its available series as a tied hash keyed by series name, whose values were tied arrays of issues, whose elements were tied arrays of pages' image data.
I then "simply" iterated over the resulting data structure to produce CBZ archives for the manga I wanted to read on the bus (without internet)