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 = self.root_harness_value().ok_or_else(|| {
157                VmError::Runtime(
158                    "pipeline finish callback requires Harness, but no root Harness is installed"
159                        .to_string(),
160                )
161            })?;
162            self.call_closure_pub(&closure, &[harness_value, value])
163                .await?
164        } else {
165            value
166        };
167
168        let post_payload = serde_json::json!({
169            "event": HookEvent::PostFinish.as_str(),
170            "return_value": crate::llm::vm_value_to_json(&final_value),
171            "unsettled": unsettled.to_json(),
172        });
173        self.fire_finish_lifecycle_event(HookEvent::PostFinish, &post_payload)
174            .await?;
175
176        Ok(final_value)
177    }
178
179    /// Dispatch a pipeline-finish lifecycle event by invoking matching
180    /// hook closures directly on `self`. The shared `run_lifecycle_hooks`
181    /// path clones a fresh child VM per call and discards its stdout —
182    /// fine for the agent-loop boundaries where hooks are advisory side-
183    /// channels, but the pipeline-finish boundary is the script's last
184    /// chance to print before `vm.output()` is captured, so the closures
185    /// run on `self` to keep their output visible.
186    ///
187    /// Honors the lifecycle control contract (harn#1859):
188    ///   * `PreFinish` rejects `Block` outright — surfaces a runtime
189    ///     error pointing the user at `OnFinish.block_until_settled`.
190    ///     `PostFinish` ignores any control return (advisory only).
191    ///   * `OnUnsettledDetected` honors `Block` to abort the finish
192    ///     lifecycle until the unsettled work clears.
193    ///   * Modify returns are recorded but not consumed at this boundary
194    ///     (the dispatcher already replays subsequent hooks with the
195    ///     post-modify payload via `run_lifecycle_hooks_with_control`).
196    async fn fire_finish_lifecycle_event(
197        &mut self,
198        event: crate::orchestration::HookEvent,
199        payload: &serde_json::Value,
200    ) -> Result<(), VmError> {
201        use crate::orchestration::{HookControl, HookEvent};
202        let invocations = crate::orchestration::matching_vm_lifecycle_hooks(event, payload);
203        if invocations.is_empty() {
204            return Ok(());
205        }
206        let harness = self.root_harness_value().ok_or_else(|| {
207            VmError::Runtime(
208                "pipeline lifecycle hook requires Harness, but no root Harness is installed"
209                    .to_string(),
210            )
211        })?;
212        let mut current_payload = payload.clone();
213        for invocation in invocations {
214            let arg = crate::stdlib::json_to_vm_value(&current_payload);
215            let closure = invocation.resolve(self).await?;
216            let raw = self
217                .call_closure_pub(&closure, &[harness.clone(), arg])
218                .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: crate::value::HarnStr::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_depth = self.frames.len();
420            let frame = match self.frames.last_mut() {
421                Some(f) => f,
422                None => return Ok(self.stack.pop().unwrap_or(VmValue::Nil)),
423            };
424
425            if frame.ip >= frame.chunk.code.len() {
426                let val = self.stack.pop().unwrap_or(VmValue::Nil);
427                let val = self.run_step_post_hooks_for_current_frame(val).await?;
428                self.release_sync_guards_for_frame(self.frames.len());
429                let popped_frame = self.frames.pop().unwrap();
430                if let Some(ref dir) = popped_frame.saved_source_dir {
431                    crate::stdlib::set_thread_source_dir(dir);
432                }
433                let current_depth = self.frames.len();
434                crate::step_runtime::prune_below_frame(current_depth);
435                // Drop any deadlines owned by the popped frame so the
436                // caller doesn't inherit them (an early `return` from
437                // inside `deadline(d) { ... }` would otherwise leave the
438                // deadline live across the function boundary).
439                while self.deadlines.last().is_some_and(|d| d.1 > current_depth) {
440                    self.deadlines.pop();
441                }
442
443                let reached_target = current_depth <= target_depth;
444                if reached_target && !restore_on_final_pop {
445                    // Entrypoint mode: leave env / iterators / stack in place
446                    // so the caller can observe the script's top-level scope.
447                    return Ok(val);
448                }
449                self.iterators.truncate(popped_frame.saved_iterator_depth);
450                self.env = popped_frame.saved_env;
451                self.stack.truncate(popped_frame.stack_base);
452                if reached_target {
453                    return Ok(val);
454                }
455                self.stack.push(val);
456                continue;
457            }
458
459            let op_byte = frame.chunk.code[frame.ip];
460            // Line-coverage hit. `self.coverage` is `None` unless a coverage
461            // session is active, so this is a single predictable branch on the
462            // hot path (and a disjoint-field borrow from `frame`, which holds
463            // `self.frames`). `frame.ip` is still the index of the instruction
464            // we just read, before the increment below.
465            if let Some(coverage) = self.coverage.as_mut() {
466                coverage.record(&frame.chunk, frame.ip);
467            }
468            frame.ip += 1;
469
470            // Sync/async split dispatch: sync opcodes stay on the direct hot
471            // path even while a host deadline is armed. The instruction-boundary
472            // check above makes CPU-bound code interruptible without paying for
473            // a future and `tokio::select!` on every arithmetic/local opcode.
474            let op = match Op::from_byte(op_byte) {
475                Some(op) => op,
476                None => return Err(VmError::InvalidInstruction(op_byte)),
477            };
478            if let Some(recorder) = self.flight_recorder.as_ref() {
479                recorder.record_instruction(
480                    &self.runtime_context.task_id,
481                    frame_depth,
482                    frame.fn_name.as_str(),
483                    &frame.chunk,
484                    self.source_file.as_deref(),
485                    frame.ip - 1,
486                    op,
487                );
488            }
489            let op_result: Result<(), VmError> = if let Some(result) = self.execute_op_sync(op) {
490                result
491            } else if self.scope_interrupts_clean() {
492                self.execute_op_async(op).await
493            } else {
494                match self.execute_op_with_scope_interrupts(op_byte).await {
495                    Ok(Some(val)) => return Ok(val),
496                    Ok(None) => Ok(()),
497                    Err(e) => Err(e),
498                }
499            };
500
501            match op_result {
502                Ok(()) => continue,
503                Err(VmError::Return(val)) => {
504                    let val = self.run_step_post_hooks_for_current_frame(val).await?;
505                    if let Some(popped_frame) = self.frames.pop() {
506                        self.release_sync_guards_for_frame(self.frames.len() + 1);
507                        if let Some(ref dir) = popped_frame.saved_source_dir {
508                            crate::stdlib::set_thread_source_dir(dir);
509                        }
510                        let current_depth = self.frames.len();
511                        self.exception_handlers
512                            .retain(|h| h.frame_depth <= current_depth);
513                        crate::step_runtime::prune_below_frame(current_depth);
514                        while self.deadlines.last().is_some_and(|d| d.1 > current_depth) {
515                            self.deadlines.pop();
516                        }
517
518                        let reached_target = current_depth <= target_depth;
519                        if reached_target && !restore_on_final_pop {
520                            return Ok(val);
521                        }
522                        self.iterators.truncate(popped_frame.saved_iterator_depth);
523                        self.env = popped_frame.saved_env;
524                        self.stack.truncate(popped_frame.stack_base);
525                        if reached_target {
526                            return Ok(val);
527                        }
528                        self.stack.push(val);
529                    } else {
530                        return Ok(val);
531                    }
532                }
533                Err(e) => {
534                    // Capture stack trace before error handling unwinds frames.
535                    if self.error_stack_trace.is_empty() {
536                        self.error_stack_trace = self.capture_stack_trace();
537                    }
538                    // Honor `@step(error_boundary: ...)` if a step-budget
539                    // exhaustion error is propagating out of the step's
540                    // own frame. `continue` swaps the throw for a Nil
541                    // return; `escalate` re-tags the error as a handoff
542                    // escalation and lets the existing exception
543                    // handlers route it.
544                    let e = match self.apply_step_error_boundary(e) {
545                        StepBoundaryOutcome::Returned(val) => {
546                            self.error_stack_trace.clear();
547                            if self.frames.len() <= target_depth {
548                                return Ok(val);
549                            }
550                            self.stack.push(val);
551                            continue;
552                        }
553                        StepBoundaryOutcome::Throw(err) => err,
554                    };
555                    match self.handle_error(e) {
556                        Ok(None) => {
557                            self.error_stack_trace.clear();
558                            continue;
559                        }
560                        Ok(Some(val)) => return Ok(val),
561                        Err(e) => {
562                            self.unwind_frames_to_depth(target_depth);
563                            return Err(self.enrich_error_with_line(e));
564                        }
565                    }
566                }
567            }
568        }
569    }
570
571    /// Pop frames until `self.frames.len() <= target_depth`, restoring env,
572    /// iterators, stack, source-dir thread-locals, and releasing per-frame
573    /// sync guards for each popped frame. Used by [`drive_until_frame_depth`]
574    /// on the error path so a closure sub-execution leaves caller-visible
575    /// state at the same depth it found when an unhandled error propagates
576    /// out.
577    fn unwind_frames_to_depth(&mut self, target_depth: usize) {
578        while self.frames.len() > target_depth {
579            let frame_depth = self.frames.len();
580            if let Some(frame) = self.frames.pop() {
581                self.release_sync_guards_for_frame(frame_depth);
582                if let Some(ref dir) = frame.saved_source_dir {
583                    crate::stdlib::set_thread_source_dir(dir);
584                }
585                self.iterators.truncate(frame.saved_iterator_depth);
586                self.env = frame.saved_env;
587                self.stack.truncate(frame.stack_base);
588            }
589        }
590        let current_depth = self.frames.len();
591        crate::step_runtime::prune_below_frame(current_depth);
592        while self.deadlines.last().is_some_and(|d| d.1 > current_depth) {
593            self.deadlines.pop();
594        }
595    }
596
597    /// Inspect a thrown error against the topmost active step's
598    /// `error_boundary`. Called from the main step loop before
599    /// `handle_error` so that a step's own budget-exhaustion error can be
600    /// short-circuited (`continue`) or annotated (`escalate`) before the
601    /// generic try/catch machinery sees it.
602    pub(crate) fn apply_step_error_boundary(&mut self, error: VmError) -> StepBoundaryOutcome {
603        use crate::step_runtime;
604        if !step_runtime::is_step_budget_exhausted(&error) {
605            return StepBoundaryOutcome::Throw(error);
606        }
607        let Some(step_depth) = step_runtime::active_step_frame_depth() else {
608            return StepBoundaryOutcome::Throw(error);
609        };
610        // The step's frame is the topmost on the call stack iff its
611        // recorded frame_depth equals `frames.len()`. If the throw is
612        // coming from a deeper frame we let it bubble up — the boundary
613        // still applies later when the step's own frame is reached.
614        if step_depth != self.frames.len() {
615            return StepBoundaryOutcome::Throw(error);
616        }
617        let boundary = step_runtime::with_active_step(|step| step.definition.boundary())
618            .unwrap_or(step_runtime::StepErrorBoundary::Fail);
619        match boundary {
620            step_runtime::StepErrorBoundary::Continue => {
621                // Mimic VmError::Return(Nil) for the step's frame: pop
622                // the frame, restore its env/iterators/stack, and feed a
623                // Nil return value back to the caller.
624                if let Some(popped) = self.frames.pop() {
625                    self.release_sync_guards_for_frame(self.frames.len() + 1);
626                    if let Some(ref dir) = popped.saved_source_dir {
627                        crate::stdlib::set_thread_source_dir(dir);
628                    }
629                    let current_depth = self.frames.len();
630                    self.exception_handlers
631                        .retain(|h| h.frame_depth <= current_depth);
632                    step_runtime::pop_and_record(
633                        current_depth + 1,
634                        "skipped",
635                        Some(step_runtime_error_message(&error)),
636                    );
637                    if self.frames.is_empty() {
638                        return StepBoundaryOutcome::Returned(VmValue::Nil);
639                    }
640                    self.iterators.truncate(popped.saved_iterator_depth);
641                    self.env = popped.saved_env;
642                    self.stack.truncate(popped.stack_base);
643                }
644                StepBoundaryOutcome::Returned(VmValue::Nil)
645            }
646            step_runtime::StepErrorBoundary::Escalate => {
647                let identity = step_runtime::with_active_step(|step| {
648                    (
649                        step.definition.name.clone(),
650                        step.definition.function.clone(),
651                    )
652                });
653                step_runtime::pop_and_record(
654                    step_depth,
655                    "escalated",
656                    Some(step_runtime_error_message(&error)),
657                );
658                let (step_name, function) = identity.unzip();
659                StepBoundaryOutcome::Throw(step_runtime::mark_escalated(
660                    error,
661                    step_name.as_deref(),
662                    function.as_deref(),
663                ))
664            }
665            step_runtime::StepErrorBoundary::Fail => {
666                step_runtime::pop_and_record(
667                    step_depth,
668                    "failed",
669                    Some(step_runtime_error_message(&error)),
670                );
671                StepBoundaryOutcome::Throw(error)
672            }
673        }
674    }
675}
676
677fn next_deadline(
678    execution_deadline: Option<Instant>,
679    scope_deadline: Option<Instant>,
680    interrupt_handler_deadline: Option<Instant>,
681) -> (Option<Instant>, Option<DeadlineKind>) {
682    [
683        (execution_deadline, DeadlineKind::Execution),
684        (scope_deadline, DeadlineKind::Scope),
685        (interrupt_handler_deadline, DeadlineKind::InterruptHandler),
686    ]
687    .into_iter()
688    .filter_map(|(deadline, kind)| deadline.map(|deadline| (deadline, kind)))
689    .min_by_key(|(deadline, _)| *deadline)
690    .map_or((None, None), |(deadline, kind)| {
691        (Some(deadline), Some(kind))
692    })
693}
694
695fn step_runtime_error_message(error: &VmError) -> String {
696    match error {
697        VmError::Thrown(VmValue::Dict(dict)) => dict
698            .get("message")
699            .map(|v| v.display())
700            .unwrap_or_else(|| error.to_string()),
701        _ => error.to_string(),
702    }
703}
704
705pub(crate) enum StepBoundaryOutcome {
706    Returned(VmValue),
707    Throw(VmError),
708}
709
710impl crate::vm::Vm {
711    pub(crate) async fn execute_one_cycle(&mut self) -> Result<Option<(VmValue, bool)>, VmError> {
712        if let Some(err) = self.pending_scope_interrupt().await {
713            match self.handle_error(err) {
714                Ok(None) => return Ok(None),
715                Ok(Some(val)) => return Ok(Some((val, false))),
716                Err(e) => return Err(e),
717            }
718        }
719
720        let frame_depth = self.frames.len();
721        let frame = match self.frames.last_mut() {
722            Some(f) => f,
723            None => {
724                let val = self.stack.pop().unwrap_or(VmValue::Nil);
725                return Ok(Some((val, false)));
726            }
727        };
728
729        if frame.ip >= frame.chunk.code.len() {
730            let val = self.stack.pop().unwrap_or(VmValue::Nil);
731            self.release_sync_guards_for_frame(self.frames.len());
732            let popped_frame = self.frames.pop().unwrap();
733            if self.frames.is_empty() {
734                return Ok(Some((val, false)));
735            }
736            self.iterators.truncate(popped_frame.saved_iterator_depth);
737            self.env = popped_frame.saved_env;
738            self.stack.truncate(popped_frame.stack_base);
739            self.stack.push(val);
740            return Ok(None);
741        }
742
743        let op_offset = frame.ip;
744        let op = frame.chunk.code[op_offset];
745        frame.ip += 1;
746
747        if let (Some(recorder), Some(decoded)) = (self.flight_recorder.as_ref(), Op::from_byte(op))
748        {
749            recorder.record_instruction(
750                &self.runtime_context.task_id,
751                frame_depth,
752                frame.fn_name.as_str(),
753                &frame.chunk,
754                self.source_file.as_deref(),
755                op_offset,
756                decoded,
757            );
758        }
759
760        match self.execute_op_with_scope_interrupts(op).await {
761            Ok(Some(val)) => Ok(Some((val, false))),
762            Ok(None) => Ok(None),
763            Err(VmError::Return(val)) => {
764                if let Some(popped_frame) = self.frames.pop() {
765                    self.release_sync_guards_for_frame(self.frames.len() + 1);
766                    if let Some(ref dir) = popped_frame.saved_source_dir {
767                        crate::stdlib::set_thread_source_dir(dir);
768                    }
769                    let current_depth = self.frames.len();
770                    self.exception_handlers
771                        .retain(|h| h.frame_depth <= current_depth);
772                    if self.frames.is_empty() {
773                        return Ok(Some((val, false)));
774                    }
775                    self.iterators.truncate(popped_frame.saved_iterator_depth);
776                    self.env = popped_frame.saved_env;
777                    self.stack.truncate(popped_frame.stack_base);
778                    self.stack.push(val);
779                    Ok(None)
780                } else {
781                    Ok(Some((val, false)))
782                }
783            }
784            Err(e) => {
785                if self.error_stack_trace.is_empty() {
786                    self.error_stack_trace = self.capture_stack_trace();
787                }
788                match self.handle_error(e) {
789                    Ok(None) => {
790                        self.error_stack_trace.clear();
791                        Ok(None)
792                    }
793                    Ok(Some(val)) => Ok(Some((val, false))),
794                    Err(e) => Err(self.enrich_error_with_line(e)),
795                }
796            }
797        }
798    }
799
800    async fn execute_op_with_scope_interrupts(
801        &mut self,
802        op: u8,
803    ) -> Result<Option<VmValue>, VmError> {
804        #[cfg(test)]
805        SCOPE_INTERRUPT_ASYNC_DISPATCHES
806            .set(SCOPE_INTERRUPT_ASYNC_DISPATCHES.get().saturating_add(1));
807
808        enum ScopeInterruptResult {
809            Op(Result<Option<VmValue>, VmError>),
810            Deadline(DeadlineKind),
811            CancelTimedOut,
812        }
813
814        let execution_deadline = Arc::clone(&self.execution_deadline);
815        let scope_deadline = self.deadlines.last().map(|(deadline, _)| *deadline);
816        let interrupt_handler_deadline = self.interrupt_handler_deadline;
817        let cancel_token = self.cancel_token.clone();
818
819        let has_deadline = execution_deadline.is_active()
820            || scope_deadline.is_some()
821            || interrupt_handler_deadline.is_some();
822        if !has_deadline && cancel_token.is_none() {
823            return self.execute_op(op).await;
824        }
825
826        let cancel_requested_at_start = cancel_token
827            .as_ref()
828            .is_some_and(|token| token.load(std::sync::atomic::Ordering::SeqCst));
829        let has_cancel = cancel_token.is_some() && !cancel_requested_at_start;
830        let deadline_sleep = async move {
831            loop {
832                // Register before reading state so a pause/resume between the
833                // read and await cannot strand the deadline waiter.
834                let changed = execution_deadline.changed();
835                let (deadline, kind) = next_deadline(
836                    execution_deadline.current(),
837                    scope_deadline,
838                    interrupt_handler_deadline,
839                );
840                if let Some(deadline) = deadline {
841                    tokio::select! {
842                        _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {
843                            return kind.unwrap_or(DeadlineKind::Scope);
844                        }
845                        _ = changed => {}
846                    }
847                } else {
848                    changed.await;
849                }
850            }
851        };
852        let cancel_sleep = async move {
853            if let Some(token) = cancel_token {
854                while !token.load(std::sync::atomic::Ordering::SeqCst) {
855                    tokio::time::sleep(Duration::from_millis(10)).await;
856                }
857            } else {
858                std::future::pending::<()>().await;
859            }
860        };
861
862        let result = {
863            let op_future = self.execute_op(op);
864            tokio::pin!(op_future);
865            tokio::select! {
866                result = &mut op_future => ScopeInterruptResult::Op(result),
867                kind = deadline_sleep, if has_deadline => {
868                    ScopeInterruptResult::Deadline(kind)
869                },
870                _ = cancel_sleep, if has_cancel => {
871                    let grace = tokio::time::sleep(CANCEL_GRACE_ASYNC_OP);
872                    tokio::pin!(grace);
873                    tokio::select! {
874                        result = &mut op_future => ScopeInterruptResult::Op(result),
875                        _ = &mut grace => ScopeInterruptResult::CancelTimedOut,
876                    }
877                }
878            }
879        };
880
881        match result {
882            ScopeInterruptResult::Op(result) => result,
883            ScopeInterruptResult::Deadline(DeadlineKind::Execution) => {
884                self.cancel_spawned_tasks();
885                Err(VmError::ExecutionDeadlineExceeded)
886            }
887            ScopeInterruptResult::Deadline(DeadlineKind::Scope) => {
888                self.deadlines.pop();
889                self.cancel_spawned_tasks();
890                Err(Self::deadline_exceeded_error())
891            }
892            ScopeInterruptResult::Deadline(DeadlineKind::InterruptHandler) => {
893                Err(Self::interrupt_handler_timeout_error())
894            }
895            ScopeInterruptResult::CancelTimedOut => {
896                self.cancel_spawned_tasks();
897                let signal = self
898                    .take_host_interrupt_signal()
899                    .unwrap_or_else(|| "SIGINT".to_string());
900                if self.has_interrupt_handler_for(&signal) {
901                    self.dispatch_interrupt_handlers(&signal).await?;
902                }
903                Err(Self::cancelled_error())
904            }
905        }
906    }
907
908    pub(crate) fn deadline_exceeded_error() -> VmError {
909        VmError::Thrown(VmValue::String(arcstr::ArcStr::from("Deadline exceeded")))
910    }
911
912    pub(crate) fn cancelled_error() -> VmError {
913        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
914            "kind:cancelled:VM cancelled by host",
915        )))
916    }
917
918    /// Capture the current call stack as (fn_name, line, col, source_file) tuples.
919    pub(crate) fn capture_stack_trace(&self) -> Vec<(String, usize, usize, Option<String>)> {
920        self.frames
921            .iter()
922            .map(|f| {
923                let idx = if f.ip > 0 { f.ip - 1 } else { 0 };
924                let line = f.chunk.lines.get(idx).copied().unwrap_or(0) as usize;
925                let col = f.chunk.columns.get(idx).copied().unwrap_or(0) as usize;
926                (
927                    f.fn_name.to_string(),
928                    line,
929                    col,
930                    f.chunk.source_file.clone(),
931                )
932            })
933            .collect()
934    }
935
936    /// Enrich a VmError with source line information from the captured stack
937    /// trace. Appends ` (line N)` to error variants whose messages don't
938    /// already carry location context.
939    pub(crate) fn enrich_error_with_line(&self, error: VmError) -> VmError {
940        // Determine the line AND source file from the captured stack trace
941        // (innermost frame) so the error names the exact `.harn` it crashed in.
942        // A bare `(line N)` is ambiguous across 100+ stdlib files and forces a
943        // manual hunt; `(stall.harn:497)` pinpoints it immediately.
944        let (line, file) = self
945            .error_stack_trace
946            .last()
947            .map(|(_, l, _, f)| (*l, f.clone()))
948            .unwrap_or_else(|| (self.current_line(), None));
949        if line == 0 {
950            return error;
951        }
952        let suffix = match file.as_deref() {
953            Some(path) => {
954                let name = std::path::Path::new(path)
955                    .file_name()
956                    .and_then(|n| n.to_str())
957                    .unwrap_or(path);
958                format!(" ({name}:{line})")
959            }
960            None => format!(" (line {line})"),
961        };
962        match error {
963            VmError::Runtime(msg) => VmError::Runtime(format!("{msg}{suffix}")),
964            VmError::TypeError(msg) => VmError::TypeError(format!("{msg}{suffix}")),
965            VmError::DivisionByZero => VmError::Runtime(format!("Division by zero{suffix}")),
966            VmError::UndefinedVariable(name) => {
967                VmError::Runtime(format!("Undefined variable: {name}{suffix}"))
968            }
969            VmError::UndefinedBuiltin(name) => {
970                VmError::Runtime(format!("Undefined builtin: {name}{suffix}"))
971            }
972            VmError::ImmutableAssignment(name) => VmError::Runtime(format!(
973                "Cannot assign to immutable binding: {name}{suffix}"
974            )),
975            VmError::StackOverflow => {
976                VmError::Runtime(format!("Stack overflow: too many nested calls{suffix}"))
977            }
978            // Leave these untouched:
979            // - Thrown: user-thrown errors should not be silently modified
980            // - CategorizedError: structured errors for agent orchestration
981            // - Return / ProcessExit: control flow, not a real error
982            // - StackUnderflow / InvalidInstruction: internal VM bugs
983            other => other,
984        }
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use crate::compiler::Compiler;
992    use crate::stdlib::register_vm_stdlib;
993    use harn_lexer::Lexer;
994    use harn_parser::Parser;
995    use std::sync::atomic::{AtomicBool, Ordering};
996
997    fn compile_harn(source: &str) -> Chunk {
998        let mut lexer = Lexer::new(source);
999        let tokens = lexer.tokenize().unwrap();
1000        let mut parser = Parser::new(tokens);
1001        let program = parser.parse().unwrap();
1002        Compiler::new().compile(&program).unwrap()
1003    }
1004
1005    #[tokio::test(flavor = "current_thread")]
1006    async fn dropping_timed_execution_restores_ambient_state_and_poison_vm_reuse() {
1007        let local = tokio::task::LocalSet::new();
1008        local
1009            .run_until(async {
1010                crate::reset_thread_local_state();
1011                let baseline_dir = tempfile::tempdir().unwrap();
1012                let imported_dir = tempfile::tempdir().unwrap();
1013                let poisoned_dir = tempfile::tempdir().unwrap();
1014                let quick = compile_harn("pipeline default(harness: Harness) { return 42 }");
1015                let mut vm = Vm::new();
1016                register_vm_stdlib(&mut vm);
1017                crate::tracing::set_tracing_enabled(true);
1018
1019                let child_started = Arc::new(AtomicBool::new(false));
1020                let child_effect = Arc::new(AtomicBool::new(false));
1021                let child_release = Arc::new(tokio::sync::Notify::new());
1022                let (ambient_started_tx, ambient_started_rx) = tokio::sync::oneshot::channel();
1023                let ambient_started_tx = Arc::new(std::sync::Mutex::new(Some(ambient_started_tx)));
1024                let started_for_builtin = Arc::clone(&child_started);
1025                vm.register_builtin("child_started", move |_args, _output| {
1026                    started_for_builtin.store(true, Ordering::Release);
1027                    Ok(VmValue::Nil)
1028                });
1029                let effect_for_builtin = Arc::clone(&child_effect);
1030                vm.register_builtin("child_effect", move |_args, _output| {
1031                    effect_for_builtin.store(true, Ordering::Release);
1032                    Ok(VmValue::Nil)
1033                });
1034                let release_for_builtin = Arc::clone(&child_release);
1035                vm.register_async_builtin("wait_for_child_release", move |_ctx, _args| {
1036                    let release = Arc::clone(&release_for_builtin);
1037                    async move {
1038                        release.notified().await;
1039                        Ok(VmValue::Nil)
1040                    }
1041                });
1042                vm.register_async_builtin("wait_forever", |_ctx, _args| async move {
1043                    std::future::pending::<()>().await;
1044                    Ok(VmValue::Nil)
1045                });
1046
1047                let imported_path = imported_dir.path().join("cancelled.harn");
1048                let imported_source_dir = imported_dir.path().to_path_buf();
1049                let poisoned_source_dir = poisoned_dir.path().to_path_buf();
1050                let ambient_started_for_builtin = Arc::clone(&ambient_started_tx);
1051                vm.register_builtin("strand_ambient_state", move |_args, _output| {
1052                    crate::step_runtime::register_persona(
1053                        "cancellation_entry",
1054                        crate::step_runtime::PersonaDefinition {
1055                            name: "cancel_persona".into(),
1056                            stages: vec![crate::personas::StageDecl {
1057                                name: "cancel_step".into(),
1058                                allowed_tools: Some(vec!["cancel_tool".into()]),
1059                                ..Default::default()
1060                            }],
1061                            ..Default::default()
1062                        },
1063                    );
1064                    crate::step_runtime::register_step(
1065                        "cancelled_step",
1066                        crate::step_runtime::StepDefinition {
1067                            name: "cancel_step".into(),
1068                            function: "cancelled_step".into(),
1069                            model: Some("cancel-model".into()),
1070                            ..Default::default()
1071                        },
1072                    );
1073                    assert!(crate::step_runtime::maybe_push_active_persona(
1074                        "cancellation_entry",
1075                        1
1076                    ));
1077                    assert!(crate::step_runtime::maybe_push_active_step(
1078                        "cancelled_step",
1079                        2,
1080                        &[]
1081                    ));
1082                    assert_eq!(
1083                        crate::stdlib::process::source_root_path(),
1084                        imported_source_dir
1085                    );
1086                    assert_eq!(
1087                        crate::step_runtime::current_persona_name().as_deref(),
1088                        Some("cancel_persona")
1089                    );
1090                    assert_eq!(
1091                        crate::step_runtime::active_step_model_default().as_deref(),
1092                        Some("cancel-model")
1093                    );
1094                    assert_eq!(
1095                        crate::orchestration::current_execution_policy()
1096                            .unwrap()
1097                            .tools,
1098                        vec!["cancel_tool"]
1099                    );
1100                    crate::stdlib::process::set_thread_source_dir(&poisoned_source_dir);
1101                    crate::stdlib::process::set_thread_execution_context(Some(
1102                        crate::orchestration::RunExecutionRecord {
1103                            adapter: Some("cancelled".into()),
1104                            ..Default::default()
1105                        },
1106                    ));
1107                    crate::orchestration::push_approval_policy(
1108                        crate::orchestration::ToolApprovalPolicy {
1109                            auto_deny: vec!["cancel_tool".into()],
1110                            ..Default::default()
1111                        },
1112                    );
1113                    if let Some(sender) = ambient_started_for_builtin.lock().unwrap().take() {
1114                        let _ = sender.send(());
1115                    }
1116                    Ok(VmValue::Nil)
1117                });
1118
1119                std::fs::write(
1120                    &imported_path,
1121                    r#"
1122@persona(name: "cancel_persona", stages: [{name: "cancel_step", allowed_tools: ["cancel_tool"]}])
1123pub fn cancellation_entry(agent: HarnessAgent) {
1124  return cancelled_step(agent)
1125}
1126
1127@step(name: "cancel_step", model: "cancel-model")
1128fn cancelled_step(agent: HarnessAgent) {
1129  agent.pipeline_on_finish({ _h, value -> value })
1130  const child = spawn {
1131    child_started()
1132    wait_for_child_release()
1133    child_effect()
1134  }
1135  strand_ambient_state()
1136  wait_forever()
1137}
1138"#,
1139                )
1140                .unwrap();
1141                let mut helper_exports = vm
1142                    .load_module_exports_from_source(
1143                        "<cancellation-helper>",
1144                        "pub fn outer_callback(_h, value) { return value }\n\
1145                         pub fn answer() { return 42 }",
1146                    )
1147                    .await
1148                    .unwrap();
1149                let callable = helper_exports.remove("answer").unwrap();
1150                let outer_callback = helper_exports.remove("outer_callback").unwrap();
1151                let slow = compile_harn(&format!(
1152                    "import {{ cancellation_entry }} from \"{}\"\n\
1153                     pipeline default(harness: Harness) {{ return cancellation_entry(harness.agent) }}",
1154                    imported_path.display()
1155                ));
1156
1157                let baseline_execution = crate::orchestration::RunExecutionRecord {
1158                    cwd: Some(baseline_dir.path().display().to_string()),
1159                    source_dir: Some(baseline_dir.path().display().to_string()),
1160                    adapter: Some("baseline".into()),
1161                    ..Default::default()
1162                };
1163                let baseline_policy = crate::orchestration::CapabilityPolicy {
1164                    tools: vec!["baseline_tool".into(), "cancel_tool".into()],
1165                    ..Default::default()
1166                };
1167                let baseline_approval = crate::orchestration::ToolApprovalPolicy {
1168                    auto_approve: vec!["baseline_tool".into()],
1169                    ..Default::default()
1170                };
1171                crate::stdlib::process::set_thread_source_dir(baseline_dir.path());
1172                crate::stdlib::process::set_thread_execution_context(Some(
1173                    baseline_execution.clone(),
1174                ));
1175                crate::orchestration::push_execution_policy(baseline_policy.clone());
1176                crate::orchestration::push_approval_policy(baseline_approval.clone());
1177                crate::orchestration::set_pipeline_on_finish(Arc::clone(&outer_callback));
1178                let outer_span =
1179                    crate::tracing::span_start(crate::tracing::SpanKind::Pipeline, "outer".into());
1180
1181                let mut execution =
1182                    Box::pin(vm.execute_with_timeout(&slow, Duration::from_secs(30)));
1183                tokio::select! {
1184                    biased;
1185                    result = &mut execution => panic!("slow execution unexpectedly finished: {result:?}"),
1186                    started = async {
1187                        ambient_started_rx.await.expect("step did not reach cancellation point");
1188                        while !child_started.load(Ordering::Acquire) {
1189                            tokio::task::yield_now().await;
1190                        }
1191                    } => started,
1192                }
1193                drop(execution);
1194
1195                assert!(!vm.execution_deadline.is_active());
1196                assert!(!vm.frames.is_empty(), "fixture must abandon a live frame");
1197                assert_eq!(
1198                    crate::stdlib::process::source_root_path(),
1199                    baseline_dir.path()
1200                );
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                assert_eq!(crate::tracing::current_span_id(), Some(outer_span));
1216                let restored_callback =
1217                    crate::orchestration::take_pipeline_on_finish().unwrap();
1218                assert!(Arc::ptr_eq(&restored_callback, &outer_callback));
1219                crate::orchestration::set_pipeline_on_finish(restored_callback);
1220                let abandoned_spans = crate::tracing::peek_spans();
1221                assert!(abandoned_spans.iter().any(|span| {
1222                    span.name == "cancel_step"
1223                        && span.metadata.get("status") == Some(&serde_json::json!("abandoned"))
1224                }));
1225                assert!(abandoned_spans.iter().any(|span| {
1226                    span.name == "main"
1227                        && span.metadata.get("status") == Some(&serde_json::json!("abandoned"))
1228                }));
1229
1230                let frame_depth = vm.frames.len();
1231                let output = vm.output().to_string();
1232                let error = vm.execute(&quick).await.unwrap_err();
1233                assert!(matches!(error, VmError::AbandonedExecution));
1234                let closure_error = vm.call_closure_pub(&callable, &[]).await.unwrap_err();
1235                assert!(matches!(closure_error, VmError::AbandonedExecution));
1236                let source_cache_len = vm.source_cache.len();
1237                let module_cache_len = vm.module_cache.len();
1238                let module_error = vm
1239                    .load_module_exports_from_source(
1240                        "<poisoned-module-load>",
1241                        "pub fn poisoned() { return 0 }",
1242                    )
1243                    .await
1244                    .unwrap_err();
1245                assert!(matches!(module_error, VmError::AbandonedExecution));
1246                assert_eq!(vm.source_cache.len(), source_cache_len);
1247                assert_eq!(vm.module_cache.len(), module_cache_len);
1248                let start_error = vm.start(&quick).unwrap_err();
1249                assert!(matches!(start_error, VmError::AbandonedExecution));
1250                let restart_error = vm.restart_frame(0).unwrap_err();
1251                assert!(matches!(restart_error, VmError::AbandonedExecution));
1252                assert_eq!(vm.frames.len(), frame_depth);
1253                assert_eq!(vm.output(), output);
1254
1255                let mut vm_b = Vm::new();
1256                register_vm_stdlib(&mut vm_b);
1257                let observed_callback = Arc::clone(&outer_callback);
1258                let observed_dir = baseline_dir.path().to_path_buf();
1259                vm_b.register_builtin("observe_baseline", move |_args, _output| {
1260                    assert_eq!(crate::stdlib::process::source_root_path(), observed_dir);
1261                    assert_eq!(
1262                        crate::stdlib::process::current_execution_context(),
1263                        Some(baseline_execution.clone())
1264                    );
1265                    assert_eq!(
1266                        crate::orchestration::current_execution_policy(),
1267                        Some(baseline_policy.clone())
1268                    );
1269                    assert_eq!(
1270                        crate::orchestration::current_approval_policy(),
1271                        Some(baseline_approval.clone())
1272                    );
1273                    assert!(crate::step_runtime::current_persona_name().is_none());
1274                    assert!(crate::step_runtime::active_step_model_default().is_none());
1275                    let callback = crate::orchestration::take_pipeline_on_finish().unwrap();
1276                    assert!(Arc::ptr_eq(&callback, &observed_callback));
1277                    crate::orchestration::set_pipeline_on_finish(callback);
1278                    Ok(VmValue::Nil)
1279                });
1280                let vm_b_chunk =
1281                    compile_harn("pipeline default(harness: Harness) { observe_baseline(); return 42 }");
1282                assert!(matches!(
1283                    vm_b.execute(&vm_b_chunk).await.unwrap(),
1284                    VmValue::Int(42)
1285                ));
1286                assert_eq!(crate::tracing::current_span_id(), Some(outer_span));
1287
1288                drop(vm);
1289                child_release.notify_one();
1290                for _ in 0..10 {
1291                    tokio::task::yield_now().await;
1292                }
1293                assert!(
1294                    !child_effect.load(Ordering::Acquire),
1295                    "dropping an abandoned VM must abort spawned side effects"
1296                );
1297                crate::reset_thread_local_state();
1298            })
1299            .await;
1300    }
1301
1302    #[tokio::test(flavor = "current_thread")]
1303    async fn natural_host_deadline_is_terminal_not_abandoned() {
1304        let local = tokio::task::LocalSet::new();
1305        local
1306            .run_until(async {
1307                let infinite = compile_harn("pipeline default(harness: Harness) { while true {} }");
1308                let quick = compile_harn("pipeline default(harness: Harness) { return 42 }");
1309                let mut vm = Vm::new();
1310                register_vm_stdlib(&mut vm);
1311
1312                let error = vm
1313                    .execute_with_timeout(&infinite, Duration::ZERO)
1314                    .await
1315                    .unwrap_err();
1316                assert!(matches!(error, VmError::ExecutionDeadlineExceeded));
1317                assert!(!vm.execution_deadline.is_abandoned());
1318                assert!(matches!(
1319                    vm.execute(&quick).await.unwrap(),
1320                    VmValue::Int(42)
1321                ));
1322            })
1323            .await;
1324    }
1325
1326    #[tokio::test(flavor = "current_thread")]
1327    async fn host_admission_extends_execution_deadline_by_injected_clock_delta() {
1328        let chunk = compile_harn(
1329            r"
1330pipeline default(harness: Harness) {
1331  wait_for_admission()
1332  return 42
1333}
1334",
1335        );
1336        let mut vm = Vm::new();
1337        register_vm_stdlib(&mut vm);
1338        let clock = harn_clock::PausedClock::new(time::OffsetDateTime::UNIX_EPOCH);
1339        let admission_clock: Arc<dyn harn_clock::Clock> = clock.clone();
1340        vm.register_async_builtin("wait_for_admission", move |ctx, _args| {
1341            let clock = Arc::clone(&clock);
1342            let admission_clock = Arc::clone(&admission_clock);
1343            async move {
1344                let before = ctx.execution_deadline_offset_for_test();
1345                let pause = ctx
1346                    .pause_execution_deadline(admission_clock)
1347                    .expect("timed execution exposes its outer deadline to inline host work");
1348                clock.advance(Duration::from_millis(25));
1349                drop(pause);
1350                let after = ctx.execution_deadline_offset_for_test();
1351                assert_eq!(after.saturating_sub(before), 25_000_000);
1352                Ok(VmValue::Nil)
1353            }
1354        });
1355
1356        let value = vm
1357            .execute_with_timeout(&chunk, Duration::from_secs(5))
1358            .await
1359            .expect("host admission extends rather than spends the execution budget");
1360        assert!(matches!(value, VmValue::Int(42)));
1361    }
1362
1363    #[tokio::test(flavor = "current_thread")]
1364    async fn timed_finite_loop_keeps_sync_opcodes_on_direct_dispatch() {
1365        let local = tokio::task::LocalSet::new();
1366        local
1367            .run_until(async {
1368                let chunk = compile_harn(
1369                    r"
1370pipeline default(harness: Harness) {
1371  let total = 0
1372  for i in 0 to 10000 {
1373    total = total + i
1374  }
1375  return total
1376}
1377",
1378                );
1379                let mut vm = Vm::new();
1380                register_vm_stdlib(&mut vm);
1381                reset_scope_interrupt_async_dispatches();
1382
1383                let value = vm
1384                    .execute_with_timeout(&chunk, Duration::from_secs(1))
1385                    .await
1386                    .unwrap();
1387
1388                assert!(matches!(value, VmValue::Int(_)));
1389                assert!(
1390                    scope_interrupt_async_dispatches() <= 4,
1391                    "finite sync loop fell back to per-op async dispatch"
1392                );
1393            })
1394            .await;
1395    }
1396}