Viscosity
Review
Part X · Drip in Rust · Day 41

163. Errors and diagnostics in Rust

A Drip program can fail in two ways, and drip-rs reports each on its own path: compile errors pile up in a Vec, a runtime error stops the VM with a stack trace. Both end as an exit code, and every byte on stderr matches the TypeScript VM.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Follow a compile error through error_at and panic mode, and a runtime error through runtime_error to the exit code.
  • Explain why runtime_error may push to self.stderr while it walks self.frames, and what a stale ip does to a trace.
  • Map drip-rs’s exit codes 0, 64, 65, 70 and 74 to the TypeScript CLI’s.
  • Write error_at with panic mode for a small compiler, with tests.

Five lines on stderr

class Oven {
  bake(n) { return this.heat(n); }
  heat(n) { return n * nil; }
}
fn go() {
  return Oven().bake(3);
}
go();

drip-rs oven.drip prints nothing on stdout, this on stderr, and exits with 70:

Operands must be numbers.
[line 3] in heat()
[line 2] in bake()
[line 6] in go()
[line 8] in script

drip --vm prints the same five lines and exits with 70 too. A Drip program fails either before it runs, with compile errors, or while it runs, with one runtime error. drip-rs reports the two on different paths, and both paths end in main as an exit code.

Compile errors: one per statement

The compiler pushes messages into errors: &mut Vec<String> and keeps going. Adapted from src/compiler.rs:

fn error_at(
    &mut self, token: Token<'src>,
    message: &str,
) {
    if self.panic_mode {
        return;
    }
    self.panic_mode = true;
    let location = match token.kind {
        TokenKind::Eof =>
            " at end".to_string(),
        TokenKind::Error => String::new(),
        _ => format!(" at '{}'",
            token.lexeme),
    };
    let line = token.line;
    self.errors.push(format!(
        "[line {line}] Error{location}: \
         {message}"));
    self.had_error = true;
}

Token is Copy, so error can pass self.previous by value. An Error token is one the scanner made for a bad character, and its lexeme is the message, so it gets no location. panic_mode is chapter 34’s idea: after the first error in a statement, the rest are probably echoes of it, so they are dropped until synchronize reaches the next statement. Three statements, three errors:

print (1 + ;) * ;
print @;
print 2
[line 1] Error at ';': Expect expression.
[line 2] Error: Unexpected character.
[line 3] Error at end: Expect ';' after
  value.

(The last line is wrapped here.) That is what both VMs print, with exit code 65. The tree-walker, drip without --vm, prints five errors for the same file, the scanner’s first, because it scans the whole file before parsing. Chapter 164 shows how the test corpus copes with that.

Runtime errors: a message and a trace

Intuition

A lost-luggage report: what went wrong, then every airport the bag passed through, last stop first.

Chapter 157 wrote the trace loop. Here is the whole route, from the failing instruction to the exit code:

Step throughA runtime error, from the failing instruction to the exit code
The call frames on the left and the VM's stderr lines on the right, as a runtime error is reportedheat: 0003 OP_MULTIPLYframes (innermost on top)heatip 0 (stale)lines[3] = 3bakeip 7lines[6] = 2goip 9lines[8] = 6scriptip 23lines[22] = 8self.ip = 4self.stderrEach line is lines[ip − 1]: ip is already past the failing instruction.
Four frames are running. pop_numbers finds 3 and nil, and its let ... else takes the else branch. Nothing has been printed yet.
1/7

The function’s signature is fn runtime_error(&mut self, message: impl Into<String>) -> RuntimeError. impl Into<String> lets callers pass a literal like "Stack overflow." or a String from format! (chapter 144). Inside, the loop reads self.frames and self.heap while it pushes to self.stderr. That is allowed: they are different fields, borrowed separately in one function body. Move the push into a helper, as TypeScript could, and it isn’t. On a toy, rustc 1.97 (trimmed):

error[E0502]: cannot borrow `*self` as
  mutable because it is also borrowed
  as immutable
