A Go panic anywhere inside the interpreter loop is recovered, discarded, and reported to the caller as a successful run. The process prints nothing further and exits 0.
Mechanism
VM.run installs a blanket recover (pkg/vm/vm.go:1250-1259):
func (vm *VM) run() (InterpretResult, Value) {
defer func() {
if r := recover(); r != nil {
if debugVM {
fmt.Printf("[PANIC] Recovered panic in VM.run(): %v\n", r)
debug.PrintStack()
}
}
}()
debugVM is false in normal builds, so the panic value and stack are dropped on the floor. Because the returns are unnamed, the recovered function then yields the zero values — and InterpretOK is the first iota in the InterpretResult block (pkg/vm/vm.go:389-395), so zero is InterpretOK.
The caller therefore cannot distinguish a crash from a clean run: it sees InterpretOK and an undefined result.
Reproduction
Any panicking native will do. Before the BigInt fix, this was a one-liner:
var b = 10n;
console.log("before");
console.log(b.toString()); // panics inside the native
console.log("after");
$ ./paserati --no-typecheck repro.js
before
$ echo $?
0
No error, no stack, no non-zero exit — after simply never runs.
Why it matters
- It hides bugs for as long as nobody looks. The BigInt breakage meant every
BigInt.prototype method was unusable on a primitive receiver, and it presented as "the script stops early."
- It is invisible to a test harness. Anything checking exit status or absence of stderr sees success. A panicking script looks like a passing one.
debugVM is the wrong gate. It has to be flipped at compile time and turns on very heavy tracing, so nobody enables it just to find out why a script stopped.
Possible directions
Deliberately not proposing a patch, since the right answer is a design call:
- Convert the recovered panic into a runtime error — return
InterpretRuntimeError with the panic value as the message, so it surfaces through the normal error path and sets a non-zero exit.
- Re-panic after logging, so it fails loudly with a usable stack.
- Keep recovering, but always write the panic value and stack to stderr regardless of
debugVM, and return InterpretRuntimeError.
Any of the three would have made the BigInt bug obvious immediately. Option 1 seems most in keeping with the engine's existing runtimeError handling, but it is your call.
Related: the BigInt bug that surfaced this.
🤖 Generated with Claude Code
A Go panic anywhere inside the interpreter loop is recovered, discarded, and reported to the caller as a successful run. The process prints nothing further and exits 0.
Mechanism
VM.runinstalls a blanket recover (pkg/vm/vm.go:1250-1259):debugVMisfalsein normal builds, so the panic value and stack are dropped on the floor. Because the returns are unnamed, the recovered function then yields the zero values — andInterpretOKis the firstiotain theInterpretResultblock (pkg/vm/vm.go:389-395), so zero isInterpretOK.The caller therefore cannot distinguish a crash from a clean run: it sees
InterpretOKand anundefinedresult.Reproduction
Any panicking native will do. Before the BigInt fix, this was a one-liner:
No error, no stack, no non-zero exit —
aftersimply never runs.Why it matters
BigInt.prototypemethod was unusable on a primitive receiver, and it presented as "the script stops early."debugVMis the wrong gate. It has to be flipped at compile time and turns on very heavy tracing, so nobody enables it just to find out why a script stopped.Possible directions
Deliberately not proposing a patch, since the right answer is a design call:
InterpretRuntimeErrorwith the panic value as the message, so it surfaces through the normal error path and sets a non-zero exit.debugVM, and returnInterpretRuntimeError.Any of the three would have made the BigInt bug obvious immediately. Option 1 seems most in keeping with the engine's existing
runtimeErrorhandling, but it is your call.Related: the BigInt bug that surfaced this.
🤖 Generated with Claude Code