Devlog · July 2026 · release 0.1.1
Teaching the VM to remember: stack traces
The spec says every Exception carries a stackTrace: ExecutionPoint[], captured natively by the VM, and that runaway recursion must become a catchable StackOverflowException — not a native crash. Here's how that landed, in four steps.
1. A line-number table in the bytecode
A stack trace is useless without source lines, and the VM only sees bytecode. So each compiled method now embeds a compact table of {start_pc, line} entries, emitted by codegen at statement granularity and deduplicated on line changes. The format round-trips through the shared nl-bytecode crate, so both compiler and VM read the same definition.
2. A shadow call stack
The interpreter recurses natively (a Rust call per NL frame), so there is no walkable NL stack to inspect. Instead of restructuring the interpreter, the VM keeps a lightweight parallel stack: a thread_local! list of frames pushed and popped by an RAII guard — which covers every exit path, including error propagation — with the current line resolved from the line table as each instruction executes. Being thread-local, spawned system.thread.Thread instances get isolation for free.
3. Recursion limits, calibrated empirically
With a frame counter in place, a depth guard becomes trivial — the interesting part was choosing the limit. Measured by bisection on a worst-case recursive program: the native Rust stack crashes around 300–350 NL frames in debug builds, so MAX_CALL_DEPTH = 150 keeps a ~2× margin. One surprise surfaced along the way: spawned threads got a ~2 MiB stack by default versus 8 MiB for the main thread, so a "safe" limit on the main thread would still have crashed natively inside a thread. Thread creation now requests an 8 MiB stack, and a single limit is safe everywhere.
The overflow is raised as if thrown by the calling instruction — the new frame is never pushed — so it unwinds through the caller's exception table and an ordinary NL try/catch can handle it.
4. Capture in the root constructor — and only there
Per the spec, the trace is captured natively while the root Exception constructor runs. The subtle bug to avoid: at that moment, the top of the stack contains the exception's own constructor chain (MyException → ... → Exception, each paused inside its super(...) call). Those frames must be excluded, and their count is exactly the extends-depth from the object's runtime class up to Exception. Get that right and the trace starts at the throw new ... site, as a user expects.
Natively raised exceptions — division by zero, null dereference, out-of-bounds, stack overflow — go through the same machinery, so they are traced too. Uncaught exceptions now print ClassName: message followed by one at file:line per frame on stderr.
The rule that kept it honest
One project rule applied throughout: no placeholder shims. Either the trace contains real lines, or the field stays out of the prelude entirely. An API that exists but returns empty data is worse than one that doesn't exist yet.
The step-by-step working notes (in French) are in journal/journal_02_stack_trace.md.