Things fall apart

Things fall apart

(photo by Jeff Jackowski, licensed under Creative Commons)

If you can doubt at points where other people feel no impulse to doubt, then you are making progress.
—Basil Liddell Hart, “Why Don’t We Learn from History?”

Everything that can go wrong will go wrong, as we all know, and that goes double for programmers. With software, everything probably already has gone wrong, but the tests haven’t detected it, because the tests are broken too.

Never mind. We do what we can, and one thing we can do is ensure that our programs expect things to go wrong all the time, and recover gracefully when they do—or, at least, report the problem in a helpful way.

In The magic function, we started building a little line-counting program in Rust, like the Unix wc tool. We imagined some magic function count_lines that would do the hard work for us, and we wrote a simple test for it:

#[test]
fn count_lines_fn_counts_lines_in_input() {
    let input = Cursor::new("line 1\nline 2\n");
    let lines = count_lines(input);
    assert_eq!(lines, 2, "wrong line count");
}

(Listing count_2)

By writing terrible code, we checked that this really tests something, because we now know it fails when count_lines returns the wrong answer. Then we got the test passing, with the following function that really does count lines:

pub fn count_lines(input: impl BufRead) -> usize {
    input.lines().count()
}

(Listing count_2)

So, do we have a production-ready line counter crate yet? Well, not quite, because in real life (as opposed to programming tutorials) things always seem to go wrong somehow, don’t they?

In my book The Secrets of Rust: Tools, we’ll see that programs can fail in all sorts of ways. Some of them are due to bugs—where the programmer has done something wrong. Others are just due to things going wrong in the environment where the program is running.

Results

These run-time errors are predictable, in the sense that we usually know where they can occur, even if we don’t know exactly when they’ll occur. For example, if we’re trying to open some file, the file might not exist, or we might not have permission to read it. If we’re trying to write to a file, there might be no disk space left. And so on.

As we saw in Rust error handling is perfect actually, the Result type conveys a data value that might exist, or not, because maybe some error happened. A Result can be one of two variants: either Ok(x), meaning that we have the value x, or Err(e), meaning some error e.

Any time some function returns a Result, you know that some kind of error could happen, so it’s wise to be prepared for it.

Readers are fallible

So, what about the Read trait? Can there be an error reading from a reader? Let’s have a look:

pub trait Read {
    // Required method
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;

    // ...
}

Trait std::io::Read

Well, of course there can—in our secret hearts, we knew that anyway. Reading a byte stream from any external source can fail for all sorts of reasons. But the paperwork confirms it: read returns a Result<usize>.

What this means is that if the result is the Ok variant, then it will contain a usize: the number of bytes successfully read on this call. But if it’s the Err variant, the error will tell us what went wrong.

How lines handles errors

We don’t call read directly in our count_lines function, but we call lines on input, using the BufRead trait, and that will call read to get the data. So you might be wondering what happens if there is some error from the underlying reader. What does lines do in that case?

There are lots of things it could do: for example, it could panic, or stop iterating, or yield an empty line but keep going, and so on. None of these seem quite satisfactory. What it actually does is, I think, exactly right. It doesn’t make any decision at all, but passes the decision on to us, by yielding a Result itself:

impl<B: BufRead> Iterator for Lines<B> {
    type Item = Result<String>;
    // ...
}

Struct std::io::Lines

In other words, a BufReader is not an iterator of lines, but of line results. That is, each item yielded by lines is a Result that either contains the line, or an error explaining why not.

Does our program handle errors?

So what does our code do when that Result is Err? Let’s take another look:

pub fn count_lines(input: impl BufRead) -> usize {
    input.lines().count()
}

(Listing count_2)

Well, “nothing special” is the answer! We just count all the items yielded by lines, whether they’re Ok or Err. That doesn’t seem right, does it?

So if there were an error reading the input, it sounds like our program would sit there not printing anything, and never getting anywhere. So, can we test this out? The program reads from standard input, so how could we deliberately trigger an error there?

One way would be to use the shell to pipe something into the program’s standard input. For example, we can pipe the contents of a file, so that the program counts the lines in the file:

cargo run <src/main.rs

8 lines

Fine, that works, but what we want is something that can’t be read this way, so we can see what the program does on encountering a read error. Well, depending on your operating system, you usually can’t read a directory. So attempting to pipe in “.”, the shorthand for the current directory, probably won’t work. Let’s try it with wc:

wc -l <.

wc: stdin: read: Is a directory

Exactly! That’s what I’m saying. So what does our Rust program do in the same situation? Time to find out:

cargo run <.

(Time passes)

Huh. It’s stuck, which is what we predicted. That’s no good, because users can’t tell if there’s really a problem, or the program’s just taking a while, or is waiting for more input. We should be able to detect the error, print some informative message about it, and stop, just like wc does.

Handling errors

Congratulations! We’ve found a bug. Good programmers are always delighted when they find a bug—not because they like bugs, of course, but finding one means there’s now a chance of fixing it. So how should we go about this?

We need to modify the count_lines function to do something different if it encounters a read error, but there’s something even more important we need to do before that. We need to add a test that proves count_lines handles errors correctly.

What’s the point of that, when we already know it doesn’t? Well, that is the point. We knew we’d correctly implemented the “counts lines” behaviour when the “counts lines” test stopped failing and started passing. Similarly, we’ll know we’ve correctly fixed the “handles errors” bug when the “handles errors” test stops failing and starts passing.

A fallible reader

To test this, we want to call count_lines with something that implements Read, but always returns an error. That’s easy to write:

struct ErrorReader;

impl Read for ErrorReader {
    fn read(&mut self, _buf: &mut [u8]) -> Result<usize> {
        Err(Error::new(ErrorKind::Other, "oh no"))
    }
}

(Listing count_3)

Right? All you need in order to be a reader is to implement the read method, and it returns Result. So we don’t even need to do any actual reading: we can just return the Err variant directly, giving it some Error made out of a string (“oh no”).

This is useless as a reader, of course: it can’t read anything. But it’s awfully useful for our test, because as soon as the lines iterator tries to read from it, it will blow up, and our test can check what happens.

To make our useless reader acceptable to count_lines, though, we need to wrap it in a BufReader, so let’s first import that:

use std::io::BufReader;

And here’s how we use it:

#[test]
fn count_lines_fn_returns_any_read_error() {
    let reader = BufReader::new(ErrorReader);
    let result = count_lines(reader);
    assert!(result.is_err(), "no error returned");
}

(Listing count_3)

If count_lines returns anything other than Err, this test will fail. As a quick thought experiment, what will happen if we run this test now? Will it fail?

Returning a Result

Well, no, because it doesn’t actually compile yet:

no method named `is_err` found for type `usize`

That’s fair. In order to even see the test fail, we need to modify count_lines to return a Result rather than a plain old usize. Let’s do that:

use std::io::{BufRead, Result};

pub fn count_lines(input: impl BufRead) -> Result<usize> {
    Ok(input.lines().count())
}

Notice that once we update the function’s return type to Result<usize>, we also have to wrap the count() iterator expression in Ok(). That’s not inferred: we have to explicitly say that the value we’re returning is Ok, not Err.

Now the test compiles, but if our guess is right, it still won’t fail, because count_lines just won’t ever return. Let’s see:

cargo test

(Time passes. Thorin sits down and starts singing about gold.)

All right, then. We’ve reproduced the bug in a test; in the next post, we’ll see if we can figure out how to fix it. Until then, ciao bella, and happy Rusting.

Catching grace

Catching grace

0