Step 7 — Runtime Errors and (Mini) Stack Traces

What Counts as a Runtime Error

cp-07 catches at runtime:

CauseWhereMessage template
Undefined global readOp::GetGlobalundefined variable '<name>'
Undefined global writeOp::SetGlobalundefined variable '<name>'
Type mismatch in binary +Op::Addoperands to '+' must be two numbers or two strings
Wrong type in numeric opOp::Sub/Mul/…operands must be numbers
Division by zeroOp::Div, Op::Moddivision by zero
Wrong type in unary -Op::Negoperand to unary '-' must be a number
Calling a non-functioncallValuecan only call functions
Arity mismatchcallValuefunction '<n>' expects K argument(s)
Stack overflowcallValuestack overflow (max call depth)
Use of unsupported Op::*Upvaluedispatch loopupvalues not supported in cp-07 (see cp-12)

The RuntimeError Type

struct RuntimeError : std::runtime_error {
    int line;
    RuntimeError(int l, std::string m)
        : std::runtime_error(std::move(m)), line(l) {}
};

A single exception type keeps the dispatch loop's error path uniform. The public VM::run catches it at the top, prints a one-line message + the call chain, and returns Status::RuntimeError.

Source-Line Tracking

The compiler stuffs a lines parallel array into each Chunk:

struct Chunk {
    std::vector<uint8_t> code;
    std::vector<int>     lines;       // 1:1 with code
    std::vector<Value>   constants;
};

This is wasteful — one int per byte — and a real VM compresses lines via run-length encoding. We trade memory for clarity; cp-15 covers debug-info compression.

At runtime the VM computes the current line as

int currentLine() {
    auto& fr = frames_.back();
    size_t off = fr.ip - fr.fn->chunk.code.data() - 1;   // -1: we already advanced past the op
    return fr.fn->chunk.lines[off];
}

The -1 is the subtle bit — readByte() post-increments ip, so by the time we're handling an opcode ip_ points to the next byte.

A Mini Stack Trace

VM::run's catch block walks frames_ from the top down:

catch (const RuntimeError& e) {
    (*err) << "runtime error [line " << e.line << "]: " << e.what() << "\n";
    for (auto it = frames_.rbegin(); it != frames_.rend(); ++it) {
        const auto& fn = it->fn;
        (*err) << "  in " << (fn->name.empty() ? "<script>" : fn->name) << "\n";
    }
    return Status::RuntimeError;
}

So:

fn boom() { return 1 / 0; }
fn caller() { return boom(); }
print caller();

prints (on stderr):

runtime error [line 1]: division by zero
  in boom
  in caller
  in <script>

This is the minimum-viable stack trace. Real implementations also include file/line per frame, source snippets, and column ranges. cp-15 (Tooling & Diagnostics) is the big upgrade.

Why Not assert?

A core principle: runtime errors are not crashes. They are recoverable from the embedder's standpoint — a REPL must keep running after a typo, an embedded interpreter must report to its host, etc. Using assert or std::terminate would couple the VM's lifecycle to the user's mistake.

This is also why we don't std::exit() from inside the VM; the driver (main.cpp) decides the exit code from Status.

Compile-Time vs Runtime Errors

After cp-04 (resolver) and cp-05 (typechecker), most static errors fire before we ever build a chunk:

ErrorCaught byPhase
Undefined name (local)Resolverstatic
Use before initResolverstatic
Assignment to letResolver / Compilerstatic
Calling non-callable typeTypeCheckerstatic
Arity mismatch (known fn)TypeCheckerstatic
Type mismatch on operatorTypeCheckerstatic
Bad cast at runtimeVMdynamic
Undefined globalVMdynamic
Division by zero on user inputVMdynamic

This split is intentional — static = before any code runs; dynamic = unavoidable.

Pitfalls

  • Catching std::exception too broadly. Other exceptions (bad-alloc, malformed UTF-8 in string display, …) deserve different handling. We catch RuntimeError specifically and let everything else propagate to the driver.
  • Forgetting to flush err. When the VM is embedded in another process, using std::cerr is fine; when the test harness uses ostringstream, buffered output is captured automatically.
  • Reusing line indices after chunk mutation. Chunk::lines must stay 1:1 with code; any opcode emit must push exactly one line entry per byte. Helper functions handle this — never push to code directly.