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