Skip to main content

harn_vm/vm/
execution.rs

1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use crate::chunk::{Chunk, ChunkRef, Op};
5use crate::value::{ModuleFunctionRegistry, VmError, VmValue};
6
7use super::state::{ExecutionDeadlineState, ScopeSpan};
8use super::{CallFrame, LocalSlot, Vm};
9
10const CANCEL_GRACE_ASYNC_OP: Duration = Duration::from_millis(250);
11
12pub(super) fn new_execution_deadline_state(
13    deadline: Option<Instant>,
14) -> Arc<ExecutionDeadlineState> {
15    ExecutionDeadlineState::new(Instant::now(), deadline)
16}
17
18#[cfg(test)]
19thread_local! {
20    static SCOPE_INTERRUPT_ASYNC_DISPATCHES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
21}
22
23#[cfg(test)]
24pub(super) fn reset_scope_interrupt_async_dispatches() {
25    SCOPE_INTERRUPT_ASYNC_DISPATCHES.set(0);
26}
27
28#[cfg(test)]
29pub(super) fn scope_interrupt_async_dispatches() -> u64 {
30    SCOPE_INTERRUPT_ASYNC_DISPATCHES.get()
31}
32
33#[derive(Clone, Copy)]
34enum DeadlineKind {
35    Execution,
36    Scope,
37    InterruptHandler,
38}
39
40impl Vm {
41    /// Returns true when no scope-level async machinery is armed. The hot
42    /// interpreter loop uses this to skip both the `pending_scope_interrupt`
43    /// future and the `execute_op_with_scope_interrupts` `tokio::select!`
44    /// wrapper on every dispatch — both are necessary for cancellable /
45    /// deadlined VMs but pure overhead in the common case (benchmarks,
46    /// background script execution, etc.).
47    #[inline]
48    pub(crate) fn scope_interrupts_clean(&self) -> bool {
49        self.requested_process_exit().is_none()
50            && self.cancel_token.is_none()
51            && self.interrupt_signal_token.is_none()
52            && self.pending_interrupt_signal.is_none()
53            && self.interrupt_handler_deadline.is_none()
54            && !self.execution_deadline.is_active()
55            && self.deadlines.is_empty()
56    }
57
58    /// Execute a compiled chunk.
59    ///
60    /// Convenience entry point for callers that hold a borrowed [`Chunk`] and
61    /// run it once (tests, one-shot CLI invocations). It clones the chunk once
62    /// to obtain the owned [`ChunkRef`] the call frame requires. Callers that
63    /// re-run the same compiled chunk (servers, record filters, triggers)
64    /// should hold a [`ChunkRef`] and call [`Vm::execute_arc`] to skip the
65    /// per-execution deep copy of the bytecode + constant pool.
66    pub async fn execute(&mut self, chunk: &Chunk) -> Result<VmValue, VmError> {
67        self.execute_arc(Arc::new(chunk.clone())).await
68    }
69
70    /// Execute a compiled chunk under an uncatchable host wall-clock limit.
71    ///
72    /// This is distinct from Harn's catchable `deadline` expression: test
73    /// runners and embedding hosts must be able to stop CPU-bound user code
74    /// even when it never yields to the async runtime.
75    ///
76    /// The returned future must be awaited to completion. Dropping it after it
77    /// has been polled is unsupported: Rust futures cannot synchronously unwind
78    /// interpreter frames or every thread-local program scope. The VM is then
79    /// poisoned, so every later execution returns
80    /// [`VmError::AbandonedExecution`], and dropping it aborts its spawned child
81    /// tasks. An embedding host must also exclusively own the polling execution
82    /// context and reset that context before running another VM on it; it must
83    /// not globally reset a context shared with unrelated executions.
84    pub async fn execute_with_timeout(
85        &mut self,
86        chunk: &Chunk,
87        timeout: Duration,
88    ) -> Result<VmValue, VmError> {
89        let deadline = Instant::now().checked_add(timeout).ok_or_else(|| {
90            VmError::Runtime("execution timeout exceeds the platform clock range".to_string())
91        })?;
92        let deadline_guard = self.execution_deadline.install(deadline);
93        let result = self.execute(chunk).await;
94        deadline_guard.complete();
95        result
96    }
97
98    /// Execute a shared compiled chunk without cloning its bytecode.
99    ///
100    /// Threads the existing [`ChunkRef`] straight into the call frame, so
101    /// re-running the same chunk is a refcount bump rather than an
102    /// `O(code + constants)` copy.
103    pub async fn execute_arc(&mut self, chunk: ChunkRef) -> Result<VmValue, VmError> {
104        self.ensure_execution_available()?;
105        let registry = self.pool_registry.clone();
106        let owner = crate::observability::execution_scope::mint_execution_scope();
107        let ambient = crate::orchestration::AmbientExecutionScope::capture_for_top_level_execution(
108            owner,
109            self.llm_mock_context.clone(),
110        );
111        let execution = crate::stdlib::pool::with_pool_registry_scope(registry, async {
112            self.execute_scoped(chunk).await
113        });
114        crate::orchestration::scope_ambient(ambient, execution).await
115    }
116
117    async fn execute_scoped(&mut self, chunk: ChunkRef) -> Result<VmValue, VmError> {
118        let _execution_activity = self
119            .wait_for_graph
120            .register_task(self.runtime_context.task_id.clone());
121        let _span = ScopeSpan::new(crate::tracing::SpanKind::Pipeline, "main".into());
122        let result = self.run_chunk(chunk).await;
123        let result = match result {
124            Ok(value) => self.run_pipeline_finish_lifecycle(value).await,
125            Err(error) => {
126                crate::orchestration::clear_pipeline_on_finish();
127                Err(error)
128            }
129        };
130        result
131    }
132
133    /// Run the pipeline-finish lifecycle: `PreFinish`, optional
134    /// `OnUnsettledDetected`, the `on_finish` callback, `PostFinish`. The
135    /// callback (if registered) may transform the return value; everything
136    /// else is advisory.
137    ///
138    /// Tracked: <https://github.com/burin-labs/harn/issues/1854>.
139    async fn run_pipeline_finish_lifecycle(&mut self, value: VmValue) -> Result<VmValue, VmError> {
140        use crate::orchestration::{
141            take_pipeline_on_finish, unsettled_state_snapshot_async, HookEvent,
142        };
143        let _tape_phase =
144            crate::testbench::tape::enter_phase(crate::testbench::tape::TapePhase::RuntimeFinalize);
145
146        let on_finish = take_pipeline_on_finish();
147        let unsettled = unsettled_state_snapshot_async().await;
148
149        let pre_payload = serde_json::json!({
150            "event": HookEvent::PreFinish.as_str(),
151            "return_value": crate::llm::vm_value_to_json(&value),
152            "unsettled": unsettled.to_json(),
153            "has_on_finish": on_finish.is_some(),
154        });
155        self.fire_finish_lifecycle_event(HookEvent::PreFinish, &pre_payload)
156            .await?;
157
158        if !unsettled.is_empty() {
159            let payload = serde_json::json!({
160                "event": HookEvent::OnUnsettledDetected.as_str(),
161                "unsettled": unsettled.to_json(),
162            });
163            self.fire_finish_lifecycle_event(HookEvent::OnUnsettledDetected, &payload)
164                .await?;
165        }
166
167        let final_value = if let Some(closure) = on_finish {
168            let harness_value = crate::harness::Harness::real().into_vm_value();
169            self.call_closure_pub(&closure, &[harness_value, value])
170                .await?
171        } else {
172            value
173        };
174
175        let post_payload = serde_json::json!({
176            "event": HookEvent::PostFinish.as_str(),
177            "return_value": crate::llm::vm_value_to_json(&final_value),
178            "unsettled": unsettled.to_json(),
179        });
180        self.fire_finish_lifecycle_event(HookEvent::PostFinish, &post_payload)
181            .await?;
182
183        Ok(final_value)
184    }
185
186    /// Dispatch a pipeline-finish lifecycle event by invoking matching
187    /// hook closures directly on `self`. The shared `run_lifecycle_hooks`
188    /// path clones a fresh child VM per call and discards its stdout —
189    /// fine for the agent-loop boundaries where hooks are advisory side-
190    /// channels, but the pipeline-finish boundary is the script's last
191    /// chance to print before `vm.output()` is captured, so the closures
192    /// run on `self` to keep their output visible.
193    ///
194    /// Honors the lifecycle control contract (harn#1859):
195    ///   * `PreFinish` rejects `Block` outright — surfaces a runtime
196    ///     error pointing the user at `OnFinish.block_until_settled`.
197    ///     `PostFinish` ignores any control return (advisory only).
198    ///   * `OnUnsettledDetected` honors `Block` to abort the finish
199    ///     lifecycle until the unsettled work clears.
200    ///   * Modify returns are recorded but not consumed at this boundary
201    ///     (the dispatcher already replays subsequent hooks with the
202    ///     post-modify payload via `run_lifecycle_hooks_with_control`).
203    async fn fire_finish_lifecycle_event(
204        &mut self,
205        event: crate::orchestration::HookEvent,
206        payload: &serde_json::Value,
207    ) -> Result<(), VmError> {
208        use crate::orchestration::{HookControl, HookEvent};
209        let invocations = crate::orchestration::matching_vm_lifecycle_hooks(event, payload);
210        if invocations.is_empty() {
211            return Ok(());
212        }
213        let mut current_payload = payload.clone();
214        for invocation in invocations {
215            let arg = crate::stdlib::json_to_vm_value(&current_payload);
216            let closure = invocation.resolve(self).await?;
217            let raw = self.call_closure_pub(&closure, &[arg]).await?;
218            let (action, effects) = crate::orchestration::collect_hook_effects_and_action(
219                event,
220                raw,
221                crate::value::VmValue::Nil,
222            )?;
223            crate::orchestration::inject_hook_effects_into_current_session(effects)?;
224            let control = crate::orchestration::parse_hook_control_for_finish(event, &action)?;
225            match control {
226                HookControl::Allow => {}
227                HookControl::Block { reason } => {
228                    if matches!(event, HookEvent::PreFinish) {
229                        return Err(VmError::Runtime(format!(
230                            "PreFinish hook returned block, which is not a valid control: {reason}. \
231                             To delay pipeline finish until unsettled work clears, use \
232                             OnFinish.block_until_settled (std/lifecycle) or return Modify/Allow \
233                             from PreFinish."
234                        )));
235                    }
236                    if matches!(event, HookEvent::PostFinish) {
237                        // Advisory only; ignore block returns from PostFinish.
238                        continue;
239                    }
240                    // OnUnsettledDetected: block aborts the finish lifecycle.
241                    return Err(VmError::Runtime(format!(
242                        "{} hook blocked pipeline finish: {reason}",
243                        event.as_str()
244                    )));
245                }
246                HookControl::Modify { payload: modified } => {
247                    current_payload = modified;
248                }
249                HookControl::Decision { .. } => {}
250            }
251        }
252        Ok(())
253    }
254
255    /// Convert a VmError into either a handled exception (returning Ok) or a propagated error.
256    pub(crate) fn handle_error(&mut self, error: VmError) -> Result<Option<VmValue>, VmError> {
257        if let Some(code) = error.process_exit_code() {
258            self.request_process_exit(code);
259        }
260        if error.is_uncatchable_control_flow() {
261            return Err(error);
262        }
263        let thrown_value = error.thrown_value();
264
265        if let Some(handler) = self.exception_handlers.pop() {
266            if let Some(error_type) = handler.error_type.as_deref() {
267                // Typed catch: only match when the thrown enum's type equals the declared type.
268                let matches = match &thrown_value {
269                    VmValue::EnumVariant(enum_variant) => enum_variant.has_enum_name(error_type),
270                    _ => false,
271                };
272                if !matches {
273                    return self.handle_error(error);
274                }
275            }
276
277            self.release_sync_guards_after_unwind(handler.frame_depth, handler.env_scope_depth);
278
279            while self.frames.len() > handler.frame_depth {
280                if let Some(frame) = self.frames.pop() {
281                    if let Some(ref dir) = frame.saved_source_dir {
282                        crate::stdlib::set_thread_source_dir(dir);
283                    }
284                    self.iterators.truncate(frame.saved_iterator_depth);
285                    self.env = frame.saved_env;
286                }
287            }
288            crate::step_runtime::prune_below_frame(self.frames.len());
289
290            // Drop deadlines that belonged to unwound frames.
291            while self
292                .deadlines
293                .last()
294                .is_some_and(|d| d.1 > handler.frame_depth)
295            {
296                self.deadlines.pop();
297            }
298
299            self.env.truncate_scopes(handler.env_scope_depth);
300
301            self.stack.truncate(handler.stack_depth);
302            self.stack.push(thrown_value);
303
304            if let Some(frame) = self.frames.last_mut() {
305                frame.ip = handler.catch_ip;
306            }
307
308            Ok(None)
309        } else {
310            Err(error)
311        }
312    }
313
314    pub(crate) async fn run_chunk(&mut self, chunk: ChunkRef) -> Result<VmValue, VmError> {
315        self.run_chunk_ref(chunk, 0, None, None, None, None).await
316    }
317
318    pub(crate) async fn run_chunk_ref(
319        &mut self,
320        chunk: ChunkRef,
321        argc: usize,
322        saved_source_dir: Option<std::path::PathBuf>,
323        module_functions: Option<ModuleFunctionRegistry>,
324        module_state: Option<crate::value::ModuleState>,
325        local_slots: Option<Vec<LocalSlot>>,
326    ) -> Result<VmValue, VmError> {
327        self.ensure_execution_available()?;
328        let debugger = self.debugger_attached();
329        let local_slots = local_slots.unwrap_or_else(|| Self::fresh_local_slots(&chunk));
330        let initial_env = if debugger {
331            Some(self.env.clone())
332        } else {
333            None
334        };
335        let initial_local_slots = if debugger {
336            Some(local_slots.clone())
337        } else {
338            None
339        };
340        let inline_cache_set = self.inline_cache_set_index_for_chunk(&chunk);
341        self.frames.push(CallFrame {
342            chunk,
343            inline_cache_set,
344            ip: 0,
345            stack_base: self.stack.len(),
346            saved_env: self.env.clone(),
347            initial_env,
348            initial_local_slots,
349            saved_iterator_depth: self.iterators.len(),
350            fn_name: String::new(),
351            argc,
352            saved_source_dir,
353            module_functions,
354            module_state,
355            local_slots,
356            local_scope_base: self.env.scope_depth().saturating_sub(1),
357            local_scope_depth: 0,
358        });
359
360        self.drive_dispatch_loop(0, false).await
361    }
362
363    /// Sub-execution entrypoint used by [`Vm::call_closure`]: runs the
364    /// dispatch loop until the topmost frame pops back to `target_depth`,
365    /// restoring env/iterators/stack on that final pop so the caller's
366    /// state is intact. Distinct from the entrypoint-mode call in
367    /// [`Vm::run_chunk_ref`] (which preserves the script's top-level scope
368    /// for the module-init capture in `modules.rs`).
369    pub(crate) async fn drive_until_frame_depth(
370        &mut self,
371        target_depth: usize,
372    ) -> Result<VmValue, VmError> {
373        self.drive_dispatch_loop(target_depth, true).await
374    }
375
376    /// Dispatch loop body, parameterized on a target frame depth at which
377    /// the loop should return and whether to restore the caller's
378    /// env/iterators/stack on the final pop.
379    ///
380    /// `restore_on_final_pop = false` is the entrypoint mode used by
381    /// `run_chunk_ref` (leaves the script's top-level state in place so the
382    /// caller can capture it — see `modules.rs`).
383    ///
384    /// `restore_on_final_pop = true` is the sub-execution mode used by
385    /// `call_closure`: the closure's frame is pushed onto the caller's
386    /// frame stack and the loop drains it back to `target_depth`, so the
387    /// per-invocation `Box::pin` heap allocation a recursive async
388    /// `call_closure` would require is avoided.
389    async fn drive_dispatch_loop(
390        &mut self,
391        target_depth: usize,
392        restore_on_final_pop: bool,
393    ) -> Result<VmValue, VmError> {
394        self.ensure_execution_available()?;
395        let _task_activity = self
396            .wait_for_graph
397            .register_task(self.runtime_context.task_id.clone());
398        loop {
399            // Slow path only: the interrupt-handler future, deadline check,
400            // and host-signal poll inside `pending_scope_interrupt` are all
401            // no-ops when no cancel/interrupt/deadline machinery is armed
402            // (the common case for unsupervised execution), so guard them
403            // with a sync check that avoids the per-iteration future
404            // state-machine allocation.
405            if !self.scope_interrupts_clean() {
406                if let Some(err) = self.pending_scope_interrupt().await {
407                    match self.handle_error(err) {
408                        Ok(None) => continue,
409                        Ok(Some(val)) => return Ok(val),
410                        Err(e) => {
411                            self.unwind_frames_to_depth(target_depth);
412                            return Err(e);
413                        }
414                    }
415                }
416            }
417
418            let frame = match self.frames.last_mut() {
419                Some(f) => f,
420                None => return Ok(self.stack.pop().unwrap_or(VmValue::Nil)),
421            };
422
423            if frame.ip >= frame.chunk.code.len() {
424                let val = self.stack.pop().unwrap_or(VmValue::Nil);
425                let val = self.run_step_post_hooks_for_current_frame(val).await?;
426                self.release_sync_guards_for_frame(self.frames.len());
427                let popped_frame = self.frames.pop().unwrap();
428                if let Some(ref dir) = popped_frame.saved_source_dir {
429                    crate::stdlib::set_thread_source_dir(dir);
430                }
431                let current_depth = self.frames.len();
432                crate::step_runtime::prune_below_frame(current_depth);
433                // Drop any deadlines owned by the popped frame so the
434                // caller doesn't inherit them (an early `return` from
435                // inside `deadline(d) { ... }` would otherwise leave the
436                // deadline live across the function boundary).
437                while self.deadlines.last().is_some_and(|d| d.1 > current_depth) {
438                    self.deadlines.pop();
439                }
440
441                let reached_target = current_depth <= target_depth;
442                if reached_target && !restore_on_final_pop {
443                    // Entrypoint mode: leave env / iterators / stack in place
444                    // so the caller can observe the script's top-level scope.
445                    return Ok(val);
446                }
447                self.iterators.truncate(popped_frame.saved_iterator_depth);
448                self.env = popped_frame.saved_env;
449                self.stack.truncate(popped_frame.stack_base);
450                if reached_target {
451                    return Ok(val);
452                }
453                self.stack.push(val);
454                continue;
455            }
456
457            let op_byte = frame.chunk.code[frame.ip];
458            // Line-coverage hit. `self.coverage` is `None` unless a coverage
459            // session is active, so this is a single predictable branch on the
460            // hot path (and a disjoint-field borrow from `frame`, which holds
461            // `self.frames`). `frame.ip` is still the index of the instruction
462            // we just read, before the increment below.
463            if let Some(coverage) = self.coverage.as_mut() {
464                coverage.record(&frame.chunk, frame.ip);
465            }
466            frame.ip += 1;
467
468            // Sync/async split dispatch: sync opcodes stay on the direct hot
469            // path even while a host deadline is armed. The instruction-boundary
470            // check above makes CPU-bound code interruptible without paying for
471            // a future and `tokio::select!` on every arithmetic/local opcode.
472            let op = match Op::from_byte(op_byte) {
473                Some(op) => op,
474                None => return Err(VmError::InvalidInstruction(op_byte)),
475            };
476            let op_result: Result<(), VmError> = if let Some(result) = self.execute_op_sync(op) {
477                result
478            } else if self.scope_interrupts_clean() {
479                self.execute_op_async(op).await
480            } else {
481                match self.execute_op_with_scope_interrupts(op_byte).await {
482                    Ok(Some(val)) => return Ok(val),
483                    Ok(None) => Ok(()),
484                    Err(e) => Err(e),
485                }
486            };
487
488            match op_result {
489                Ok(()) => continue,
490                Err(VmError::Return(val)) => {
491                    let val = self.run_step_post_hooks_for_current_frame(val).await?;
492                    if let Some(popped_frame) = self.frames.pop() {
493                        self.release_sync_guards_for_frame(self.frames.len() + 1);
494                        if let Some(ref dir) = popped_frame.saved_source_dir {
495                            crate::stdlib::set_thread_source_dir(dir);
496                        }
497                        let current_depth = self.frames.len();
498                        self.exception_handlers
499                            .retain(|h| h.frame_depth <= current_depth);
500                        crate::step_runtime::prune_below_frame(current_depth);
501                        while self.deadlines.last().is_some_and(|d| d.1 > current_depth) {
502                            self.deadlines.pop();
503                        }
504
505                        let reached_target = current_depth <= target_depth;
506                        if reached_target && !restore_on_final_pop {
507                            return Ok(val);
508                        }
509                        self.iterators.truncate(popped_frame.saved_iterator_depth);
510                        self.env = popped_frame.saved_env;
511                        self.stack.truncate(popped_frame.stack_base);
512                        if reached_target {
513                            return Ok(val);
514                        }
515                        self.stack.push(val);
516                    } else {
517                        return Ok(val);
518                    }
519                }
520                Err(e) => {
521                    // Capture stack trace before error handling unwinds frames.
522                    if self.error_stack_trace.is_empty() {
523                        self.error_stack_trace = self.capture_stack_trace();
524                    }
525                    // Honor `@step(error_boundary: ...)` if a step-budget
526                    // exhaustion error is propagating out of the step's
527                    // own frame. `continue` swaps the throw for a Nil
528                    // return; `escalate` re-tags the error as a handoff
529                    // escalation and lets the existing exception
530                    // handlers route it.
531                    let e = match self.apply_step_error_boundary(e) {
532                        StepBoundaryOutcome::Returned(val) => {
533                            self.error_stack_trace.clear();
534                            if self.frames.len() <= target_depth {
535                                return Ok(val);
536                            }
537                            self.stack.push(val);
538                            continue;
539                        }
540                        StepBoundaryOutcome::Throw(err) => err,
541                    };
542                    match self.handle_error(e) {
543                        Ok(None) => {
544                            self.error_stack_trace.clear();
545                            continue;
546                        }
547                        Ok(Some(val)) => return Ok(val),
548                        Err(e) => {
549                            self.unwind_frames_to_depth(target_depth);
550                            return Err(self.enrich_error_with_line(e));
551                        }
552                    }
553                }
554            }
555        }
556    }
557
558    /// Pop frames until `self.frames.len() <= target_depth`, restoring env,
559    /// iterators, stack, source-dir thread-locals, and releasing per-frame
560    /// sync guards for each popped frame. Used by [`drive_until_frame_depth`]
561    /// on the error path so a closure sub-execution leaves caller-visible
562    /// state at the same depth it found when an unhandled error propagates
563    /// out.
564    fn unwind_frames_to_depth(&mut self, target_depth: usize) {
565        while self.frames.len() > target_depth {
566            let frame_depth = self.frames.len();
567            if let Some(frame) = self.frames.pop() {
568                self.release_sync_guards_for_frame(frame_depth);
569                if let Some(ref dir) = frame.saved_source_dir {
570                    crate::stdlib::set_thread_source_dir(dir);
571                }
572                self.iterators.truncate(frame.saved_iterator_depth);
573                self.env = frame.saved_env;
574                self.stack.truncate(frame.stack_base);
575            }
576        }
577        let current_depth = self.frames.len();
578        crate::step_runtime::prune_below_frame(current_depth);
579        while self.deadlines.last().is_some_and(|d| d.1 > current_depth) {
580            self.deadlines.pop();
581        }
582    }
583
584    /// Inspect a thrown error against the topmost active step's
585    /// `error_boundary`. Called from the main step loop before
586    /// `handle_error` so that a step's own budget-exhaustion error can be
587    /// short-circuited (`continue`) or annotated (`escalate`) before the
588    /// generic try/catch machinery sees it.
589    pub(crate) fn apply_step_error_boundary(&mut self, error: VmError) -> StepBoundaryOutcome {
590        use crate::step_runtime;
591        if !step_runtime::is_step_budget_exhausted(&error) {
592            return StepBoundaryOutcome::Throw(error);
593        }
594        let Some(step_depth) = step_runtime::active_step_frame_depth() else {
595            return StepBoundaryOutcome::Throw(error);
596        };
597        // The step's frame is the topmost on the call stack iff its
598        // recorded frame_depth equals `frames.len()`. If the throw is
599        // coming from a deeper frame we let it bubble up — the boundary
600        // still applies later when the step's own frame is reached.
601        if step_depth != self.frames.len() {
602            return StepBoundaryOutcome::Throw(error);
603        }
604        let boundary = step_runtime::with_active_step(|step| step.definition.boundary())
605            .unwrap_or(step_runtime::StepErrorBoundary::Fail);
606        match boundary {
607            step_runtime::StepErrorBoundary::Continue => {
608                // Mimic VmError::Return(Nil) for the step's frame: pop
609                // the frame, restore its env/iterators/stack, and feed a
610                // Nil return value back to the caller.
611                if let Some(popped) = self.frames.pop() {
612                    self.release_sync_guards_for_frame(self.frames.len() + 1);
613                    if let Some(ref dir) = popped.saved_source_dir {
614                        crate::stdlib::set_thread_source_dir(dir);
615                    }
616                    let current_depth = self.frames.len();
617                    self.exception_handlers
618                        .retain(|h| h.frame_depth <= current_depth);
619                    step_runtime::pop_and_record(
620                        current_depth + 1,
621                        "skipped",
622                        Some(step_runtime_error_message(&error)),
623                    );
624                    if self.frames.is_empty() {
625                        return StepBoundaryOutcome::Returned(VmValue::Nil);
626                    }
627                    self.iterators.truncate(popped.saved_iterator_depth);
628                    self.env = popped.saved_env;
629                    self.stack.truncate(popped.stack_base);
630                }
631                StepBoundaryOutcome::Returned(VmValue::Nil)
632            }
633            step_runtime::StepErrorBoundary::Escalate => {
634                let identity = step_runtime::with_active_step(|step| {
635                    (
636                        step.definition.name.clone(),
637                        step.definition.function.clone(),
638                    )
639                });
640                step_runtime::pop_and_record(
641                    step_depth,
642                    "escalated",
643                    Some(step_runtime_error_message(&error)),
644                );
645                let (step_name, function) = identity.unzip();
646                StepBoundaryOutcome::Throw(step_runtime::mark_escalated(
647                    error,
648                    step_name.as_deref(),
649                    function.as_deref(),
650                ))
651            }
652            step_runtime::StepErrorBoundary::Fail => {
653                step_runtime::pop_and_record(
654                    step_depth,
655                    "failed",
656                    Some(step_runtime_error_message(&error)),
657                );
658                StepBoundaryOutcome::Throw(error)
659            }
660        }
661    }
662}
663
664fn next_deadline(
665    execution_deadline: Option<Instant>,
666    scope_deadline: Option<Instant>,
667    interrupt_handler_deadline: Option<Instant>,
668) -> (Option<Instant>, Option<DeadlineKind>) {
669    [
670        (execution_deadline, DeadlineKind::Execution),
671        (scope_deadline, DeadlineKind::Scope),
672        (interrupt_handler_deadline, DeadlineKind::InterruptHandler),
673    ]
674    .into_iter()
675    .filter_map(|(deadline, kind)| deadline.map(|deadline| (deadline, kind)))
676    .min_by_key(|(deadline, _)| *deadline)
677    .map_or((None, None), |(deadline, kind)| {
678        (Some(deadline), Some(kind))
679    })
680}
681
682fn step_runtime_error_message(error: &VmError) -> String {
683    match error {
684        VmError::Thrown(VmValue::Dict(dict)) => dict
685            .get("message")
686            .map(|v| v.display())
687            .unwrap_or_else(|| error.to_string()),
688        _ => error.to_string(),
689    }
690}
691
692pub(crate) enum StepBoundaryOutcome {
693    Returned(VmValue),
694    Throw(VmError),
695}
696
697impl crate::vm::Vm {
698    pub(crate) async fn execute_one_cycle(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
699        if let Some(err) = self.pending_scope_interrupt().await {
700            match self.handle_error(err) {
701                Ok(None) => return Ok(None),
702                Ok(Some(val)) => return Ok(Some((val, false))),
703                Err(e) => return Err(e),
704            }
705        }
706
707        let frame = match self.frames.last_mut() {
708            Some(f) => f,
709            None => {
710                let val = self.stack.pop().unwrap_or(VmValue::Nil);
711                return Ok(Some((val, false)));
712            }
713        };
714
715        if frame.ip >= frame.chunk.code.len() {
716            let val = self.stack.pop().unwrap_or(VmValue::Nil);
717            self.release_sync_guards_for_frame(self.frames.len());
718            let popped_frame = self.frames.pop().unwrap();
719            if self.frames.is_empty() {
720                return Ok(Some((val, false)));
721            }
722            self.iterators.truncate(popped_frame.saved_iterator_depth);
723            self.env = popped_frame.saved_env;
724            self.stack.truncate(popped_frame.stack_base);
725            self.stack.push(val);
726            return Ok(None);
727        }
728
729        let op = frame.chunk.code[frame.ip];
730        frame.ip += 1;
731
732        match self.execute_op_with_scope_interrupts(op).await {
733            Ok(Some(val)) => Ok(Some((val, false))),
734            Ok(None) => Ok(None),
735            Err(VmError::Return(val)) => {
736                if let Some(popped_frame) = self.frames.pop() {
737                    self.release_sync_guards_for_frame(self.frames.len() + 1);
738                    if let Some(ref dir) = popped_frame.saved_source_dir {
739                        crate::stdlib::set_thread_source_dir(dir);
740                    }
741                    let current_depth = self.frames.len();
742                    self.exception_handlers
743                        .retain(|h| h.frame_depth <= current_depth);
744                    if self.frames.is_empty() {
745                        return Ok(Some((val, false)));
746                    }
747                    self.iterators.truncate(popped_frame.saved_iterator_depth);
748                    self.env = popped_frame.saved_env;
749                    self.stack.truncate(popped_frame.stack_base);
750                    self.stack.push(val);
751                    Ok(None)
752                } else {
753                    Ok(Some((val, false)))
754                }
755            }
756            Err(e) => {
757                if self.error_stack_trace.is_empty() {
758                    self.error_stack_trace = self.capture_stack_trace();
759                }
760                match self.handle_error(e) {
761                    Ok(None) => {
762                        self.error_stack_trace.clear();
763                        Ok(None)
764                    }
765                    Ok(Some(val)) => Ok(Some((val, false))),
766                    Err(e) => Err(self.enrich_error_with_line(e)),
767                }
768            }
769        }
770    }
771
772    async fn execute_op_with_scope_interrupts(
773        &mut self,
774        op: u8,
775    ) -> Result<Option<VmValue>, VmError> {
776        #[cfg(test)]
777        SCOPE_INTERRUPT_ASYNC_DISPATCHES
778            .set(SCOPE_INTERRUPT_ASYNC_DISPATCHES.get().saturating_add(1));
779
780        enum ScopeInterruptResult {
781            Op(Result<Option<VmValue>, VmError>),
782            Deadline(DeadlineKind),
783            CancelTimedOut,
784        }
785
786        let (deadline, deadline_kind) = next_deadline(
787            self.execution_deadline.current(),
788            self.deadlines.last().map(|(deadline, _)| *deadline),
789            self.interrupt_handler_deadline,
790        );
791        let cancel_token = self.cancel_token.clone();
792
793        if deadline.is_none() && cancel_token.is_none() {
794            return self.execute_op(op).await;
795        }
796
797        let has_deadline = deadline.is_some();
798        let cancel_requested_at_start = cancel_token
799            .as_ref()
800            .is_some_and(|token| token.load(std::sync::atomic::Ordering::SeqCst));
801        let has_cancel = cancel_token.is_some() && !cancel_requested_at_start;
802        let deadline_sleep = async move {
803            if let Some(deadline) = deadline {
804                tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
805            } else {
806                std::future::pending::<()>().await;
807            }
808        };
809        let cancel_sleep = async move {
810            if let Some(token) = cancel_token {
811                while !token.load(std::sync::atomic::Ordering::SeqCst) {
812                    tokio::time::sleep(Duration::from_millis(10)).await;
813                }
814            } else {
815                std::future::pending::<()>().await;
816            }
817        };
818
819        let result = {
820            let op_future = self.execute_op(op);
821            tokio::pin!(op_future);
822            tokio::select! {
823                result = &mut op_future => ScopeInterruptResult::Op(result),
824                _ = deadline_sleep, if has_deadline => {
825                    ScopeInterruptResult::Deadline(deadline_kind.unwrap_or(DeadlineKind::Scope))
826                },
827                _ = cancel_sleep, if has_cancel => {
828                    let grace = tokio::time::sleep(CANCEL_GRACE_ASYNC_OP);
829                    tokio::pin!(grace);
830                    tokio::select! {
831                        result = &mut op_future => ScopeInterruptResult::Op(result),
832                        _ = &mut grace => ScopeInterruptResult::CancelTimedOut,
833                    }
834                }
835            }
836        };
837
838        match result {
839            ScopeInterruptResult::Op(result) => result,
840            ScopeInterruptResult::Deadline(DeadlineKind::Execution) => {
841                self.cancel_spawned_tasks();
842                Err(VmError::ExecutionDeadlineExceeded)
843            }
844            ScopeInterruptResult::Deadline(DeadlineKind::Scope) => {
845                self.deadlines.pop();
846                self.cancel_spawned_tasks();
847                Err(Self::deadline_exceeded_error())
848            }
849            ScopeInterruptResult::Deadline(DeadlineKind::InterruptHandler) => {
850                Err(Self::interrupt_handler_timeout_error())
851            }
852            ScopeInterruptResult::CancelTimedOut => {
853                self.cancel_spawned_tasks();
854                let signal = self
855                    .take_host_interrupt_signal()
856                    .unwrap_or_else(|| "SIGINT".to_string());
857                if self.has_interrupt_handler_for(&signal) {
858                    self.dispatch_interrupt_handlers(&signal).await?;
859                }
860                Err(Self::cancelled_error())
861            }
862        }
863    }
864
865    pub(crate) fn deadline_exceeded_error() -> VmError {
866        VmError::Thrown(VmValue::String(arcstr::ArcStr::from("Deadline exceeded")))
867    }
868
869    pub(crate) fn cancelled_error() -> VmError {
870        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
871            "kind:cancelled:VM cancelled by host",
872        )))
873    }
874
875    /// Capture the current call stack as (fn_name, line, col, source_file) tuples.
876    pub(crate) fn capture_stack_trace(&self) -> Vec<(String, usize, usize, Option<String>)> {
877        self.frames
878            .iter()
879            .map(|f| {
880                let idx = if f.ip > 0 { f.ip - 1 } else { 0 };
881                let line = f.chunk.lines.get(idx).copied().unwrap_or(0) as usize;
882                let col = f.chunk.columns.get(idx).copied().unwrap_or(0) as usize;
883                (f.fn_name.clone(), line, col, f.chunk.source_file.clone())
884            })
885            .collect()
886    }
887
888    /// Enrich a VmError with source line information from the captured stack
889    /// trace. Appends ` (line N)` to error variants whose messages don't
890    /// already carry location context.
891    pub(crate) fn enrich_error_with_line(&self, error: VmError) -> VmError {
892        // Determine the line AND source file from the captured stack trace
893        // (innermost frame) so the error names the exact `.harn` it crashed in.
894        // A bare `(line N)` is ambiguous across 100+ stdlib files and forces a
895        // manual hunt; `(stall.harn:497)` pinpoints it immediately.
896        let (line, file) = self
897            .error_stack_trace
898            .last()
899            .map(|(_, l, _, f)| (*l, f.clone()))
900            .unwrap_or_else(|| (self.current_line(), None));
901        if line == 0 {
902            return error;
903        }
904        let suffix = match file.as_deref() {
905            Some(path) => {
906                let name = std::path::Path::new(path)
907                    .file_name()
908                    .and_then(|n| n.to_str())
909                    .unwrap_or(path);
910                format!(" ({name}:{line})")
911            }
912            None => format!(" (line {line})"),
913        };
914        match error {
915            VmError::Runtime(msg) => VmError::Runtime(format!("{msg}{suffix}")),
916            VmError::TypeError(msg) => VmError::TypeError(format!("{msg}{suffix}")),
917            VmError::DivisionByZero => VmError::Runtime(format!("Division by zero{suffix}")),
918            VmError::UndefinedVariable(name) => {
919                VmError::Runtime(format!("Undefined variable: {name}{suffix}"))
920            }
921            VmError::UndefinedBuiltin(name) => {
922                VmError::Runtime(format!("Undefined builtin: {name}{suffix}"))
923            }
924            VmError::ImmutableAssignment(name) => VmError::Runtime(format!(
925                "Cannot assign to immutable binding: {name}{suffix}"
926            )),
927            VmError::StackOverflow => {
928                VmError::Runtime(format!("Stack overflow: too many nested calls{suffix}"))
929            }
930            // Leave these untouched:
931            // - Thrown: user-thrown errors should not be silently modified
932            // - CategorizedError: structured errors for agent orchestration
933            // - Return / ProcessExit: control flow, not a real error
934            // - StackUnderflow / InvalidInstruction: internal VM bugs
935            other => other,
936        }
937    }
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943    use crate::compiler::Compiler;
944    use crate::stdlib::register_vm_stdlib;
945    use harn_lexer::Lexer;
946    use harn_parser::Parser;
947    use std::sync::atomic::{AtomicBool, Ordering};
948
949    fn compile_harn(source: &str) -> Chunk {
950        let mut lexer = Lexer::new(source);
951        let tokens = lexer.tokenize().unwrap();
952        let mut parser = Parser::new(tokens);
953        let program = parser.parse().unwrap();
954        Compiler::new().compile(&program).unwrap()
955    }
956
957    #[tokio::test(flavor = "current_thread")]
958    async fn dropping_timed_execution_poison_vm_reuse() {
959        let local = tokio::task::LocalSet::new();
960        local
961            .run_until(async {
962                let slow = compile_harn(
963                    r#"pipeline default() {
964  pipeline_on_finish({ _h, value -> value })
965  const child = spawn {
966    child_started()
967    wait_for_child_release()
968    child_effect()
969  }
970  __io_println("first-started")
971  sleep(5s)
972  return 7
973}"#,
974                );
975                let quick = compile_harn("pipeline default() { return 42 }");
976                let mut vm = Vm::new();
977                register_vm_stdlib(&mut vm);
978                let child_started = Arc::new(AtomicBool::new(false));
979                let child_effect = Arc::new(AtomicBool::new(false));
980                let child_release = Arc::new(tokio::sync::Notify::new());
981                let started_for_builtin = Arc::clone(&child_started);
982                vm.register_builtin("child_started", move |_args, _output| {
983                    started_for_builtin.store(true, Ordering::Release);
984                    Ok(VmValue::Nil)
985                });
986                let effect_for_builtin = Arc::clone(&child_effect);
987                vm.register_builtin("child_effect", move |_args, _output| {
988                    effect_for_builtin.store(true, Ordering::Release);
989                    Ok(VmValue::Nil)
990                });
991                let release_for_builtin = Arc::clone(&child_release);
992                vm.register_async_builtin("wait_for_child_release", move |_ctx, _args| {
993                    let release = Arc::clone(&release_for_builtin);
994                    async move {
995                        release.notified().await;
996                        Ok(VmValue::Nil)
997                    }
998                });
999                let callable = vm
1000                    .load_module_exports_from_source(
1001                        "<abandoned-execution-test>",
1002                        "pub fn answer() { return 42 }",
1003                    )
1004                    .await
1005                    .unwrap()
1006                    .remove("answer")
1007                    .unwrap();
1008
1009                let mut execution =
1010                    Box::pin(vm.execute_with_timeout(&slow, Duration::from_secs(30)));
1011                tokio::select! {
1012                    biased;
1013                    result = &mut execution => panic!("slow execution unexpectedly finished: {result:?}"),
1014                    started = tokio::time::timeout(Duration::from_secs(1), async {
1015                        while !child_started.load(Ordering::Acquire) {
1016                            tokio::task::yield_now().await;
1017                        }
1018                    }) => started.expect("spawned child did not start"),
1019                }
1020                drop(execution);
1021
1022                assert!(!vm.execution_deadline.is_active());
1023                assert!(vm.output().contains("first-started"));
1024                assert!(!vm.frames.is_empty(), "fixture must abandon a live frame");
1025                assert!(crate::orchestration::take_pipeline_on_finish().is_none());
1026                let frame_depth = vm.frames.len();
1027                let output = vm.output().to_string();
1028                let error = vm.execute(&quick).await.unwrap_err();
1029                assert!(matches!(error, VmError::AbandonedExecution));
1030                let closure_error = vm.call_closure_pub(&callable, &[]).await.unwrap_err();
1031                assert!(matches!(closure_error, VmError::AbandonedExecution));
1032                let source_cache_len = vm.source_cache.len();
1033                let module_cache_len = vm.module_cache.len();
1034                let module_error = vm
1035                    .load_module_exports_from_source(
1036                        "<poisoned-module-load>",
1037                        "pub fn poisoned() { return 0 }",
1038                    )
1039                    .await
1040                    .unwrap_err();
1041                assert!(matches!(module_error, VmError::AbandonedExecution));
1042                assert_eq!(vm.source_cache.len(), source_cache_len);
1043                assert_eq!(vm.module_cache.len(), module_cache_len);
1044                let start_error = vm.start(&quick).unwrap_err();
1045                assert!(matches!(start_error, VmError::AbandonedExecution));
1046                let restart_error = vm.restart_frame(0).unwrap_err();
1047                assert!(matches!(restart_error, VmError::AbandonedExecution));
1048                assert_eq!(vm.frames.len(), frame_depth);
1049                assert_eq!(vm.output(), output);
1050                drop(vm);
1051                child_release.notify_one();
1052                for _ in 0..10 {
1053                    tokio::task::yield_now().await;
1054                }
1055                assert!(
1056                    !child_effect.load(Ordering::Acquire),
1057                    "dropping an abandoned VM must abort spawned side effects"
1058                );
1059            })
1060            .await;
1061    }
1062
1063    #[tokio::test(flavor = "current_thread")]
1064    async fn timed_finite_loop_keeps_sync_opcodes_on_direct_dispatch() {
1065        let local = tokio::task::LocalSet::new();
1066        local
1067            .run_until(async {
1068                let chunk = compile_harn(
1069                    r"
1070pipeline default() {
1071  let total = 0
1072  for i in 0 to 10000 {
1073    total = total + i
1074  }
1075  return total
1076}
1077",
1078                );
1079                let mut vm = Vm::new();
1080                register_vm_stdlib(&mut vm);
1081                reset_scope_interrupt_async_dispatches();
1082
1083                let value = vm
1084                    .execute_with_timeout(&chunk, Duration::from_secs(1))
1085                    .await
1086                    .unwrap();
1087
1088                assert!(matches!(value, VmValue::Int(_)));
1089                assert!(
1090                    scope_interrupt_async_dispatches() <= 4,
1091                    "finite sync loop fell back to per-op async dispatch"
1092                );
1093            })
1094            .await;
1095    }
1096}