luna_core/vm/async_drive.rs
1//! Cooperative-yield core for `Vm::eval_async`.
2//!
3//! This module implements:
4//!
5//! - `DispatchOutcome` — terminal / cooperative-yield enum.
6//! - `Vm::drive_one` — runs the dispatcher until completion / error /
7//! `BudgetExhausted`. Layers on `Vm::call_value` for the bootstrap
8//! poll and on `Vm::exec_with_async` for resume polls.
9//! - [`EvalFuture`] — `!Send` `std::future::Future` that owns the
10//! `&mut Vm` borrow and surfaces the poll loop.
11//! - [`Vm::eval_async`] / [`Vm::eval_async_chunk`] — public entry
12//! points; convenience for embedders wanting `tokio` / `async-std`
13//! integration.
14//!
15//! Async mode auto-disables JIT for the future's lifetime and
16//! restores the prior setting on terminal poll.
17//!
18//! ```
19//! use luna_core::vm::Vm;
20//! use luna_core::version::LuaVersion;
21//! use std::future::Future;
22//! use std::pin::Pin;
23//! use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
24//!
25//! // 20-line hand-rolled block_on (no tokio dep).
26//! fn block_on<F: Future>(mut fut: F) -> F::Output {
27//! fn raw_waker() -> RawWaker {
28//! fn noop(_: *const ()) {}
29//! fn clone(_: *const ()) -> RawWaker { raw_waker() }
30//! static VT: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
31//! RawWaker::new(std::ptr::null(), &VT)
32//! }
33//! let waker = unsafe { Waker::from_raw(raw_waker()) };
34//! let mut cx = Context::from_waker(&waker);
35//! let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
36//! loop {
37//! match fut.as_mut().poll(&mut cx) {
38//! Poll::Ready(v) => return v,
39//! Poll::Pending => continue,
40//! }
41//! }
42//! }
43//!
44//! let mut vm = Vm::sandbox(LuaVersion::Lua55).open_base().build();
45//! let r = block_on(vm.eval_async("return 1 + 2")).unwrap();
46//! assert_eq!(r.len(), 1);
47//! ```
48
49use crate::runtime::Value;
50use crate::vm::error::LuaError;
51use crate::vm::exec::Vm;
52use std::future::Future;
53use std::pin::Pin;
54use std::task::{Context, Poll};
55
56/// Async-native function ABI. Returns a
57/// `Pin<Box<dyn Future>>` that resolves to the return-value count
58/// (same convention as sync [`crate::runtime::value::NativeFn`]: write
59/// results into the caller's slot via the borrowed `Vm`, then yield
60/// the count back).
61///
62/// # Safety contract
63///
64/// The first parameter is `*mut Vm` rather than `&mut Vm` because the
65/// returned `Pin<Box<dyn Future>>` is `'static` (the trait object
66/// erases lifetimes) and we cannot tie it to the caller's borrow
67/// without `for<'vm>` HRTBs that the trait system rejects on `dyn`
68/// futures. Implementors must reborrow inside the future:
69///
70/// ```ignore
71/// fn my_async(
72/// vm: *mut Vm,
73/// func_slot: u32,
74/// nargs: u32,
75/// ) -> Pin<Box<dyn Future<Output = Result<u32, LuaError>>>> {
76/// Box::pin(async move {
77/// // SAFETY: the dispatcher is suspended and EvalFuture
78/// // holds the unique &mut Vm borrow for the future's
79/// // entire lifetime; no concurrent access can occur.
80/// let vm = unsafe { &mut *vm };
81/// // ... read args from vm.stack[func_slot+1..], do async
82/// // work (e.g. `sleep(...).await`), write results back
83/// // to vm.stack[func_slot..], return their count ...
84/// Ok(0)
85/// })
86/// }
87/// ```
88///
89/// The `Vm` is exclusively owned by the active [`EvalFuture`] for the
90/// suspension's full lifetime (the dispatcher is paused; the host's
91/// executor is the only driver). This makes the `unsafe { &mut *vm }`
92/// reborrow sound provided the future doesn't leak the borrow past
93/// its own `await` boundaries.
94///
95/// The native is invoked exactly once per Lua call site. The future
96/// is polled by [`EvalFuture::poll`]; on `Poll::Ready(Ok(n))` the
97/// dispatcher resumes, treats slots `[func_slot, func_slot+n)` as the
98/// return list, and continues. On `Poll::Ready(Err(e))` the error
99/// propagates as if a sync native had returned it.
100pub type AsyncNativeFn =
101 fn(*mut Vm, func_slot: u32, nargs: u32) -> Pin<Box<dyn Future<Output = Result<u32, LuaError>>>>;
102
103/// Outcome of a single dispatcher slice driven by
104/// [`Vm::drive_one`]. The `AsyncNativeAwaiting` variant is
105/// for async natives: the dispatcher suspends in-place, hands the
106/// returned future to [`EvalFuture::poll`], and resumes the same call
107/// site once the future resolves.
108pub(crate) enum DispatchOutcome {
109 /// The chunk returned cleanly; values are the Lua-side return list.
110 Complete(Vec<Value>),
111 /// A genuine runtime / syntax / type error (NOT a budget yield).
112 Error(LuaError),
113 /// The per-poll instruction quota was exhausted. The dispatcher's
114 /// call frames are intact; the next [`Vm::drive_one`] call (after
115 /// the host pumps the executor) resumes from the same point.
116 BudgetExhausted,
117 /// The dispatcher invoked an async-marked
118 /// native; the returned future is now under host drive. The Vm
119 /// preserves the in-flight call's `(func_slot, nargs, nresults)`
120 /// context in `pending_async_native_ctx` so that
121 /// [`Vm::commit_async_native_result`] can land the future's
122 /// eventual `Ok(nret)` back into the calling frame.
123 AsyncNativeAwaiting(Pin<Box<dyn Future<Output = Result<u32, LuaError>>>>),
124}
125
126impl Vm {
127 /// Allocate a `Value::Native` whose closure is
128 /// tagged as async (`NativeClosure.is_async = true`). The
129 /// underlying `NativeFn` pointer slot stores `f` transmuted from
130 /// [`AsyncNativeFn`] — same pointer width, no provenance loss —
131 /// and the marker bit is what tells the dispatcher to route it
132 /// through the cooperative-yield path.
133 ///
134 /// The returned `Value` can be installed under a Lua global via
135 /// [`Vm::set_global`], passed as a callback, stored in a table —
136 /// whatever a sync `vm.native(f)` value supports. Calling it from
137 /// a sync `Vm::eval` context raises `LuaError` ("async native
138 /// called in sync context"); only `Vm::eval_async` (or another
139 /// driver that sets `async_mode = true`) can drive it.
140 pub fn create_async_native(&mut self, f: AsyncNativeFn) -> Value {
141 // SAFETY: `AsyncNativeFn` and `NativeFn` are both Rust `fn`
142 // pointers and have identical size + alignment (single word).
143 // The `is_async` marker bit, set by `Heap::new_async_native`,
144 // is the discriminant the dispatcher reads before transmuting
145 // back to `AsyncNativeFn` at the call site; without the bit
146 // the pointer is never invoked.
147 let raw_fn: crate::runtime::value::NativeFn = unsafe { std::mem::transmute(f) };
148 Value::Native(self.heap.new_async_native(raw_fn, Box::new([])))
149 }
150
151 /// Convenience: install an async native under
152 /// `name` as a Lua global. Equivalent to
153 /// `vm.set_global(name, vm.create_async_native(f))`.
154 pub fn set_async_native(&mut self, name: &str, f: AsyncNativeFn) -> Result<(), LuaError> {
155 let v = self.create_async_native(f);
156 self.set_global(name, v)
157 }
158
159 /// Convenience entry: compile + run `src` as an
160 /// anonymous chunk via the cooperative-yield dispatcher. The
161 /// returned `EvalFuture` borrows `&mut self` for its full lifetime,
162 /// which (by `Vm: !Send`) keeps it pinned to a single OS thread.
163 ///
164 /// Holding two `EvalFuture`s on the same Vm is blocked by the
165 /// borrow checker (`&mut Vm` exclusivity). Holding a sync
166 /// `eval`/`call_value` call *while* an `EvalFuture` is in flight
167 /// is likewise blocked.
168 ///
169 /// The chunk source name in tracebacks is `"=eval"`. Use
170 /// [`Vm::eval_async_chunk`] to supply a custom name.
171 pub fn eval_async<'vm>(&'vm mut self, src: &str) -> EvalFuture<'vm> {
172 self.eval_async_chunk(src, "=eval")
173 }
174
175 /// Like [`Vm::eval_async`] but with a
176 /// user-supplied chunk name (appears in tracebacks).
177 pub fn eval_async_chunk<'vm>(&'vm mut self, src: &str, name: &str) -> EvalFuture<'vm> {
178 EvalFuture {
179 vm: self,
180 state: EvalState::Initial {
181 src: src.to_string(),
182 name: name.to_string(),
183 },
184 saved_jit_enabled: None,
185 saved_async_slice: None,
186 }
187 }
188
189 /// Set the per-poll opcode quota loaded into
190 /// `instr_budget` at the start of each [`EvalFuture`] poll slice.
191 /// Default 10_000 opcodes. Smaller = finer-grained cooperative
192 /// yield (lower per-task latency, more task-switch overhead);
193 /// larger = closer to sync throughput per slice.
194 pub fn set_async_slice(&mut self, n: i64) {
195 // i64::MAX silently caps at i64::MAX; non-positive values
196 // would loop indefinitely so clamp to 1 (a single opcode per
197 // slice — pathological but well-defined).
198 self.async_slice_size = n.max(1);
199 }
200
201 /// Current per-poll async slice size (default
202 /// 10_000).
203 pub fn async_slice(&self) -> i64 {
204 self.async_slice_size
205 }
206
207 /// Drive the dispatcher one slice. Used
208 /// internally by [`EvalFuture::poll`]. The `bootstrap` flag tells
209 /// the helper whether this is the first slice of a fresh chunk
210 /// (in which case `call_value` sets up the call frame) or a
211 /// resume (in which case the existing frames live in `self.frames`
212 /// and the helper just re-enters the dispatcher at the saved
213 /// `entry_depth`).
214 pub(crate) fn drive_one(
215 &mut self,
216 bootstrap: Option<Value>,
217 entry_depth: usize,
218 ) -> DispatchOutcome {
219 // Arm `async_mode` so the budget hot loop yields cooperatively
220 // instead of erroring. The future installs this once on the
221 // first poll and clears it on terminal poll; arming again here
222 // is idempotent.
223 self.async_mode = true;
224 // Arm a fresh slice quota. The previous slice exhausted to 0;
225 // `instr_budget` was set to `None` by the hot loop on
226 // exhaustion. Reload it for this slice.
227 self.instr_budget = Some(self.async_slice_size);
228
229 let raw = match bootstrap {
230 Some(closure_val) => {
231 // First slice — set up the call frame via the existing
232 // `call_value` path. This handles `c_depth`,
233 // `public_call_depth`, `clear_error_metadata`, and the
234 // `begin_call` push. On a synchronous completion (e.g.
235 // a chunk whose only op is `return`) the call
236 // finishes within `call_value` and we hit
237 // `Complete` immediately.
238 self.call_value(closure_val, &[])
239 }
240 None => {
241 // Resume slice — frames are intact from the prior
242 // `BudgetExhausted`. Walk the dispatcher again.
243 self.exec_with_async(entry_depth)
244 }
245 };
246
247 match raw {
248 Ok(values) => DispatchOutcome::Complete(values),
249 Err(e) => {
250 // Async-native suspension takes
251 // precedence: the future is the active work item, the
252 // sentinel Err is just transport. Check before
253 // `host_yield_pending` because both flags can in
254 // principle coexist (a budget exhaustion deferred by
255 // an in-flight async-native call) but the async-native
256 // future must be drained first.
257 if self.pending_async_native_fut.is_some() {
258 let fut = self.pending_async_native_fut.take().expect("checked above");
259 // ctx stays in place — `commit_async_native_result`
260 // consumes it when the future resolves.
261 DispatchOutcome::AsyncNativeAwaiting(fut)
262 } else if self.host_yield_pending {
263 self.host_yield_pending = false;
264 DispatchOutcome::BudgetExhausted
265 } else {
266 DispatchOutcome::Error(e)
267 }
268 }
269 }
270 }
271
272 /// Land an async native's resolved return
273 /// count back into the calling frame's expected result slots.
274 /// Mirrors the sync-native tail of `call_at` (sans the
275 /// `running_natives` bookkeeping). Consumes
276 /// `Vm.pending_async_native_ctx`; subsequent `drive_one` calls
277 /// resume the dispatcher above this call site.
278 ///
279 /// Called by [`EvalFuture::poll`] after the awaited future
280 /// resolves to `Poll::Ready(Ok(nret))`.
281 pub(crate) fn commit_async_native_result(&mut self, nret: u32) -> Result<(), LuaError> {
282 let ctx = self
283 .pending_async_native_ctx
284 .take()
285 .expect("commit_async_native_result without a pending ctx");
286 self.finish_results(ctx.func_slot, nret, ctx.nresults);
287 // Fire the matching "return" hook for the
288 // async native, after results land in the call window and
289 // before the post-call GC checkpoint. Mirrors the sync
290 // native's `hook_return(true, nargs + 1, nret)` placement in
291 // `exec.rs`. The sync path widens its C-frame argument window
292 // around the hook so `debug.getlocal(2, ftransfer..)` reads
293 // the results; the async path doesn't push to
294 // `running_natives` (the future owned the borrow window
295 // across `.await`), so there's no `running_native_slots` to
296 // widen — `hook_ftransfer` / `hook_ntransfer` set by
297 // `hook_return` carry the same information for Rust hooks
298 // and for Lua hooks reading `debug.getinfo(.).ftransfer`.
299 let ftransfer = ctx.nargs + 1;
300 self.hook_return(true, ftransfer, nret)?;
301 // Same post-call GC checkpoint the sync path runs: the native
302 // may have allocated, and the live boundary is now the result
303 // window.
304 self.maybe_collect_garbage(self.top);
305 Ok(())
306 }
307}
308
309/// Host-driven cooperative-yield future. Borrows
310/// `&mut Vm` for its full lifetime; the borrow + `Vm: !Send` together
311/// make the future `!Send` (suits tokio `current_thread` /
312/// `LocalSet`, NOT multi-thread runtimes).
313///
314/// See module docs for a hand-rolled `block_on` usage example.
315pub struct EvalFuture<'vm> {
316 vm: &'vm mut Vm,
317 state: EvalState,
318 /// Saved `jit.enabled` snapshot from the first poll. JIT-compiled
319 /// traces don't honor `instr_budget` at every opcode, so a runaway
320 /// trace in async mode could starve other tokio tasks. The future
321 /// disables JIT for its duration and restores on terminal poll
322 /// (or on Drop).
323 saved_jit_enabled: Option<bool>,
324 /// Saved `async_slice_size`. The future doesn't mutate it; the
325 /// field lets an async-native path install per-future slice tweaks
326 /// without leaking them into sibling futures.
327 #[allow(dead_code)]
328 saved_async_slice: Option<i64>,
329}
330
331/// State machine driving an `EvalFuture`.
332///
333/// - `Initial` — pre-compile. The source string is owned so the
334/// future can outlive the caller's `&str`.
335/// - `Running` — bootstrap done; subsequent polls resume from
336/// `entry_depth`.
337/// - `Done` — terminal. Polling again panics (per `Future` contract:
338/// futures must not be polled after `Poll::Ready`).
339enum EvalState {
340 Initial {
341 src: String,
342 name: String,
343 },
344 Running {
345 entry_depth: usize,
346 /// `true` only on the very first slice — we still need to
347 /// invoke `call_value` to push the entry frame. After the
348 /// first `BudgetExhausted`, this flips to `false` and the
349 /// future resumes via `exec_with_async`.
350 first_slice: bool,
351 /// Cached for `bootstrap = Some(...)`. After bootstrap fires
352 /// once, the value is `None`.
353 closure: Option<Value>,
354 },
355 /// An async native is mid-await. The future is
356 /// owned here (rather than on the `Vm`) so an explicit `Drop` of
357 /// `EvalFuture` cancels the in-flight future cleanly. On the next
358 /// poll: if the future resolves to `Ok(nret)`, the EvalFuture
359 /// calls `Vm::commit_async_native_result(nret)` and falls back to
360 /// `EvalState::Running` to keep driving the dispatcher; on `Err`
361 /// the EvalFuture transitions to `Done` and surfaces the error.
362 AwaitingNative {
363 entry_depth: usize,
364 fut: Pin<Box<dyn Future<Output = Result<u32, LuaError>>>>,
365 },
366 Done,
367}
368
369impl<'vm> Future for EvalFuture<'vm> {
370 type Output = Result<Vec<Value>, LuaError>;
371
372 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
373 // `EvalFuture` holds no self-referential state — `vm` is a
374 // plain mutable borrow, `state` is owned by value. Safe to
375 // project out of the pin without `pin-project`.
376 let this = unsafe { self.as_mut().get_unchecked_mut() };
377
378 loop {
379 // ---- State transition: Initial → Running ----
380 if let EvalState::Initial { src, name } = &this.state {
381 // Stash JIT setting + disable for the duration (JIT
382 // traces don't honor instr_budget per opcode, so async
383 // mode + JIT could starve the executor).
384 if this.saved_jit_enabled.is_none() {
385 this.saved_jit_enabled = Some(this.vm.jit_enabled());
386 this.vm.set_jit_enabled(false);
387 }
388 // Compile. On syntax error we transition directly to
389 // Done with the error — no Lua frames were pushed,
390 // so the Vm is back at quiescent state.
391 let cl = match this.vm.load(src.as_bytes(), name.as_bytes()) {
392 Ok(c) => c,
393 Err(syntax) => {
394 // Match `eval_chunk`'s syntax-error shaping
395 // (error classification + source position).
396 this.vm
397 .set_error_kind(crate::vm::error::LuaErrorKind::Syntax);
398 this.vm.set_error_source(name.clone(), syntax.line);
399 let msg = format!("{}", syntax);
400 let s = this.vm.intern_str(&msg);
401 // Restore JIT + clean up before returning.
402 if let Some(prev) = this.saved_jit_enabled.take() {
403 this.vm.set_jit_enabled(prev);
404 }
405 this.vm.async_mode = false;
406 this.vm.async_waker = None;
407 this.state = EvalState::Done;
408 return Poll::Ready(Err(LuaError(Value::Str(s))));
409 }
410 };
411 // For the bootstrap slice, frames.len() is currently
412 // 0 (no prior calls on this Vm: enforced by `&mut
413 // Vm` exclusivity over the future's lifetime). The
414 // `call_value` path will push one Lua frame, so the
415 // saved `entry_depth` is 1. We capture it explicitly
416 // rather than reading `vm.frames.len()` post-call so
417 // resume after BudgetExhausted reuses the right
418 // depth.
419 let entry_depth = this.vm.frame_count().saturating_add(1);
420 this.state = EvalState::Running {
421 entry_depth,
422 first_slice: true,
423 closure: Some(Value::Closure(cl)),
424 };
425 // Fall through to Running.
426 }
427
428 // ---- State: Running. Drive a slice. ----
429 match &mut this.state {
430 EvalState::Running {
431 entry_depth,
432 first_slice,
433 closure,
434 } => {
435 // Register the waker so an in-flight async native can
436 // wake the host. A budget exhaustion re-wakes the host
437 // immediately via `cx.waker().wake_by_ref()`.
438 this.vm.async_waker = Some(cx.waker().clone());
439
440 let (bootstrap_arg, ed) = if *first_slice {
441 (closure.take(), *entry_depth)
442 } else {
443 (None, *entry_depth)
444 };
445 let ed_for_resume = *entry_depth;
446 let outcome = this.vm.drive_one(bootstrap_arg, ed);
447 // The first slice is consumed.
448 *first_slice = false;
449
450 match outcome {
451 DispatchOutcome::Complete(values) => {
452 // Restore JIT + clear async state.
453 if let Some(prev) = this.saved_jit_enabled.take() {
454 this.vm.set_jit_enabled(prev);
455 }
456 this.vm.async_mode = false;
457 this.vm.async_waker = None;
458 this.state = EvalState::Done;
459 return Poll::Ready(Ok(values));
460 }
461 DispatchOutcome::Error(e) => {
462 if let Some(prev) = this.saved_jit_enabled.take() {
463 this.vm.set_jit_enabled(prev);
464 }
465 this.vm.async_mode = false;
466 this.vm.async_waker = None;
467 this.state = EvalState::Done;
468 return Poll::Ready(Err(e));
469 }
470 DispatchOutcome::BudgetExhausted => {
471 // Re-wake immediately so the host's executor polls
472 // us again. The `wake_by_ref` call models "we still
473 // have work to do but want to let other tasks run".
474 cx.waker().wake_by_ref();
475 return Poll::Pending;
476 }
477 DispatchOutcome::AsyncNativeAwaiting(fut) => {
478 // Stash the future + flip to AwaitingNative.
479 // Loop back to the top so the very next
480 // iteration polls it (gives Ready-fast
481 // futures a one-poll completion path).
482 this.state = EvalState::AwaitingNative {
483 entry_depth: ed_for_resume,
484 fut,
485 };
486 continue;
487 }
488 }
489 }
490 EvalState::AwaitingNative { entry_depth, fut } => {
491 // Poll the in-flight async native. On Ready, land
492 // the result into the calling Lua frame and fall
493 // back into Running so `drive_one` resumes the
494 // dispatcher above this call site. On Pending,
495 // surface to the host — the future itself
496 // registered any wakers it needs inside the host
497 // executor (e.g. a tokio timer).
498 match fut.as_mut().poll(cx) {
499 Poll::Ready(Ok(nret)) => {
500 let ed = *entry_depth;
501 // Commit may fire the
502 // async-native "return" hook, which can
503 // error (hook propagates `LuaError`). On
504 // error, run the same cleanup the
505 // `Poll::Ready(Err)` arm runs below.
506 if let Err(e) = this.vm.commit_async_native_result(nret) {
507 if let Some(prev) = this.saved_jit_enabled.take() {
508 this.vm.set_jit_enabled(prev);
509 }
510 this.vm.async_mode = false;
511 this.vm.async_waker = None;
512 this.state = EvalState::Done;
513 return Poll::Ready(Err(e));
514 }
515 this.state = EvalState::Running {
516 entry_depth: ed,
517 first_slice: false,
518 closure: None,
519 };
520 continue;
521 }
522 Poll::Ready(Err(e)) => {
523 // Drop the in-flight ctx — the future
524 // failed, so its slot is gone.
525 this.vm.pending_async_native_ctx = None;
526 if let Some(prev) = this.saved_jit_enabled.take() {
527 this.vm.set_jit_enabled(prev);
528 }
529 this.vm.async_mode = false;
530 this.vm.async_waker = None;
531 this.state = EvalState::Done;
532 return Poll::Ready(Err(e));
533 }
534 Poll::Pending => return Poll::Pending,
535 }
536 }
537 EvalState::Initial { .. } => unreachable!("transitioned above"),
538 EvalState::Done => panic!("EvalFuture polled after Poll::Ready"),
539 }
540 }
541 }
542}
543
544impl<'vm> Drop for EvalFuture<'vm> {
545 fn drop(&mut self) {
546 // If the future is dropped mid-flight (host timeout, task
547 // cancelled), restore any state we mutated so the Vm is
548 // usable again. Note: stale call frames from an in-flight
549 // chunk remain in `vm.frames`; a full cleanup pass (closing
550 // `__close` handlers etc.) would mirror `close_coro` and is
551 // not done here; there is no `Vm::cancel_async`. Embedders
552 // relying on cancellation should construct a fresh Vm per request.
553 if let Some(prev) = self.saved_jit_enabled.take() {
554 self.vm.set_jit_enabled(prev);
555 }
556 // Always clear async state on drop so the next `eval` / `eval_async`
557 // call on the same Vm starts clean.
558 self.vm.async_mode = false;
559 self.vm.async_waker = None;
560 self.vm.host_yield_pending = false;
561 // Async-native bookkeeping. The future is
562 // owned by `EvalFuture` (not by the Vm) once `drive_one`
563 // surfaces it, so cancelling here only needs to clear the
564 // post-call ctx; the dropped EvalFuture takes the Pin<Box<...>>
565 // with it.
566 self.vm.pending_async_native_fut = None;
567 self.vm.pending_async_native_ctx = None;
568 }
569}