Skip to main content

harn_vm/vm/
async_builtin.rs

1use std::future::Future;
2use std::sync::atomic::AtomicBool;
3use std::sync::Arc;
4use std::time::Instant;
5
6use super::Vm;
7
8/// Explicit handle to the parent VM's execution context for the duration of one
9/// async-builtin call. Threaded into every async builtin by the dispatch loop
10/// (and the `#[harn_builtin]` macro), so context can no longer be "lost across a
11/// spawn boundary": a handler that needs VM access receives or clones this
12/// handle deliberately instead of reading ambient state.
13///
14/// Holds the "template" child VM that closure-invoking host helpers clone via
15/// [`AsyncBuiltinCtx::child_vm`], and whose `output` buffer collects text
16/// forwarded from VM-side closures via [`AsyncBuiltinCtx::forward_output`]. The
17/// dispatch loop drains that buffer back to the original parent VM after the
18/// async builtin returns. Cheap to clone: it is an `Arc` handle and everything
19/// heavy inside the `Vm` is shared.
20#[derive(Clone)]
21pub struct AsyncBuiltinCtx {
22    child: Arc<parking_lot::Mutex<Vm>>,
23}
24
25impl AsyncBuiltinCtx {
26    fn new(vm: Vm) -> Self {
27        Self {
28            child: Arc::new(parking_lot::Mutex::new(vm)),
29        }
30    }
31
32    /// Construct a context around `vm` for host adapters that are not themselves
33    /// async builtins but need to run VM-side closures.
34    pub fn from_vm(vm: Vm) -> Self {
35        Self::new(vm)
36    }
37
38    /// Construct a context for host work that the current VM awaits inline.
39    ///
40    /// The parent is parked until the host future completes, so the context
41    /// must share its execution deadline and inherited lock set. Using a plain
42    /// `child_vm()` here would fork the outer deadline: admission could pause
43    /// the fork while the real caller still timed out.
44    pub(crate) fn from_inline_parent(parent: &Vm) -> Self {
45        Self::new(parent.child_vm_inline())
46    }
47
48    /// Construct a standalone ctx around `vm` for tests that drive an async
49    /// builtin directly. Reuse the fixture's active execution registries just
50    /// as the production dispatch scope would; production never calls this.
51    #[cfg(test)]
52    pub fn for_test(mut vm: Vm) -> Self {
53        vm.worker_registry = crate::stdlib::agents::agents_workers::active_worker_registry();
54        vm.daemon_registry = crate::stdlib::agents_daemon::active_daemon_registry();
55        vm.trigger_registry = crate::triggers::registry::active_trigger_registry();
56        vm.session_runtime = crate::agent_sessions::active_session_runtime();
57        vm.tracing_runtime = crate::tracing::active_tracing_runtime();
58        vm.agent_host_session_runtime =
59            crate::llm::agent_session_host::active_agent_host_session_runtime();
60        Self::new(vm)
61    }
62
63    /// Clone a fresh child VM from this context. The returned `Vm` shares the
64    /// parent's heavy state, so each closure-invoking handler gets its own
65    /// cheap execution context.
66    ///
67    /// Uses the *inline* clone: this child runs while the original parent is
68    /// parked awaiting the builtin, so it inherits the parent's held-lock keys
69    /// for cross-context self-deadlock detection (HARN-ORC-011). Long-lived /
70    /// detached contexts use [`AsyncBuiltinCtx::child_ctx`] instead, which does
71    /// not inherit, since the parent keeps running there.
72    pub fn child_vm(&self) -> Vm {
73        self.child.lock().child_vm_inline()
74    }
75
76    /// Harn-owned identity of the execution awaiting this builtin.
77    pub(crate) fn execution_id(&self) -> String {
78        self.child.lock().execution_id().to_string()
79    }
80
81    /// Runtime task that owns this builtin invocation.
82    pub(crate) fn task_id(&self) -> String {
83        self.child.lock().runtime_context.task_id.clone()
84    }
85
86    /// Pool tasks may execute on any Tokio worker thread, so pool lookup state
87    /// is shared through the VM context rather than thread-local storage.
88    pub(crate) fn pool_registry(&self) -> Arc<crate::stdlib::pool::PoolRegistry> {
89        self.child.lock().pool_registry.clone()
90    }
91
92    pub(crate) fn wait_for_graph(&self) -> Arc<crate::wait_for_graph::VmWaitForGraph> {
93        self.child.lock().wait_for_graph.clone()
94    }
95
96    pub(crate) fn package_snapshot_registry(&self) -> Arc<crate::stdlib::PackageSnapshotRegistry> {
97        self.child.lock().package_snapshot_registry.clone()
98    }
99
100    /// Resolve a connector client from this VM tree's eager projection or its
101    /// execution-owned lazy resolver. The runtime handle is cloned before the
102    /// await so no VM lock crosses user/provider initialization work.
103    pub(crate) async fn connector_client(
104        &self,
105        provider: &str,
106    ) -> Result<Option<Arc<dyn crate::connectors::ConnectorClient>>, crate::connectors::ClientError>
107    {
108        let runtime = self.child.lock().connector_clients.clone();
109        runtime.resolve(provider).await
110    }
111
112    /// Create an independent context rooted at a fresh child VM. Long-lived
113    /// local tasks use this instead of sharing the parent builtin's output
114    /// buffer after the parent future has returned.
115    ///
116    /// This is a *detached* context: the new task runs independently of the
117    /// original parent, so it must NOT inherit the parent's held-lock keys
118    /// (blocking on a parent-held lock is legitimately resolvable here). Uses
119    /// the plain, non-inheriting `child_vm()` rather than `Self::child_vm`.
120    pub fn child_ctx(&self) -> Self {
121        Self::new(self.child.lock().child_vm())
122    }
123
124    /// Forward captured output from a transient child VM (typically created via
125    /// [`AsyncBuiltinCtx::child_vm`] and used to invoke a closure) back into this
126    /// context's output buffer. The dispatch loop drains that buffer back to the
127    /// original parent VM after the async builtin returns.
128    ///
129    /// Without this hook, `harness.stdio.log()`/`__io_print()` calls inside
130    /// `post_turn_callback` closures, tool handlers, and other VM-side closures
131    /// invoked from async builtins would silently disappear because the transient
132    /// child VM's output buffer is dropped on scope exit.
133    pub fn forward_output(&self, text: &str) {
134        if text.is_empty() {
135            return;
136        }
137        self.child.lock().append_output(text);
138    }
139
140    /// Snapshot the cancellation and deadline sources for a host operation
141    /// that must move its blocking work to Tokio's blocking pool.
142    pub fn interrupt_sources(&self) -> (Option<Arc<AtomicBool>>, Option<Instant>) {
143        self.child.lock().interrupt_sources()
144    }
145
146    /// Pause the outer host execution rail while an embedder-owned resource is
147    /// not runnable. Catchable script deadlines remain unchanged.
148    pub(crate) fn pause_execution_deadline(
149        &self,
150        clock: Arc<dyn harn_clock::Clock>,
151    ) -> Option<super::state::ExecutionDeadlinePauseGuard> {
152        self.child.lock().execution_deadline.pause(clock)
153    }
154
155    #[cfg(test)]
156    pub(crate) fn execution_deadline_offset_for_test(&self) -> u64 {
157        self.child
158            .lock()
159            .execution_deadline
160            .encoded_offset_for_test()
161    }
162}
163
164/// Run an async builtin's future with `child` installed as its explicit
165/// [`AsyncBuiltinCtx`]. `make_fut` receives the ctx handle and returns the
166/// handler's future; the ctx is moved into the future, so it lives exactly as
167/// long as the call. Returns the future's output plus any output that VM-side
168/// closures forwarded into the context, which the dispatch loop appends to the
169/// real parent VM. Cancel-safe: if the returned future is dropped, the ctx +
170/// child `Vm` are dropped with it.
171pub(crate) fn run_async_builtin_with<F, M>(
172    child: Vm,
173    make_fut: M,
174) -> impl Future<Output = (F::Output, String)>
175where
176    F: Future + Send,
177    M: FnOnce(AsyncBuiltinCtx) -> F,
178{
179    // Build the context + scope synchronously so the by-value `child: Vm` moves
180    // onto the heap *before* any async state machine exists. If this were
181    // an `async fn`, the future would reserve a Vm-sized slot for `child` up to
182    // its first await, and that bloat propagates into every caller's stack
183    // frame, which can trip clippy::large_stack_frames in large dispatch
184    // functions.
185    let ctx = AsyncBuiltinCtx::new(child);
186    let registry = ctx.pool_registry();
187    let sink = Arc::clone(&ctx.child);
188    let fut = make_fut(ctx);
189    async move {
190        let output = crate::stdlib::pool::with_pool_registry_scope(registry, fut).await;
191        let captured = sink.lock().take_output();
192        (output, captured)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::Vm;
200
201    #[tokio::test]
202    async fn explicit_ctx_mints_child_and_captures_forwarded_output() {
203        let (present, captured) = run_async_builtin_with(Vm::new(), |ctx| async move {
204            // The handler holds the explicit ctx — no ambient lookup needed.
205            let _child = ctx.child_vm();
206            ctx.forward_output("hello ");
207            ctx.forward_output("world");
208            true
209        })
210        .await;
211        assert!(present);
212        // `forward_output` appends into the same buffer the dispatch loop drains.
213        assert_eq!(captured, "hello world");
214    }
215
216    #[tokio::test]
217    async fn child_context_has_independent_output_buffer() {
218        let (_result, captured) = run_async_builtin_with(Vm::new(), |ctx| async move {
219            let child = ctx.child_ctx();
220            child.forward_output("child");
221            ctx.forward_output("parent");
222        })
223        .await;
224        assert_eq!(captured, "parent");
225    }
226
227    #[tokio::test]
228    async fn cancelled_scope_strands_nothing() {
229        use std::future::pending;
230        // Build a future that never completes, then drop it without polling to
231        // completion. The ctx is owned by that future, so dropping it releases
232        // the child VM without any ambient cleanup.
233        let never = run_async_builtin_with(Vm::new(), |_ctx| pending::<()>());
234        drop(never);
235    }
236}