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 unit tests that drive an async
49 /// builtin handler directly (outside the dispatch loop). Production code
50 /// receives its ctx from the dispatch path, never this.
51 #[cfg(test)]
52 pub fn for_test(vm: Vm) -> Self {
53 Self::new(vm)
54 }
55
56 /// Clone a fresh child VM from this context. The returned `Vm` shares the
57 /// parent's heavy state, so each closure-invoking handler gets its own
58 /// cheap execution context.
59 ///
60 /// Uses the *inline* clone: this child runs while the original parent is
61 /// parked awaiting the builtin, so it inherits the parent's held-lock keys
62 /// for cross-context self-deadlock detection (HARN-ORC-011). Long-lived /
63 /// detached contexts use [`AsyncBuiltinCtx::child_ctx`] instead, which does
64 /// not inherit, since the parent keeps running there.
65 pub fn child_vm(&self) -> Vm {
66 self.child.lock().child_vm_inline()
67 }
68
69 /// Pool tasks may execute on any Tokio worker thread, so pool lookup state
70 /// is shared through the VM context rather than thread-local storage.
71 pub(crate) fn pool_registry(&self) -> Arc<crate::stdlib::pool::PoolRegistry> {
72 self.child.lock().pool_registry.clone()
73 }
74
75 pub(crate) fn wait_for_graph(&self) -> Arc<crate::wait_for_graph::VmWaitForGraph> {
76 self.child.lock().wait_for_graph.clone()
77 }
78
79 pub(crate) fn package_snapshot_registry(&self) -> Arc<crate::stdlib::PackageSnapshotRegistry> {
80 self.child.lock().package_snapshot_registry.clone()
81 }
82
83 /// Create an independent context rooted at a fresh child VM. Long-lived
84 /// local tasks use this instead of sharing the parent builtin's output
85 /// buffer after the parent future has returned.
86 ///
87 /// This is a *detached* context: the new task runs independently of the
88 /// original parent, so it must NOT inherit the parent's held-lock keys
89 /// (blocking on a parent-held lock is legitimately resolvable here). Uses
90 /// the plain, non-inheriting `child_vm()` rather than `Self::child_vm`.
91 pub fn child_ctx(&self) -> Self {
92 Self::new(self.child.lock().child_vm())
93 }
94
95 /// Forward captured output from a transient child VM (typically created via
96 /// [`AsyncBuiltinCtx::child_vm`] and used to invoke a closure) back into this
97 /// context's output buffer. The dispatch loop drains that buffer back to the
98 /// original parent VM after the async builtin returns.
99 ///
100 /// Without this hook, `harness.stdio.log()`/`__io_print()` calls inside
101 /// `post_turn_callback` closures, tool handlers, and other VM-side closures
102 /// invoked from async builtins would silently disappear because the transient
103 /// child VM's output buffer is dropped on scope exit.
104 pub fn forward_output(&self, text: &str) {
105 if text.is_empty() {
106 return;
107 }
108 self.child.lock().append_output(text);
109 }
110
111 /// Snapshot the cancellation and deadline sources for a host operation
112 /// that must move its blocking work to Tokio's blocking pool.
113 pub fn interrupt_sources(&self) -> (Option<Arc<AtomicBool>>, Option<Instant>) {
114 self.child.lock().interrupt_sources()
115 }
116
117 /// Pause the outer host execution rail while an embedder-owned resource is
118 /// not runnable. Catchable script deadlines remain unchanged.
119 pub(crate) fn pause_execution_deadline(
120 &self,
121 clock: Arc<dyn harn_clock::Clock>,
122 ) -> Option<super::state::ExecutionDeadlinePauseGuard> {
123 self.child.lock().execution_deadline.pause(clock)
124 }
125
126 #[cfg(test)]
127 pub(crate) fn execution_deadline_offset_for_test(&self) -> u64 {
128 self.child
129 .lock()
130 .execution_deadline
131 .encoded_offset_for_test()
132 }
133}
134
135/// Run an async builtin's future with `child` installed as its explicit
136/// [`AsyncBuiltinCtx`]. `make_fut` receives the ctx handle and returns the
137/// handler's future; the ctx is moved into the future, so it lives exactly as
138/// long as the call. Returns the future's output plus any output that VM-side
139/// closures forwarded into the context, which the dispatch loop appends to the
140/// real parent VM. Cancel-safe: if the returned future is dropped, the ctx +
141/// child `Vm` are dropped with it.
142pub(crate) fn run_async_builtin_with<F, M>(
143 child: Vm,
144 make_fut: M,
145) -> impl Future<Output = (F::Output, String)>
146where
147 F: Future + Send,
148 M: FnOnce(AsyncBuiltinCtx) -> F,
149{
150 // Build the context + scope synchronously so the by-value `child: Vm` moves
151 // onto the heap *before* any async state machine exists. If this were
152 // an `async fn`, the future would reserve a Vm-sized slot for `child` up to
153 // its first await, and that bloat propagates into every caller's stack
154 // frame, which can trip clippy::large_stack_frames in large dispatch
155 // functions.
156 let ctx = AsyncBuiltinCtx::new(child);
157 let registry = ctx.pool_registry();
158 let sink = Arc::clone(&ctx.child);
159 let fut = make_fut(ctx);
160 async move {
161 let output = crate::stdlib::pool::with_pool_registry_scope(registry, fut).await;
162 let captured = sink.lock().take_output();
163 (output, captured)
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::Vm;
171
172 #[tokio::test]
173 async fn explicit_ctx_mints_child_and_captures_forwarded_output() {
174 let (present, captured) = run_async_builtin_with(Vm::new(), |ctx| async move {
175 // The handler holds the explicit ctx — no ambient lookup needed.
176 let _child = ctx.child_vm();
177 ctx.forward_output("hello ");
178 ctx.forward_output("world");
179 true
180 })
181 .await;
182 assert!(present);
183 // `forward_output` appends into the same buffer the dispatch loop drains.
184 assert_eq!(captured, "hello world");
185 }
186
187 #[tokio::test]
188 async fn child_context_has_independent_output_buffer() {
189 let (_result, captured) = run_async_builtin_with(Vm::new(), |ctx| async move {
190 let child = ctx.child_ctx();
191 child.forward_output("child");
192 ctx.forward_output("parent");
193 })
194 .await;
195 assert_eq!(captured, "parent");
196 }
197
198 #[tokio::test]
199 async fn cancelled_scope_strands_nothing() {
200 use std::future::pending;
201 // Build a future that never completes, then drop it without polling to
202 // completion. The ctx is owned by that future, so dropping it releases
203 // the child VM without any ambient cleanup.
204 let never = run_async_builtin_with(Vm::new(), |_ctx| pending::<()>());
205 drop(never);
206 }
207}