16 | for f in self.frames.iter().rev() {
   |          ------------------------
   |          immutable borrow occurs here
17 |     self.say(format!("ip {}", f.ip));
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |     mutable borrow occurs here

self.say needs all of self, and self.frames is still borrowed.

What if the trace forgot that the top frame’s ip lives in the register? I changed that one line in a copy of drip-rs to read frame.ip for every frame. On print 1; print -nil;, the debug build panicked with attempt to subtract with overflow and exit code 101, because the script’s saved ip was still 0. Worse, with fn f() {} f(); on lines 1 and 2 and print -nil; on line 3, it printed [line 2] in script: a wrong line, silently.

Definition

A runtime stack trace names where a runtime error happened in every call in progress: the message, then one [line N] in name() line per frame, innermost first, with script for the top level. Each line is lines[ip - 1] of that frame’s chunk, because ip is already past the failing instruction. In drip-rs the innermost frame’s ip is the VM’s register, and the others’ are the values call saved.

After the error

run returns Err(RuntimeError) through its ?s (chapter 150), and interpret tidies up:

Err(RuntimeError) => {
    self.stack.clear();
    self.frames.clear();
    self.chunk = Rc::default();
    self.open_upvalues.clear();
    EXIT_RUNTIME_ERROR
}

The globals stay, so the VM can run another program, as a REPL would. tests/vm.rs checks that: after f() fails with Operand must be a number., [line 2] in f(), [line 3] in script, the same VM runs print x; and prints 1.

Exit codes

main, adapted from src/main.rs:

let [path] = files.as_slice() else {
    eprintln!("{USAGE}");
    return ExitCode::from(64);
};
// read the file, or exit with 74
let outcome = Vm::new(Options::default())
    .interpret(&source);
// stdout lines, then stderr lines
ExitCode::from(outcome.exit_code as u8)

let [path] = ... else is a slice pattern: exactly one file argument, or the usage message. main returns a std::process::ExitCode, built from a u8[2], so the i32 in the Outcome is cast with as u8, which is safe for 0, 65 and 70[3]. The codes are clox’s: 65 for a compile error, 70 for a runtime error, 64 for bad usage, 74 when the file can’t be read[1].

The TypeScript CLI also prints all of stdout and then all of stderr, and exits with 64, 65 or 70 in the same cases. Writing this chapter turned up one difference outside the corpus. Given a file that doesn’t exist, drip-rs printed Could not read "nope.drip": No such file or directory (os error 2) and exited with 74, while Node printed its own report of an uncaught exception and exited with 1. The TypeScript CLI now catches the error too and exits with 74; only the wording after the colon still differs, because it comes from each platform’s own error message.

The same bytes on stderr

To check “the same messages”, I ran every one of the corpus’s 265 programs through both VMs, kept stdout, stderr and the exit code of each in files, and compared them with cmp. stderr was byte-identical for all 265, 128 of which print something there, and so was every exit code: 137 programs exit with 0, 66 with 65 and 62 with 70. stdout is chapter 164’s story.

Under the hood

Nothing here is new to Drip. clox stops at the first runtime error, prints the message and one line per frame from the innermost out, and uses the same exit codes[1]. What Rust changed is plumbing: errors are values carried by ?, the messages are buffered in the Outcome instead of written as they happen, and the compiler, not a code review, checks that the trace loop and the stderr buffer don’t fight over self.

Your turn

Exerciseerror_at with panic mode

Write error_at, the compiler's error reporter, as drip-rs has it:

  • If panic_mode is set, report nothing. Otherwise set it.
  • Push [line N] Error{at}: {message}, where {at} is at end for an Eof token, empty for an Error token, and at 'lexeme' otherwise.
  • Set had_error.

synchronize clears panic_mode at the next statement. had_error is never cleared: one error anywhere means the program doesn't run.

#[derive(Clone, Copy, PartialEq)]
enum Kind {
    Ident,
    Eof,
    // Made by the scanner for a bad
    // character: the lexeme is the
    // message.
    Error,
}

#[derive(Clone, Copy)]
struct Token {
    kind: Kind,
    lexeme: &'static str,
    line: usize,
}

#[derive(Default)]
struct Reporter {
    errors: Vec<String>,
    had_error: bool,
    panic_mode: bool,
}

impl Reporter {
    // A statement boundary: errors may
    // be reported again.
    fn synchronize(&mut self) {
        self.panic_mode = false;
    }

    // Report one compile error, unless
    // one is already being reported.
    fn error_at(
        &mut self,
        t: Token,
        m: &str,
    ) {
        todo!()
    }
}
Open in Rust Playground ↗

What you learned

  • Compile errors are collected in a Vec<String>. error_at formats the location from the token’s kind, and panic mode keeps one error per statement. Exit code 65.
  • A runtime error writes its message and a stack trace, innermost first, then returns Err through every ?. Exit code 70.
  • Pushing to self.stderr while iterating self.frames is fine; calling a &mut self helper in that loop is E0502.
  • Reading a frame’s stale ip gives a panic or a wrong line.
  • main maps an Outcome to an ExitCode; stderr and exit codes match the TypeScript VM on all 265 corpus programs.

Check your understanding

0/6
  1. 1.

    print (1 + ;) * ; is one statement with two problems. How many errors does drip-rs report for it?

  2. 2.

    Why does an Error token get no location in the message?

  3. 3.

    A copy of drip-rs reads frame.ip for every frame in the trace. The script calls f() on line 2, then fails on line 3. What does the trace say?

  4. 4.

    Inside for frame in self.frames.iter().rev(), which line compiles?

  5. 5.

    What does drip-rs’s main return for a program with a runtime error?

  6. 6.

    I compared both VMs on all 265 corpus programs. What matched?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] The Rust Project Developers. Struct std::process::ExitCode (Rust standard library documentation). Rust standard library API documentation; source in library/std/src/process.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/process/struct.ExitCode.html (checked 2026-10-31)
  3. [3] The Rust Reference contributors. Operator expressions: Numeric cast. The Rust Reference. doc.rust-lang.org/reference/expressions/operator-expr.html#numeric-cast (checked 2026-10-31)