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}