Skip to main content

harn_vm/vm/
async_builtin.rs

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