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