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