From e245133f7171c0e4648c733f6f4de587bd3270d5 Mon Sep 17 00:00:00 2001 From: Alexandre MAI Date: Fri, 13 Mar 2026 08:23:29 +0100 Subject: [PATCH 1/3] core: allow async callbacks in .map() and .forEach() to suspend/resume the VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-generated code like `Promise.all(arr.map(async fn => await external()))` previously crashed the VM because Rust's for-loop couldn't be suspended mid-iteration. Users can now use async array callbacks with external function calls — each await suspends and resumes sequentially via a continuation-based execution model. --- README.md | 5 +- crates/zapcode-core/benches/execution.rs | 14 + crates/zapcode-core/src/snapshot.rs | 5 +- crates/zapcode-core/src/vm/mod.rs | 477 ++++++++++++++++++----- crates/zapcode-core/tests/async_await.rs | 214 ++++++++++ examples/python/basic/main.py | 32 ++ examples/rust/basic/basic.rs | 56 +++ examples/typescript/basic/main.ts | 31 ++ 8 files changed, 734 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index fb88300..546ff9c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ All benchmarks run the full pipeline: parse → compile → execute. No caching, | Promise.then (single) | **5.6 µs** | — | — | | Promise.then chain (×3) | **9.9 µs** | — | — | | Promise.all (3 promises) | **7.4 µs** | — | — | +| Async `.map()` (3 elements) | **11.6 µs** | — | — | | Loop (100 iterations) | **77.8 µs** | — | — | | Fibonacci (n=10, 177 calls) | **138.4 µs** | — | — | | Snapshot size (typical agent) | **< 2 KB** | N/A | N/A | @@ -519,12 +520,13 @@ For detailed logging of generated code, tool calls, and output, see the debug-tr - Call from Rust, Node.js, Python, or WebAssembly - Track and limit resources — memory, allocations, stack depth, and wall-clock time - 30+ string methods, 25+ array methods, plus Math, JSON, Object, and Promise builtins +- Async callbacks in `.map()` and `.forEach()` — each `await` suspends and resumes the VM sequentially **Cannot do:** - Run arbitrary npm packages or the full Node.js standard library - Execute regular expressions (parsing supported, execution is a no-op) -- Provide full `Promise` semantics (`.then()` chains, `Promise.race`, etc.) +- Provide full `Promise` semantics (`Promise.race`, etc.) — `.then()`, `.catch()`, `.finally()`, and `Promise.all` are supported - Run code that requires `this` in non-class contexts These are intentional constraints, not bugs. Zapcode targets one use case: **running code written by AI agents** inside a secure, embeddable sandbox. @@ -549,6 +551,7 @@ These are intentional constraints, not bugs. Zapcode targets one use case: **run | Type annotations, interfaces, type aliases | Stripped at parse time | | String methods (30+) | Supported | | Array methods (25+, including `map`, `filter`, `reduce`) | Supported | +| Async callbacks in `.map()`, `.forEach()` | Supported | | Math, JSON, Object, Promise | Supported | | `import` / `require` / `eval` | Blocked (sandbox) | | Regular expressions | Parsed, not executed | diff --git a/crates/zapcode-core/benches/execution.rs b/crates/zapcode-core/benches/execution.rs index 9de4bab..87c026c 100644 --- a/crates/zapcode-core/benches/execution.rs +++ b/crates/zapcode-core/benches/execution.rs @@ -75,3 +75,17 @@ fn promise_all_3() -> zapcode_core::Value { eval_ts("await Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)])") .unwrap() } + +#[divan::bench] +fn async_map_3() -> zapcode_core::Value { + eval_ts( + r#" + const items = [1, 2, 3]; + items.map(async (x) => { + const doubled = await Promise.resolve(x * 2); + return doubled; + }) + "#, + ) + .unwrap() +} diff --git a/crates/zapcode-core/src/snapshot.rs b/crates/zapcode-core/src/snapshot.rs index 6046da1..e72eedb 100644 --- a/crates/zapcode-core/src/snapshot.rs +++ b/crates/zapcode-core/src/snapshot.rs @@ -6,7 +6,7 @@ use crate::compiler::CompiledProgram; use crate::error::{Result, ZapcodeError}; use crate::sandbox::ResourceLimits; use crate::value::Value; -use crate::vm::{CallFrame, TryInfo, Vm, VmState}; +use crate::vm::{CallFrame, Continuation, TryInfo, Vm, VmState}; /// Internal serializable representation of VM state at a suspension point. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17,6 +17,7 @@ struct VmSnapshot { /// User-defined globals only — builtins are re-registered on resume. globals: Vec<(String, Value)>, try_stack: Vec, + continuations: Vec, stdout: String, limits: ResourceLimits, external_functions: Vec, @@ -47,6 +48,7 @@ impl ZapcodeSnapshot { frames: vm.frames.clone(), globals: user_globals, try_stack: vm.try_stack.clone(), + continuations: vm.continuations.clone(), stdout: vm.stdout.clone(), limits: vm.limits.clone(), external_functions: vm.external_functions.iter().cloned().collect(), @@ -85,6 +87,7 @@ impl ZapcodeSnapshot { vm_snap.frames, user_globals, vm_snap.try_stack, + vm_snap.continuations, vm_snap.stdout, vm_snap.limits, ext_set, diff --git a/crates/zapcode-core/src/vm/mod.rs b/crates/zapcode-core/src/vm/mod.rs index 93a516e..353f78e 100644 --- a/crates/zapcode-core/src/vm/mod.rs +++ b/crates/zapcode-core/src/vm/mod.rs @@ -48,6 +48,34 @@ pub(crate) struct CallFrame { pub(crate) receiver_source: Option, } +/// A continuation for array callback methods that may suspend (e.g., `.map()` with async callbacks). +/// Instead of running callbacks in a Rust for-loop (which can't be suspended), the continuation +/// tracks progress so the main `execute()` loop can drive iteration one callback at a time. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) enum Continuation { + /// Collecting `.map()` results element-by-element. + ArrayMap { + callback: Value, + source: Vec, + results: Vec, + next_index: usize, + /// Frame depth of the caller — the continuation fires when + /// we return to this depth AND the callback's frame has been popped. + caller_frame_depth: usize, + /// The frame index of the currently-executing callback. Only when + /// this specific frame is popped does the continuation advance. + callback_frame_index: usize, + }, + /// Collecting `.forEach()` calls element-by-element. + ArrayForEach { + callback: Value, + source: Vec, + next_index: usize, + caller_frame_depth: usize, + callback_frame_index: usize, + }, +} + /// The Zapcode VM. pub struct Vm { pub(crate) program: CompiledProgram, @@ -59,6 +87,8 @@ pub struct Vm { pub(crate) tracker: ResourceTracker, pub(crate) external_functions: HashSet, pub(crate) try_stack: Vec, + /// Active continuations for array callback methods that may suspend. + pub(crate) continuations: Vec, /// The last object a property was accessed on — used for method dispatch. last_receiver: Option, /// Where the last receiver came from — used to write back `this` mutations. @@ -99,6 +129,7 @@ impl Vm { tracker: ResourceTracker::default(), external_functions, try_stack: Vec::new(), + continuations: Vec::new(), last_receiver: None, last_receiver_source: None, last_global_name: None, @@ -121,6 +152,7 @@ impl Vm { frames: Vec, user_globals: HashMap, try_stack: Vec, + continuations: Vec, stdout: String, limits: ResourceLimits, external_functions: HashSet, @@ -143,6 +175,7 @@ impl Vm { tracker: ResourceTracker::default(), external_functions, try_stack, + continuations, last_receiver: None, last_receiver_source: None, last_global_name: None, @@ -310,6 +343,8 @@ impl Vm { } else { self.push(Value::Undefined)?; } + // Check if a continuation callback just completed + self.process_continuation()?; continue; } } @@ -319,7 +354,13 @@ impl Vm { match result { Ok(Some(state)) => return Ok(state), - Ok(None) => {} + Ok(None) => { + // After dispatch, check if a continuation callback returned + // (via Return instruction or ip overflow) + if self.process_continuation()? { + continue; + } + } Err(err) => { // Try to catch the error if let Some(try_info) = self.try_stack.pop() { @@ -344,6 +385,132 @@ impl Vm { } } + /// Process the top continuation if the current frame depth indicates a callback + /// has returned. Returns `true` if a continuation was processed (caller should + /// `continue` the execute loop). + fn process_continuation(&mut self) -> Result { + let cont = match self.continuations.last() { + Some(c) => c, + None => return Ok(false), + }; + + // Check if the callback's specific frame has been popped — only then + // has the callback returned. This avoids false triggers when inner + // helper functions return to the same depth. + let callback_frame_index = match cont { + Continuation::ArrayMap { + callback_frame_index, + .. + } => *callback_frame_index, + Continuation::ArrayForEach { + callback_frame_index, + .. + } => *callback_frame_index, + }; + + // The callback frame is still active — not done yet + if self.frames.len() > callback_frame_index { + return Ok(false); + } + + // The callback just returned — collect its result from the stack + let callback_result = self.pop().unwrap_or(Value::Undefined); + + // Unwrap promise values: async callbacks return {status: "resolved", value: X} + // or {status: "rejected", reason: X} + let callback_result = if let Value::Object(ref map) = callback_result { + match map.get("status") { + Some(Value::String(s)) if s.as_ref() == "resolved" => { + map.get("value").cloned().unwrap_or(Value::Undefined) + } + Some(Value::String(s)) if s.as_ref() == "rejected" => { + let reason = map.get("reason").cloned().unwrap_or(Value::Undefined); + // Clean up the continuation before returning error + self.continuations.pop(); + return Err(ZapcodeError::RuntimeError(format!( + "Unhandled promise rejection: {}", + reason.to_js_string() + ))); + } + _ => callback_result, + } + } else { + callback_result + }; + + // Pop the continuation, take ownership to avoid cloning results + let cont = self.continuations.pop().unwrap(); + + match cont { + Continuation::ArrayMap { + callback, + source, + mut results, + next_index, + caller_frame_depth, + .. + } => { + results.push(callback_result); + let next = next_index + 1; + + if next < source.len() { + // Set up next callback call + let item = source[next].clone(); + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => unreachable!("callback validated at start"), + }; + self.push_call_frame(&closure, &[item, Value::Int(next as i64)], None)?; + let new_frame_index = self.frames.len() - 1; + // Push updated continuation back + self.continuations.push(Continuation::ArrayMap { + callback, + source, + results, + next_index: next, + caller_frame_depth, + callback_frame_index: new_frame_index, + }); + Ok(true) + } else { + // All done — push final array, no clone needed + self.push(Value::Array(results))?; + Ok(true) + } + } + Continuation::ArrayForEach { + callback, + source, + next_index, + caller_frame_depth, + .. + } => { + let next = next_index + 1; + + if next < source.len() { + let item = source[next].clone(); + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => unreachable!("callback validated at start"), + }; + self.push_call_frame(&closure, &[item, Value::Int(next as i64)], None)?; + let new_frame_index = self.frames.len() - 1; + self.continuations.push(Continuation::ArrayForEach { + callback, + source, + next_index: next, + caller_frame_depth, + callback_frame_index: new_frame_index, + }); + Ok(true) + } else { + self.push(Value::Undefined)?; + Ok(true) + } + } + } + } + /// Call a function value with the given arguments and run it to completion. /// Returns the function's return value. fn call_function_internal(&mut self, callee: &Value, args: Vec) -> Result { @@ -438,126 +605,233 @@ impl Vm { self.call_function_internal(callback, vec![item.clone(), Value::Int(index as i64)]) } + /// Check if a callback value is an async function that might suspend. + fn is_async_callback(&self, callback: &Value) -> bool { + if let Value::Function(closure) = callback { + if let Some(func) = self.program.functions.get(closure.func_id.0) { + return func.is_async; + } + } + false + } + + /// Start a continuation-based `.map()` call: push the continuation and set up + /// the first callback invocation. Returns `None` to signal that the main + /// `execute()` loop should drive the iteration. + fn start_continuation_map( + &mut self, + callback: Value, + arr: Vec, + ) -> Result> { + if arr.is_empty() { + return Ok(Some(Value::Array(Vec::new()))); + } + + // Validate callback type BEFORE pushing continuation + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => { + return Err(ZapcodeError::TypeError( + "map callback is not a function".to_string(), + )) + } + }; + + let caller_frame_depth = self.frames.len(); + let first_item = arr[0].clone(); + + self.push_call_frame(&closure, &[first_item, Value::Int(0)], None)?; + let callback_frame_index = self.frames.len() - 1; + + self.continuations.push(Continuation::ArrayMap { + callback, + source: arr, + results: Vec::new(), + next_index: 0, + caller_frame_depth, + callback_frame_index, + }); + + Ok(None) // Signal: continuation in progress + } + + /// Start a continuation-based `.forEach()` call. + fn start_continuation_foreach( + &mut self, + callback: Value, + arr: Vec, + ) -> Result> { + if arr.is_empty() { + return Ok(Some(Value::Undefined)); + } + + // Validate callback type BEFORE pushing continuation + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => { + return Err(ZapcodeError::TypeError( + "forEach callback is not a function".to_string(), + )) + } + }; + + let caller_frame_depth = self.frames.len(); + let first_item = arr[0].clone(); + + self.push_call_frame(&closure, &[first_item, Value::Int(0)], None)?; + let callback_frame_index = self.frames.len() - 1; + + self.continuations.push(Continuation::ArrayForEach { + callback, + source: arr, + next_index: 0, + caller_frame_depth, + callback_frame_index, + }); + + Ok(None) + } + /// Execute an array callback method (map, filter, reduce, forEach, etc.) + /// Returns `Ok(Some(value))` if the method completed synchronously, or + /// `Ok(None)` if a continuation was started (async callback). fn execute_array_callback_method( &mut self, arr: Vec, method: &str, all_args: Vec, - ) -> Result { + ) -> Result> { let callback = all_args.first().cloned().unwrap_or(Value::Undefined); match method { "map" => { + // Use continuation-based execution for async callbacks + if self.is_async_callback(&callback) { + return self.start_continuation_map(callback, arr); + } let mut result = Vec::with_capacity(arr.len()); for (i, item) in arr.iter().enumerate() { result.push(self.call_element_callback(&callback, item, i)?); } - Ok(Value::Array(result)) - } - "filter" => { - let mut result = Vec::new(); - for (i, item) in arr.iter().enumerate() { - if self.call_element_callback(&callback, item, i)?.is_truthy() { - result.push(item.clone()); - } - } - Ok(Value::Array(result)) - } - "forEach" => { - for (i, item) in arr.iter().enumerate() { - self.call_element_callback(&callback, item, i)?; - } - Ok(Value::Undefined) - } - "find" => { - for (i, item) in arr.iter().enumerate() { - if self.call_element_callback(&callback, item, i)?.is_truthy() { - return Ok(item.clone()); + Ok(Some(Value::Array(result))) + } + "filter" | "find" | "findIndex" | "every" | "some" | "reduce" | "sort" | "flatMap" => { + // Async callbacks are not supported for these methods + if self.is_async_callback(&callback) { + return Err(ZapcodeError::RuntimeError(format!( + ".{}() does not support async callbacks — use .map() or a for-of loop instead", + method + ))); + } + match method { + "filter" => { + let mut result = Vec::new(); + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + result.push(item.clone()); + } + } + Ok(Some(Value::Array(result))) } - } - Ok(Value::Undefined) - } - "findIndex" => { - for (i, item) in arr.iter().enumerate() { - if self.call_element_callback(&callback, item, i)?.is_truthy() { - return Ok(Value::Int(i as i64)); + "find" => { + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(item.clone())); + } + } + Ok(Some(Value::Undefined)) } - } - Ok(Value::Int(-1)) - } - "every" => { - for (i, item) in arr.iter().enumerate() { - if !self.call_element_callback(&callback, item, i)?.is_truthy() { - return Ok(Value::Bool(false)); + "findIndex" => { + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(Value::Int(i as i64))); + } + } + Ok(Some(Value::Int(-1))) } - } - Ok(Value::Bool(true)) - } - "some" => { - for (i, item) in arr.iter().enumerate() { - if self.call_element_callback(&callback, item, i)?.is_truthy() { - return Ok(Value::Bool(true)); + "every" => { + for (i, item) in arr.iter().enumerate() { + if !self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(Value::Bool(false))); + } + } + Ok(Some(Value::Bool(true))) } - } - Ok(Value::Bool(false)) - } - "reduce" => { - let mut acc = match all_args.get(1).cloned() { - Some(init) => Some(init), - None if !arr.is_empty() => Some(arr[0].clone()), - None => { - return Err(ZapcodeError::TypeError( - "Reduce of empty array with no initial value".to_string(), - )); + "some" => { + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(Value::Bool(true))); + } + } + Ok(Some(Value::Bool(false))) } - }; - let start = if all_args.get(1).is_some() { 0 } else { 1 }; - for (i, item) in arr.iter().enumerate().skip(start) { - acc = Some(self.call_function_internal( - &callback, - vec![acc.unwrap(), item.clone(), Value::Int(i as i64)], - )?); - } - Ok(acc.unwrap_or(Value::Undefined)) - } - "sort" => { - let mut result = arr; - if matches!(callback, Value::Function(_)) { - // Insertion sort — O(n²) worst case but stable, and sort - // with a VM callback can't use Rust's built-in sort - let len = result.len(); - for i in 1..len { - let mut j = i; - while j > 0 { - let cmp = self - .call_function_internal( - &callback, - vec![result[j - 1].clone(), result[j].clone()], - )? - .to_number(); - if cmp > 0.0 { - result.swap(j - 1, j); - j -= 1; - } else { - break; + "reduce" => { + let mut acc = match all_args.get(1).cloned() { + Some(init) => Some(init), + None if !arr.is_empty() => Some(arr[0].clone()), + None => { + return Err(ZapcodeError::TypeError( + "Reduce of empty array with no initial value".to_string(), + )); + } + }; + let start = if all_args.get(1).is_some() { 0 } else { 1 }; + for (i, item) in arr.iter().enumerate().skip(start) { + acc = Some(self.call_function_internal( + &callback, + vec![acc.unwrap(), item.clone(), Value::Int(i as i64)], + )?); + } + Ok(Some(acc.unwrap_or(Value::Undefined))) + } + "sort" => { + let mut result = arr; + if matches!(callback, Value::Function(_)) { + let len = result.len(); + for i in 1..len { + let mut j = i; + while j > 0 { + let cmp = self + .call_function_internal( + &callback, + vec![result[j - 1].clone(), result[j].clone()], + )? + .to_number(); + if cmp > 0.0 { + result.swap(j - 1, j); + j -= 1; + } else { + break; + } + } + } + } else { + result.sort_by_key(|a| a.to_js_string()); + } + Ok(Some(Value::Array(result))) + } + "flatMap" => { + let mut result = Vec::new(); + for (i, item) in arr.iter().enumerate() { + match self.call_element_callback(&callback, item, i)? { + Value::Array(inner) => result.extend(inner), + other => result.push(other), } } + Ok(Some(Value::Array(result))) } - } else { - result.sort_by_key(|a| a.to_js_string()); + _ => unreachable!(), } - Ok(Value::Array(result)) } - "flatMap" => { - let mut result = Vec::new(); + "forEach" => { + // Use continuation-based execution for async callbacks + if self.is_async_callback(&callback) { + return self.start_continuation_foreach(callback, arr); + } for (i, item) in arr.iter().enumerate() { - match self.call_element_callback(&callback, item, i)? { - Value::Array(inner) => result.extend(inner), - other => result.push(other), - } + self.call_element_callback(&callback, item, i)?; } - Ok(Value::Array(result)) + Ok(Some(Value::Undefined)) } _ => Err(ZapcodeError::TypeError(format!( "Unknown array callback method: {}", @@ -1338,12 +1612,19 @@ impl Vm { match method_name.as_ref() { "map" | "filter" | "forEach" | "find" | "findIndex" | "every" | "some" | "reduce" | "sort" | "flatMap" => { - let result = self.execute_array_callback_method( + match self.execute_array_callback_method( arr.clone(), &method_name, args, - )?; - Some(result) + )? { + Some(val) => Some(val), + None => { + // Continuation started — the main execute() + // loop will drive the callbacks. Don't push + // a result; just return Ok(None). + return Ok(None); + } + } } _ => builtins::call_builtin( &Value::Array(arr.clone()), diff --git a/crates/zapcode-core/tests/async_await.rs b/crates/zapcode-core/tests/async_await.rs index dc393a5..92d8812 100644 --- a/crates/zapcode-core/tests/async_await.rs +++ b/crates/zapcode-core/tests/async_await.rs @@ -649,3 +649,217 @@ fn test_promise_then_catch_chain() { .unwrap(); assert_eq!(result, Value::Int(25)); } + +// ── Promise.all with async map (external calls) ──────────────────── + +#[test] +fn test_sequential_external_calls_in_loop() { + // Sequential external calls using a regular loop (not .map) + // This pattern already worked before continuations. + let code = r#" + const a = await getWeather("London"); + const b = await getWeather("Tokyo"); + const c = await getWeather("Paris"); + [a, b, c] + "#; + + let state = start_with_externals(code, vec!["getWeather"], Vec::new()); + + let snap = match state { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "getWeather"); + assert_eq!(args[0], Value::String("London".into())); + snapshot + } + VmState::Complete(_) => panic!("expected suspension"), + }; + + let state2 = snap.resume(Value::String("rainy".into())).unwrap(); + let snap2 = match state2 { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "getWeather"); + assert_eq!(args[0], Value::String("Tokyo".into())); + snapshot + } + VmState::Complete(_) => panic!("expected second suspension"), + }; + + let state3 = snap2.resume(Value::String("sunny".into())).unwrap(); + let snap3 = match state3 { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "getWeather"); + assert_eq!(args[0], Value::String("Paris".into())); + snapshot + } + VmState::Complete(_) => panic!("expected third suspension"), + }; + + let final_state = snap3.resume(Value::String("cloudy".into())).unwrap(); + match final_state { + VmState::Complete(Value::Array(arr)) => { + assert_eq!(arr.len(), 3); + assert_eq!(arr[0], Value::String("rainy".into())); + assert_eq!(arr[1], Value::String("sunny".into())); + assert_eq!(arr[2], Value::String("cloudy".into())); + } + other => panic!("expected array, got {:?}", other), + } +} + +#[test] +fn test_array_map_async_callback_with_external() { + // The core use case: arr.map(async fn => await external()) + let code = r#" + const items = ["a", "b", "c"]; + const results = items.map(async (item) => { + const data = await fetchData(item); + return data; + }); + results + "#; + + let state = start_with_externals(code, vec!["fetchData"], Vec::new()); + + // First suspension: fetchData("a") + let snap = match state { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "fetchData"); + assert_eq!(args[0], Value::String("a".into())); + snapshot + } + VmState::Complete(_) => panic!("expected suspension for 'a'"), + }; + + // Resume with result for "a" + let state2 = snap.resume(Value::String("data_a".into())).unwrap(); + let snap2 = match state2 { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "fetchData"); + assert_eq!(args[0], Value::String("b".into())); + snapshot + } + VmState::Complete(_) => panic!("expected suspension for 'b'"), + }; + + // Resume with result for "b" + let state3 = snap2.resume(Value::String("data_b".into())).unwrap(); + let snap3 = match state3 { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "fetchData"); + assert_eq!(args[0], Value::String("c".into())); + snapshot + } + VmState::Complete(_) => panic!("expected suspension for 'c'"), + }; + + // Resume with result for "c" + let final_state = snap3.resume(Value::String("data_c".into())).unwrap(); + match final_state { + VmState::Complete(Value::Array(arr)) => { + assert_eq!(arr.len(), 3); + assert_eq!(arr[0], Value::String("data_a".into())); + assert_eq!(arr[1], Value::String("data_b".into())); + assert_eq!(arr[2], Value::String("data_c".into())); + } + other => panic!("expected array, got {:?}", other), + } +} + +#[test] +fn test_array_map_async_empty() { + // Edge case: empty array with async map should return empty array immediately + let code = r#" + const items: string[] = []; + const results = items.map(async (item) => { + const data = await fetchData(item); + return data; + }); + results + "#; + + let state = start_with_externals(code, vec!["fetchData"], Vec::new()); + match state { + VmState::Complete(Value::Array(arr)) => { + assert_eq!(arr.len(), 0); + } + other => panic!("expected empty array, got {:?}", other), + } +} + +#[test] +fn test_array_map_sync_still_works() { + // Regression test: sync .map() must still work as before + let result = eval_ts( + r#" + const nums = [1, 2, 3]; + const doubled = nums.map(x => x * 2); + doubled + "#, + ) + .unwrap(); + match result { + Value::Array(arr) => { + assert_eq!(arr, vec![Value::Int(2), Value::Int(4), Value::Int(6)]); + } + other => panic!("expected array, got {:?}", other), + } +} + +#[test] +fn test_array_map_async_single_element() { + // Edge case: single element + let code = r#" + const items = ["only"]; + const results = items.map(async (item) => { + const data = await fetchData(item); + return data; + }); + results + "#; + + let state = start_with_externals(code, vec!["fetchData"], Vec::new()); + let snap = match state { + VmState::Suspended { + function_name, + snapshot, + .. + } => { + assert_eq!(function_name, "fetchData"); + snapshot + } + VmState::Complete(_) => panic!("expected suspension"), + }; + + let final_state = snap.resume(Value::String("result".into())).unwrap(); + match final_state { + VmState::Complete(Value::Array(arr)) => { + assert_eq!(arr.len(), 1); + assert_eq!(arr[0], Value::String("result".into())); + } + other => panic!("expected array with one element, got {:?}", other), + } +} diff --git a/examples/python/basic/main.py b/examples/python/basic/main.py index d1a2f44..ad4798e 100644 --- a/examples/python/basic/main.py +++ b/examples/python/basic/main.py @@ -85,3 +85,35 @@ restored = ZapcodeSnapshot.load(snapshot_bytes) final = restored.resume("hello world") print(f"Restored result: {final['output']}") # 11 + +# --- 7. Async map with multiple external calls --- +# arr.map(async fn => await external()) now works — +# each external call suspends/resumes sequentially. +b = Zapcode( + """ + const cities = ["London", "Tokyo", "Paris"]; + const results = cities.map(async (city) => { + const weather = await getWeather(city); + return weather; + }); + results + """, + external_functions=["getWeather"], +) + +mock_weather = { + "London": {"condition": "Rainy", "temp": 12}, + "Tokyo": {"condition": "Clear", "temp": 26}, + "Paris": {"condition": "Sunny", "temp": 22}, +} + +state = b.start() +for expected_city in ["London", "Tokyo", "Paris"]: + assert state.get("suspended"), f"expected suspension for {expected_city}" + city_arg = state["args"][0] + print(f" -> getWeather({city_arg})") + snapshot = state["snapshot"] + state = snapshot.resume(mock_weather[city_arg]) + +print("Async map result:", state["output"]) +# [{'condition': 'Rainy', 'temp': 12}, {'condition': 'Clear', ...}, ...] diff --git a/examples/rust/basic/basic.rs b/examples/rust/basic/basic.rs index e942dde..a587d4f 100644 --- a/examples/rust/basic/basic.rs +++ b/examples/rust/basic/basic.rs @@ -109,5 +109,61 @@ fn main() -> Result<(), Box> { } } + // --- 5. Async map with multiple external calls --- + // arr.map(async fn => await external()) now works — + // each external call suspends/resumes sequentially. + let runner = ZapcodeRun::new( + r#" + const cities = ["London", "Tokyo", "Paris"]; + const results = cities.map(async (city) => { + const weather = await getWeather(city); + return weather; + }); + results + "# + .to_string(), + vec![], + vec!["getWeather".to_string()], + ResourceLimits::default(), + )?; + + let mut state = runner.start(vec![])?; + + // The VM suspends once per city — resolve each one + let mock_data = vec![ + ("London", "Rainy, 12°C"), + ("Tokyo", "Clear, 26°C"), + ("Paris", "Sunny, 22°C"), + ]; + + for (expected_city, weather) in &mock_data { + match state { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + println!( + " -> {}({}) = {}", + function_name, + args[0].to_js_string(), + weather + ); + assert_eq!(function_name, "getWeather"); + assert_eq!(args[0].to_js_string(), *expected_city); + state = snapshot.resume(Value::String((*weather).into()))?; + } + VmState::Complete(_) => panic!("expected suspension for {}", expected_city), + } + } + + match state { + VmState::Complete(value) => { + println!("Async map result: {:?}", value); + // Array(["Rainy, 12°C", "Clear, 26°C", "Sunny, 22°C"]) + } + _ => println!("Unexpected suspension after all cities resolved"), + } + Ok(()) } diff --git a/examples/typescript/basic/main.ts b/examples/typescript/basic/main.ts index 6637977..ba46aa3 100644 --- a/examples/typescript/basic/main.ts +++ b/examples/typescript/basic/main.ts @@ -95,3 +95,34 @@ const classExample = new Zapcode(` [c.increment(), c.increment(), c.increment()] `); console.log(classExample.run().output); // [11, 12, 13] + +// --- 7. Async map with multiple external calls --- +// arr.map(async fn => await external()) now works — +// each external call suspends/resumes sequentially. +const asyncMapExample = new Zapcode( + ` + const cities = ["London", "Tokyo", "Paris"]; + const results = cities.map(async (city) => { + const weather = await getWeather(city); + return weather; + }); + results + `, + { externalFunctions: ["getWeather"] } +); + +const mockWeatherData: Record = { + London: { condition: "Rainy", temp: 12 }, + Tokyo: { condition: "Clear", temp: 26 }, + Paris: { condition: "Sunny", temp: 22 }, +}; + +let mapState = asyncMapExample.start(); +while (!mapState.completed) { + const city = mapState.args![0] as string; + console.log(` -> getWeather(${city})`); + const snap = ZapcodeSnapshotHandle.load(mapState.snapshot!); + mapState = snap.resume(mockWeatherData[city]); +} +console.log("Async map result:", mapState.output); +// [{condition: "Rainy", temp: 12}, {condition: "Clear", temp: 26}, ...] From 443e1634c674e260da90c182d676a5dcb8f9ca58 Mon Sep 17 00:00:00 2001 From: Alexandre MAI Date: Fri, 13 Mar 2026 08:43:22 +0100 Subject: [PATCH 2/3] core: fix continuation safety and add missing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User objects like {status: "resolved", value: 42} were incorrectly unwrapped as internal promises — now only objects with the __promise__ marker are treated as promises, preventing silent data corruption in async map callbacks. Also guard against stale continuations firing on stack unwinds by checking caller_frame_depth alongside callback_frame_index. Tests added for forEach async callbacks, async guard errors on filter/reduce, and the user-object unwrap fix. --- crates/zapcode-core/src/vm/mod.rs | 26 +++- crates/zapcode-core/tests/async_await.rs | 157 +++++++++++++++++++++++ 2 files changed, 176 insertions(+), 7 deletions(-) diff --git a/crates/zapcode-core/src/vm/mod.rs b/crates/zapcode-core/src/vm/mod.rs index 353f78e..bdffcd1 100644 --- a/crates/zapcode-core/src/vm/mod.rs +++ b/crates/zapcode-core/src/vm/mod.rs @@ -397,15 +397,17 @@ impl Vm { // Check if the callback's specific frame has been popped — only then // has the callback returned. This avoids false triggers when inner // helper functions return to the same depth. - let callback_frame_index = match cont { + let (callback_frame_index, caller_frame_depth) = match cont { Continuation::ArrayMap { callback_frame_index, + caller_frame_depth, .. - } => *callback_frame_index, + } => (*callback_frame_index, *caller_frame_depth), Continuation::ArrayForEach { callback_frame_index, + caller_frame_depth, .. - } => *callback_frame_index, + } => (*callback_frame_index, *caller_frame_depth), }; // The callback frame is still active — not done yet @@ -413,13 +415,23 @@ impl Vm { return Ok(false); } + // Guard against stale continuations on stack unwinds — we must be + // back at the original caller's frame depth. + if self.frames.len() != caller_frame_depth { + return Ok(false); + } + // The callback just returned — collect its result from the stack let callback_result = self.pop().unwrap_or(Value::Undefined); - // Unwrap promise values: async callbacks return {status: "resolved", value: X} - // or {status: "rejected", reason: X} + // Unwrap internal promise values: async callbacks return + // {__promise__: true, status: "resolved", value: X} or {status: "rejected", ...}. + // Only unwrap objects with the __promise__ marker to avoid mangling user objects. let callback_result = if let Value::Object(ref map) = callback_result { - match map.get("status") { + if !matches!(map.get("__promise__"), Some(Value::Bool(true))) { + // Not an internal promise — leave untouched + callback_result + } else { match map.get("status") { Some(Value::String(s)) if s.as_ref() == "resolved" => { map.get("value").cloned().unwrap_or(Value::Undefined) } @@ -433,7 +445,7 @@ impl Vm { ))); } _ => callback_result, - } + }} } else { callback_result }; diff --git a/crates/zapcode-core/tests/async_await.rs b/crates/zapcode-core/tests/async_await.rs index 92d8812..1598c86 100644 --- a/crates/zapcode-core/tests/async_await.rs +++ b/crates/zapcode-core/tests/async_await.rs @@ -863,3 +863,160 @@ fn test_array_map_async_single_element() { other => panic!("expected array with one element, got {:?}", other), } } + +#[test] +fn test_array_for_each_async_callback_with_external() { + // forEach with async callback should suspend for each external call + // and complete with undefined (forEach's return value) + let code = r#" + const items = ["a", "b", "c"]; + items.forEach(async (item) => { + await processItem(item); + }); + "done" + "#; + + let mut state = start_with_externals(code, vec!["processItem"], Vec::new()); + + for expected in &["a", "b", "c"] { + match state { + VmState::Suspended { + function_name, + args, + snapshot, + } => { + assert_eq!(function_name, "processItem"); + assert_eq!(args[0].to_js_string(), *expected); + state = snapshot + .resume(Value::String(format!("processed_{}", expected).into())) + .unwrap(); + } + VmState::Complete(_) => panic!("expected suspension for {}", expected), + } + } + + match state { + VmState::Complete(val) => { + assert_eq!(val, Value::String("done".into())); + } + other => panic!("expected completion with 'done', got {:?}", other), + } +} + +#[test] +fn test_array_for_each_async_empty() { + // Empty array forEach should complete immediately + let code = r#" + const items: string[] = []; + items.forEach(async (item) => { + await processItem(item); + }); + "done" + "#; + + let state = start_with_externals(code, vec!["processItem"], Vec::new()); + match state { + VmState::Complete(val) => { + assert_eq!(val, Value::String("done".into())); + } + VmState::Suspended { .. } => panic!("expected immediate completion for empty array"), + } +} + +#[test] +fn test_array_async_unsupported_methods_filter() { + // .filter() with async callback should return a clear error + let code = r#" + const items = [1, 2, 3]; + items.filter(async (item) => { + const result = await check(item); + return result; + }) + "#; + + let runner = ZapcodeRun::new( + code.to_string(), + Vec::new(), + vec!["check".to_string()], + ResourceLimits::default(), + ) + .unwrap(); + let result = runner.start(Vec::new()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("does not support async callbacks"), + "expected async guard error, got: {}", + err_msg + ); +} + +#[test] +fn test_array_async_unsupported_methods_reduce() { + // .reduce() with async callback should return a clear error + let code = r#" + const items = [1, 2, 3]; + items.reduce(async (acc, item) => { + const result = await transform(item); + return acc + result; + }, 0) + "#; + + let runner = ZapcodeRun::new( + code.to_string(), + Vec::new(), + vec!["transform".to_string()], + ResourceLimits::default(), + ) + .unwrap(); + let result = runner.start(Vec::new()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("does not support async callbacks"), + "expected async guard error, got: {}", + err_msg + ); +} + +#[test] +fn test_callback_result_user_object_not_unwrapped() { + // A user object with {status: "resolved", value: ...} should NOT be + // unwrapped as if it were an internal promise. + let code = r#" + const items = ["x"]; + items.map(async (item) => { + const data = await fetchData(item); + return data; + }) + "#; + + let state = start_with_externals(code, vec!["fetchData"], Vec::new()); + let snap = match state { + VmState::Suspended { snapshot, .. } => snapshot, + VmState::Complete(_) => panic!("expected suspension"), + }; + + // Return a user object that looks like a promise but lacks __promise__ + let user_obj = Value::Object(indexmap::indexmap! { + "status".into() => Value::String("resolved".into()), + "value".into() => Value::Int(42), + }); + let final_state = snap.resume(user_obj).unwrap(); + match final_state { + VmState::Complete(Value::Array(arr)) => { + assert_eq!(arr.len(), 1); + // The user object should be preserved as-is, not unwrapped to 42 + match &arr[0] { + Value::Object(map) => { + assert_eq!(map.get("status"), Some(&Value::String("resolved".into()))); + assert_eq!(map.get("value"), Some(&Value::Int(42))); + // Must NOT have been unwrapped — it's still an object, not Int(42) + assert!(map.get("__promise__").is_none(), "user object should not have __promise__"); + } + other => panic!("expected object, got {:?}", other), + } + } + other => panic!("expected array with user object, got {:?}", other), + } +} From 4794aef3d58c582fa4c2bbab826886a5d315ad0a Mon Sep 17 00:00:00 2001 From: Alexandre MAI Date: Fri, 13 Mar 2026 08:48:33 +0100 Subject: [PATCH 3/3] style: fix cargo fmt formatting in continuation code --- crates/zapcode-core/src/vm/mod.rs | 30 +++++++++++++----------- crates/zapcode-core/tests/async_await.rs | 5 +++- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/zapcode-core/src/vm/mod.rs b/crates/zapcode-core/src/vm/mod.rs index bdffcd1..a204b64 100644 --- a/crates/zapcode-core/src/vm/mod.rs +++ b/crates/zapcode-core/src/vm/mod.rs @@ -431,21 +431,23 @@ impl Vm { if !matches!(map.get("__promise__"), Some(Value::Bool(true))) { // Not an internal promise — leave untouched callback_result - } else { match map.get("status") { - Some(Value::String(s)) if s.as_ref() == "resolved" => { - map.get("value").cloned().unwrap_or(Value::Undefined) - } - Some(Value::String(s)) if s.as_ref() == "rejected" => { - let reason = map.get("reason").cloned().unwrap_or(Value::Undefined); - // Clean up the continuation before returning error - self.continuations.pop(); - return Err(ZapcodeError::RuntimeError(format!( - "Unhandled promise rejection: {}", - reason.to_js_string() - ))); + } else { + match map.get("status") { + Some(Value::String(s)) if s.as_ref() == "resolved" => { + map.get("value").cloned().unwrap_or(Value::Undefined) + } + Some(Value::String(s)) if s.as_ref() == "rejected" => { + let reason = map.get("reason").cloned().unwrap_or(Value::Undefined); + // Clean up the continuation before returning error + self.continuations.pop(); + return Err(ZapcodeError::RuntimeError(format!( + "Unhandled promise rejection: {}", + reason.to_js_string() + ))); + } + _ => callback_result, } - _ => callback_result, - }} + } } else { callback_result }; diff --git a/crates/zapcode-core/tests/async_await.rs b/crates/zapcode-core/tests/async_await.rs index 1598c86..0cb6df2 100644 --- a/crates/zapcode-core/tests/async_await.rs +++ b/crates/zapcode-core/tests/async_await.rs @@ -1012,7 +1012,10 @@ fn test_callback_result_user_object_not_unwrapped() { assert_eq!(map.get("status"), Some(&Value::String("resolved".into()))); assert_eq!(map.get("value"), Some(&Value::Int(42))); // Must NOT have been unwrapped — it's still an object, not Int(42) - assert!(map.get("__promise__").is_none(), "user object should not have __promise__"); + assert!( + map.get("__promise__").is_none(), + "user object should not have __promise__" + ); } other => panic!("expected object, got {:?}", other), }