harn_vm/vm/async_builtin.rs
1use std::future::Future;
2use std::sync::Arc;
3
4use super::Vm;
5
6/// Explicit handle to the parent VM's execution context for the duration of one
7/// async-builtin call. Threaded into every async builtin by the dispatch loop
8/// (and the `#[harn_builtin]` macro), so context can no longer be "lost across a
9/// spawn boundary": a handler that needs VM access receives or clones this
10/// handle deliberately instead of reading ambient state.
11///
12/// Holds the "template" child VM that closure-invoking host helpers clone via
13/// [`AsyncBuiltinCtx::child_vm`], and whose `output` buffer collects text
14/// forwarded from VM-side closures via [`AsyncBuiltinCtx::forward_output`]. The
15/// dispatch loop drains that buffer back to the original parent VM after the
16/// async builtin returns. Cheap to clone: it is an `Arc` handle and everything
17/// heavy inside the `Vm` is shared.
18#[derive(Clone)]
19pub struct AsyncBuiltinCtx {
20 child: Arc<parking_lot::Mutex<Vm>>,
21}
22
23impl AsyncBuiltinCtx {
24 fn new(vm: Vm) -> Self {
25 Self {
26 child: Arc::new(parking_lot::Mutex::new(vm)),
27 }
28 }
29
30 /// Construct a context around `vm` for host adapters that are not themselves
31 /// async builtins but need to run VM-side closures.
32 pub fn from_vm(vm: Vm) -> Self {
33 Self::new(vm)
34 }
35
36 /// Construct a standalone ctx around `vm` for unit tests that drive an async
37 /// builtin handler directly (outside the dispatch loop). Production code
38 /// receives its ctx from the dispatch path, never this.
39 #[cfg(test)]
40 pub fn for_test(vm: Vm) -> Self {
41 Self::new(vm)
42 }
43
44 /// Clone a fresh child VM from this context. The returned `Vm` shares the
45 /// parent's heavy state, so each closure-invoking handler gets its own
46 /// cheap execution context.
47 ///
48 /// Uses the *inline* clone: this child runs while the original parent is
49 /// parked awaiting the builtin, so it inherits the parent's held-lock keys
50 /// for cross-context self-deadlock detection (HARN-ORC-011). Long-lived /
51 /// detached contexts use [`AsyncBuiltinCtx::child_ctx`] instead, which does
52 /// not inherit, since the parent keeps running there.
53 pub fn child_vm(&self) -> Vm {
54 self.child.lock().child_vm_inline()
55 }
56
57 /// Pool tasks may execute on any Tokio worker thread, so pool lookup state
58 /// is shared through the VM context rather than thread-local storage.
59 pub(crate) fn pool_registry(&self) -> Arc<crate::stdlib::pool::PoolRegistry> {
60 self.child.lock().pool_registry.clone()
61 }
62
63 pub(crate) fn wait_for_graph(&self) -> Arc<crate::wait_for_graph::VmWaitForGraph> {
64 self.child.lock().wait_for_graph.clone()
65 }
66
67 pub(crate) fn package_snapshot_registry(&self) -> Arc<crate::stdlib::PackageSnapshotRegistry> {
68 self.child.lock().package_snapshot_registry.clone()
69 }
70
71 /// Create an independent context rooted at a fresh child VM. Long-lived
72 /// local tasks use this instead of sharing the parent builtin's output
73 /// buffer after the parent future has returned.
74 ///
75 /// This is a *detached* context: the new task runs independently of the
76 /// original parent, so it must NOT inherit the parent's held-lock keys
77 /// (blocking on a parent-held lock is legitimately resolvable here). Uses
78 /// the plain, non-inheriting `child_vm()` rather than `Self::child_vm`.
79 pub fn child_ctx(&self) -> Self {
80 Self::new(self.child.lock().child_vm())
81 }
82
83 /// Forward captured output from a transient child VM (typically created via
84 /// [`AsyncBuiltinCtx::child_vm`] and used to invoke a closure) back into this
85 /// context's output buffer. The dispatch loop drains that buffer back to the
86 /// original parent VM after the async builtin returns.
87 ///
88 /// Without this hook, `log()`/`__io_print()` calls inside
89 /// `post_turn_callback` closures, tool handlers, and other VM-side closures
90 /// invoked from async builtins would silently disappear because the transient
91 /// child VM's output buffer is dropped on scope exit.
92 pub fn forward_output(&self, text: &str) {
93 if text.is_empty() {
94 return;
95 }
96 self.child.lock().append_output(text);
97 }
98}
99
100/// Run an async builtin's future with `child` installed as its explicit
101/// [`AsyncBuiltinCtx`]. `make_fut` receives the ctx handle and returns the
102/// handler's future; the ctx is moved into the future, so it lives exactly as
103/// long as the call. Returns the future's output plus any output that VM-side
104/// closures forwarded into the context, which the dispatch loop appends to the
105/// real parent VM. Cancel-safe: if the returned future is dropped, the ctx +
106/// child `Vm` are dropped with it.
107pub(crate) fn run_async_builtin_with<F, M>(
108 child: Vm,
109 make_fut: M,
110) -> impl Future<Output = (F::Output, String)>
111where
112 F: Future + Send,
113 M: FnOnce(AsyncBuiltinCtx) -> F,
114{
115 // Build the context + scope synchronously so the by-value `child: Vm` moves
116 // onto the heap *before* any async state machine exists. If this were
117 // an `async fn`, the future would reserve a Vm-sized slot for `child` up to
118 // its first await, and that bloat propagates into every caller's stack
119 // frame, which can trip clippy::large_stack_frames in large dispatch
120 // functions.
121 let ctx = AsyncBuiltinCtx::new(child);
122 let registry = ctx.pool_registry();
123 let sink = Arc::clone(&ctx.child);
124 let fut = make_fut(ctx);
125 async move {
126 let output = crate::stdlib::pool::with_pool_registry_scope(registry, fut).await;
127 let captured = sink.lock().take_output();
128 (output, captured)
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::Vm;
136
137 #[tokio::test]
138 async fn explicit_ctx_mints_child_and_captures_forwarded_output() {
139 let (present, captured) = run_async_builtin_with(Vm::new(), |ctx| async move {
140 // The handler holds the explicit ctx — no ambient lookup needed.
141 let _child = ctx.child_vm();
142 ctx.forward_output("hello ");
143 ctx.forward_output("world");
144 true
145 })
146 .await;
147 assert!(present);
148 // `forward_output` appends into the same buffer the dispatch loop drains.
149 assert_eq!(captured, "hello world");
150 }
151
152 #[tokio::test]
153 async fn child_context_has_independent_output_buffer() {
154 let (_result, captured) = run_async_builtin_with(Vm::new(), |ctx| async move {
155 let child = ctx.child_ctx();
156 child.forward_output("child");
157 ctx.forward_output("parent");
158 })
159 .await;
160 assert_eq!(captured, "parent");
161 }
162
163 #[tokio::test]
164 async fn cancelled_scope_strands_nothing() {
165 use std::future::pending;
166 // Build a future that never completes, then drop it without polling to
167 // completion. The ctx is owned by that future, so dropping it releases
168 // the child VM without any ambient cleanup.
169 let never = run_async_builtin_with(Vm::new(), |_ctx| pending::<()>());
170 drop(never);
171 }
172}