From 28edb17d6fd1ebe1fe7194fa96d359b0d586f4c0 Mon Sep 17 00:00:00 2001 From: James Tippett Date: Thu, 9 Jul 2026 15:46:34 +0700 Subject: [PATCH] fix: return an error instead of panicking when the frame stack empties execute() called frames.last().unwrap(), so guest input that emptied the frame stack aborted the whole host process. It now surfaces a RuntimeError to the caller. Running the TC39 Test262 suite (~48k inputs) went from aborting on the first such input to zero panics. --- crates/zapcode-core/src/vm/mod.rs | 15 ++++++++-- crates/zapcode-core/tests/error_handling.rs | 32 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/zapcode-core/src/vm/mod.rs b/crates/zapcode-core/src/vm/mod.rs index f0f39d9..d0d972d 100644 --- a/crates/zapcode-core/src/vm/mod.rs +++ b/crates/zapcode-core/src/vm/mod.rs @@ -316,7 +316,10 @@ impl Vm { // Resource checks self.tracker.check_time(&self.limits)?; - let frame = self.frames.last().unwrap(); + let frame = self + .frames + .last() + .ok_or_else(|| ZapcodeError::RuntimeError("no active call frame".to_string()))?; let instructions = match frame.func_index { Some(idx) => &self.program.functions[idx].instructions, None => &self.program.instructions, @@ -547,7 +550,10 @@ impl Vm { loop { self.tracker.check_time(&self.limits)?; - let frame = self.frames.last().unwrap(); + let frame = self + .frames + .last() + .ok_or_else(|| ZapcodeError::RuntimeError("no active call frame".to_string()))?; let instructions = match frame.func_index { Some(idx) => &self.program.functions[idx].instructions, None => &self.program.instructions, @@ -1031,7 +1037,10 @@ impl Vm { let target_frame_depth = self.frames.len() - 1; loop { self.tracker.check_time(&self.limits)?; - let frame = self.frames.last().unwrap(); + let frame = self + .frames + .last() + .ok_or_else(|| ZapcodeError::RuntimeError("no active call frame".to_string()))?; let instructions = match frame.func_index { Some(idx) => &self.program.functions[idx].instructions, None => &self.program.instructions, diff --git a/crates/zapcode-core/tests/error_handling.rs b/crates/zapcode-core/tests/error_handling.rs index b722a72..7001619 100644 --- a/crates/zapcode-core/tests/error_handling.rs +++ b/crates/zapcode-core/tests/error_handling.rs @@ -54,3 +54,35 @@ fn test_try_no_error() { .unwrap(); assert_eq!(result, Value::Int(42)); } + +// Regression: a throw escaping a nested array callback (or a callback inside a +// class method) emptied the VM frame stack; execute() then hit +// frames.last().unwrap() and aborted the host process. These must surface an +// error to the caller, never panic. (The guest-level catch not observing the +// throw is a separate, pre-existing unwinding issue.) +#[test] +fn test_throw_from_nested_callback_does_not_panic() { + let result = eval_ts( + r#" + let out = 0; + try { + [1].map(a => [2].map(b => { throw "n"; })); + } catch (e) { out = 3; } + out + "#, + ); + assert!(result.is_err()); +} + +#[test] +fn test_throw_from_class_method_callback_does_not_panic() { + let result = eval_ts( + r#" + class A { run() { return [1].map(x => { throw "m"; }); } } + let out = 0; + try { new A().run(); } catch (e) { out = 6; } + out + "#, + ); + assert!(result.is_err()); +}