Step 7 — Runtime Errors and (Mini) Stack Traces
What Counts as a Runtime Error
cp-07 catches at runtime:
| Cause | Where | Message template |
|---|---|---|
| Undefined global read | Op::GetGlobal | undefined variable '<name>' |
| Undefined global write | Op::SetGlobal | undefined variable '<name>' |
Type mismatch in binary + | Op::Add | operands to '+' must be two numbers or two strings |
| Wrong type in numeric op | Op::Sub/Mul/… | operands must be numbers |
| Division by zero | Op::Div, Op::Mod | division by zero |
Wrong type in unary - | Op::Neg | operand to unary '-' must be a number |
| Calling a non-function | callValue | can only call functions |
| Arity mismatch | callValue | function '<n>' expects K argument(s) |
| Stack overflow | callValue | stack overflow (max call depth) |
Use of unsupported Op::*Upvalue | dispatch loop | upvalues 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:
| Error | Caught by | Phase |
|---|---|---|
| Undefined name (local) | Resolver | static |
| Use before init | Resolver | static |
Assignment to let | Resolver / Compiler | static |
| Calling non-callable type | TypeChecker | static |
| Arity mismatch (known fn) | TypeChecker | static |
| Type mismatch on operator | TypeChecker | static |
| Bad cast at runtime | VM | dynamic |
| Undefined global | VM | dynamic |
| Division by zero on user input | VM | dynamic |
This split is intentional — static = before any code runs; dynamic = unavoidable.
Pitfalls
- Catching
std::exceptiontoo broadly. Other exceptions (bad-alloc, malformed UTF-8 in string display, …) deserve different handling. We catchRuntimeErrorspecifically and let everything else propagate to the driver. - Forgetting to flush
err. When the VM is embedded in another process, usingstd::cerris fine; when the test harness usesostringstream, buffered output is captured automatically. - Reusing line indices after chunk mutation.
Chunk::linesmust stay 1:1 withcode; any opcode emit must push exactly one line entry per byte. Helper functions handle this — never push tocodedirectly.