Skip to main content

luna_core/vm/
async_drive.rs

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