Skip to content

Commit 3de68a7

Browse files
committed
Prototype support for async native functions
This experiments with enabling support for async NativeFunctions that are only async from the pov of the host, and appear as synchronous from within JavaScript. Instead of running the async functions as a Promise via enqueue_job, this works by allowing Operations to be executed over multiple VM cycles, so an Operation may start some async work in one step and then further steps can poll for completion of that work and finish the Operation. In particular this works by allowing Call Operations to return an `OpStatus::Pending`value that indicates that the same Call operation needs to be executed repeatedly, until it returns an `OpStatus::Finished` status. In the case of a `Pending` status, the program counter is reset and anything that was taken off the stack is pushed back so the same Operation can be re-executed. There is a new `NativeFunction::from_async_as_sync_with_captures()` that lets the host provide a (sync) closure that itself returns / spawns a boxed Future. This is tracked internally as an `Inner::AsyncFn`. Whenever the function is `__call__`ed then (assuming the operation isn't already in a pending / running state) a new Future is spawned via the application's closure and the Operation enters a "pending" state. When a NativeFunction is pending then each `__call__` will `poll()` the spawned `Future` to see if the `async` function has a result. This effectively stalls the VM at the same Opcode while still accounting for any cycle budget and periodically yielding to the application's async runtime while waiting for an async Call Operation to finish. Limitations / Issues ==================== == Busy Loop Polling == Even though the implementation does yield back to the application's async runtime when waiting for a NativeFunction to complete, the implementation isn't ideal because it uses a noop task Context + Waker when polling NativeFunction Futures. The effectively relies on the VM polling the future in a busy loop, wasting CPU time. A better solution could be to implement a shim Waker that would flag some state on the Boa engine Context, and then adapt the Future that's used to yield the VM to the executor so that it only becomes Ready once the async NativeFunction has signalled the waker. I.e. the Waker would act like a bridge/proxy between a spawned async NativeFunction and the the Future/Task associated with the VM's async `run_async_with_budget`. This way I think the VM could remain async runtime agnostic but would be able to actually sleep while waiting for async functions instead of entering a busy yield loop. == Requires PC rewind and reverting stack state == Ideally operations that may complete over multiple steps would maintain a state machine via private registers, whereby it would not be necessary to repeatedly rewind the program counter and re-push values to the stack so that the operation can be decoded and executed repeatedly from the beginning. == Only adapts Call Operation == Currently only the Call Operation handles async NativeFunctions but there are other Call[XYZ] Operations that could be adapted too. == Not compatible with composite Operations that `call()` == The ability to track pending async functions is implemented in terms of repeatedly executing an Opcode in the VM until it signals that it's not Pending. This currently relies on being able to reset and re-execute the Operation (such as reverting program counter and stack changes). There are lots of Operations that make use of JsObject::call() internally and they would currently trigger a panic if they called an async NativeFunction because they would not be able to "resolve()" the "Pending" status that would be returned by the `call()`. Ideally all Operations that use `__call__` or `__construct__` should be fully resumable in the same way that the Call Operation is now. This would presumably be easier to achieve with Rust Coroutines if they were stable because it would otherwise be necessary to adapt composite Operations into a state machine, similar to what the compiler does for an async Future, so they can yield for async function calls and be resumed by the VM.
1 parent 864816b commit 3de68a7

File tree

12 files changed

+605
-117
lines changed

12 files changed

+605
-117
lines changed

core/engine/src/native_function/mod.rs

Lines changed: 367 additions & 52 deletions
Large diffs are not rendered by default.

core/engine/src/object/internal_methods/mod.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,23 @@ pub(crate) enum CallValue {
393393
argument_count: usize,
394394
},
395395

396+
/// Further processing is needed.
397+
///
398+
/// Unlike for `Pending`, the further processing should not block the VM and
399+
/// be completed synchronously, it should integrate with VM cycle budgeting
400+
/// and yielding.
401+
AsyncPending,
402+
396403
/// The value has been computed and is the first element on the stack.
397404
Complete,
398405
}
399406

407+
pub(crate) enum ResolvedCallValue {
408+
Ready,
409+
Pending,
410+
Complete,
411+
}
412+
400413
impl CallValue {
401414
/// Resolves the [`CallValue`], and return if the value is complete.
402415
pub(crate) fn resolve(mut self, context: &mut Context) -> JsResult<bool> {
@@ -412,7 +425,25 @@ impl CallValue {
412425
match self {
413426
Self::Ready => Ok(false),
414427
Self::Complete => Ok(true),
428+
Self::Pending { .. } | Self::AsyncPending { .. } => unreachable!(),
429+
}
430+
}
431+
432+
pub(crate) fn async_resolve(mut self, context: &mut Context) -> JsResult<ResolvedCallValue> {
433+
while let Self::Pending {
434+
func,
435+
object,
436+
argument_count,
437+
} = self
438+
{
439+
self = func(&object, argument_count, context)?;
440+
}
441+
442+
match self {
443+
Self::Ready => Ok(ResolvedCallValue::Ready),
444+
Self::Complete => Ok(ResolvedCallValue::Complete),
415445
Self::Pending { .. } => unreachable!(),
446+
Self::AsyncPending { .. } => Ok(ResolvedCallValue::Pending),
416447
}
417448
}
418449
}

core/engine/src/vm/completion_record.rs

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
#![allow(clippy::inline_always)]
44

5+
use super::OpStatus;
56
use crate::{Context, JsError, JsResult, JsValue};
67
use boa_gc::{custom_trace, Finalize, Trace};
78
use std::ops::ControlFlow;
@@ -51,36 +52,95 @@ impl CompletionRecord {
5152
}
5253

5354
pub(crate) trait IntoCompletionRecord {
54-
fn into_completion_record(self, context: &mut Context) -> ControlFlow<CompletionRecord>;
55+
fn into_completion_record(
56+
self,
57+
context: &mut Context,
58+
saved_pc: u32,
59+
) -> ControlFlow<CompletionRecord, OpStatus>;
5560
}
5661

5762
impl IntoCompletionRecord for () {
5863
#[inline(always)]
59-
fn into_completion_record(self, _: &mut Context) -> ControlFlow<CompletionRecord> {
60-
ControlFlow::Continue(())
64+
fn into_completion_record(
65+
self,
66+
_: &mut Context,
67+
_: u32,
68+
) -> ControlFlow<CompletionRecord, OpStatus> {
69+
ControlFlow::Continue(OpStatus::Finished)
6170
}
6271
}
6372

6473
impl IntoCompletionRecord for JsError {
6574
#[inline(always)]
66-
fn into_completion_record(self, context: &mut Context) -> ControlFlow<CompletionRecord> {
75+
fn into_completion_record(
76+
self,
77+
context: &mut Context,
78+
_: u32,
79+
) -> ControlFlow<CompletionRecord, OpStatus> {
6780
context.handle_error(self)
6881
}
6982
}
7083

7184
impl IntoCompletionRecord for JsResult<()> {
7285
#[inline(always)]
73-
fn into_completion_record(self, context: &mut Context) -> ControlFlow<CompletionRecord> {
86+
fn into_completion_record(
87+
self,
88+
context: &mut Context,
89+
_: u32,
90+
) -> ControlFlow<CompletionRecord, OpStatus> {
7491
match self {
75-
Ok(()) => ControlFlow::Continue(()),
92+
Ok(()) => ControlFlow::Continue(OpStatus::Finished),
93+
Err(err) => context.handle_error(err),
94+
}
95+
}
96+
}
97+
98+
impl IntoCompletionRecord for JsResult<OpStatus> {
99+
#[inline(always)]
100+
fn into_completion_record(
101+
self,
102+
context: &mut Context,
103+
saved_pc: u32,
104+
) -> ControlFlow<CompletionRecord, OpStatus> {
105+
match self {
106+
Ok(OpStatus::Finished) => ControlFlow::Continue(OpStatus::Finished),
107+
Ok(OpStatus::Pending) => {
108+
context.vm.frame_mut().pc = saved_pc;
109+
ControlFlow::Continue(OpStatus::Pending)
110+
}
76111
Err(err) => context.handle_error(err),
77112
}
78113
}
79114
}
80115

81116
impl IntoCompletionRecord for ControlFlow<CompletionRecord> {
82117
#[inline(always)]
83-
fn into_completion_record(self, _: &mut Context) -> ControlFlow<CompletionRecord> {
84-
self
118+
fn into_completion_record(
119+
self,
120+
_: &mut Context,
121+
_: u32,
122+
) -> ControlFlow<CompletionRecord, OpStatus> {
123+
match self {
124+
ControlFlow::Continue(()) => ControlFlow::Continue(OpStatus::Finished),
125+
ControlFlow::Break(completion_record) => ControlFlow::Break(completion_record),
126+
}
127+
}
128+
}
129+
130+
impl IntoCompletionRecord for ControlFlow<CompletionRecord, OpStatus> {
131+
#[inline(always)]
132+
fn into_completion_record(
133+
self,
134+
context: &mut Context,
135+
saved_pc: u32,
136+
) -> ControlFlow<CompletionRecord, OpStatus> {
137+
match self {
138+
ControlFlow::Continue(OpStatus::Finished) => ControlFlow::Continue(OpStatus::Finished),
139+
ControlFlow::Continue(OpStatus::Pending) => {
140+
context.vm.frame_mut().pc = saved_pc;
141+
ControlFlow::Continue(OpStatus::Pending)
142+
}
143+
ControlFlow::Break(completion_record) => ControlFlow::Break(completion_record),
144+
}
85145
}
86146
}

core/engine/src/vm/mod.rs

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -580,9 +580,9 @@ impl Context {
580580
&mut self,
581581
f: F,
582582
opcode: Opcode,
583-
) -> ControlFlow<CompletionRecord>
583+
) -> ControlFlow<CompletionRecord, OpStatus>
584584
where
585-
F: FnOnce(&mut Context, Opcode) -> ControlFlow<CompletionRecord>,
585+
F: FnOnce(&mut Context, Opcode) -> ControlFlow<CompletionRecord, OpStatus>,
586586
{
587587
let frame = self.vm.frame();
588588
let (instruction, _) = frame
@@ -633,17 +633,27 @@ impl Context {
633633
}
634634
}
635635

636+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637+
pub(crate) enum OpStatus {
638+
Finished,
639+
Pending,
640+
}
641+
636642
impl Context {
637-
fn execute_instruction<F>(&mut self, f: F, opcode: Opcode) -> ControlFlow<CompletionRecord>
643+
fn execute_instruction<F>(
644+
&mut self,
645+
f: F,
646+
opcode: Opcode,
647+
) -> ControlFlow<CompletionRecord, OpStatus>
638648
where
639-
F: FnOnce(&mut Context, Opcode) -> ControlFlow<CompletionRecord>,
649+
F: FnOnce(&mut Context, Opcode) -> ControlFlow<CompletionRecord, OpStatus>,
640650
{
641651
f(self, opcode)
642652
}
643653

644-
fn execute_one<F>(&mut self, f: F, opcode: Opcode) -> ControlFlow<CompletionRecord>
654+
fn execute_one<F>(&mut self, f: F, opcode: Opcode) -> ControlFlow<CompletionRecord, OpStatus>
645655
where
646-
F: FnOnce(&mut Context, Opcode) -> ControlFlow<CompletionRecord>,
656+
F: FnOnce(&mut Context, Opcode) -> ControlFlow<CompletionRecord, OpStatus>,
647657
{
648658
#[cfg(feature = "fuzz")]
649659
{
@@ -666,7 +676,7 @@ impl Context {
666676
self.execute_instruction(f, opcode)
667677
}
668678

669-
fn handle_error(&mut self, err: JsError) -> ControlFlow<CompletionRecord> {
679+
fn handle_error(&mut self, err: JsError) -> ControlFlow<CompletionRecord, OpStatus> {
670680
// If we hit the execution step limit, bubble up the error to the
671681
// (Rust) caller instead of trying to handle as an exception.
672682
if !err.is_catchable() {
@@ -695,7 +705,7 @@ impl Context {
695705
let pc = self.vm.frame().pc.saturating_sub(1);
696706
if self.vm.handle_exception_at(pc) {
697707
self.vm.pending_exception = Some(err);
698-
return ControlFlow::Continue(());
708+
return ControlFlow::Continue(OpStatus::Finished);
699709
}
700710

701711
// Inject realm before crossing the function boundry
@@ -705,7 +715,7 @@ impl Context {
705715
self.handle_thow()
706716
}
707717

708-
fn handle_return(&mut self) -> ControlFlow<CompletionRecord> {
718+
fn handle_return(&mut self) -> ControlFlow<CompletionRecord, OpStatus> {
709719
let exit_early = self.vm.frame().exit_early();
710720
self.vm.stack.truncate_to_frame(&self.vm.frame);
711721

@@ -716,21 +726,21 @@ impl Context {
716726

717727
self.vm.stack.push(result);
718728
self.vm.pop_frame().expect("frame must exist");
719-
ControlFlow::Continue(())
729+
ControlFlow::Continue(OpStatus::Finished)
720730
}
721731

722-
fn handle_yield(&mut self) -> ControlFlow<CompletionRecord> {
732+
fn handle_yield(&mut self) -> ControlFlow<CompletionRecord, OpStatus> {
723733
let result = self.vm.take_return_value();
724734
if self.vm.frame().exit_early() {
725735
return ControlFlow::Break(CompletionRecord::Return(result));
726736
}
727737

728738
self.vm.stack.push(result);
729739
self.vm.pop_frame().expect("frame must exist");
730-
ControlFlow::Continue(())
740+
ControlFlow::Continue(OpStatus::Finished)
731741
}
732742

733-
fn handle_thow(&mut self) -> ControlFlow<CompletionRecord> {
743+
fn handle_thow(&mut self) -> ControlFlow<CompletionRecord, OpStatus> {
734744
let mut env_fp = self.vm.frame().env_fp;
735745
if self.vm.frame().exit_early() {
736746
self.vm.environments.truncate(env_fp as usize);
@@ -751,7 +761,7 @@ impl Context {
751761
let exit_early = self.vm.frame.exit_early();
752762

753763
if self.vm.handle_exception_at(pc) {
754-
return ControlFlow::Continue(());
764+
return ControlFlow::Continue(OpStatus::Finished);
755765
}
756766

757767
if exit_early {
@@ -770,7 +780,7 @@ impl Context {
770780
}
771781
self.vm.environments.truncate(env_fp as usize);
772782
self.vm.stack.truncate_to_frame(&frame);
773-
ControlFlow::Continue(())
783+
ControlFlow::Continue(OpStatus::Finished)
774784
}
775785

776786
/// Runs the current frame to completion, yielding to the caller each time `budget`
@@ -800,7 +810,10 @@ impl Context {
800810
},
801811
opcode,
802812
) {
803-
ControlFlow::Continue(()) => {}
813+
ControlFlow::Continue(OpStatus::Finished) => {}
814+
ControlFlow::Continue(OpStatus::Pending) => {
815+
runtime_budget = 0;
816+
}
804817
ControlFlow::Break(value) => return value,
805818
}
806819

@@ -830,7 +843,7 @@ impl Context {
830843
let opcode = Opcode::decode(*byte);
831844

832845
match self.execute_one(Self::execute_bytecode_instruction, opcode) {
833-
ControlFlow::Continue(()) => {}
846+
ControlFlow::Continue(_) => {}
834847
ControlFlow::Break(value) => return value,
835848
}
836849
}

core/engine/src/vm/opcode/await/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
js_string,
88
native_function::NativeFunction,
99
object::FunctionObjectBuilder,
10-
vm::{opcode::Operation, CompletionRecord, GeneratorResumeKind},
10+
vm::{opcode::Operation, CompletionRecord, GeneratorResumeKind, OpStatus},
1111
Context, JsArgs, JsValue,
1212
};
1313
use boa_gc::Gc;
@@ -25,7 +25,7 @@ impl Await {
2525
pub(super) fn operation(
2626
value: VaryingOperand,
2727
context: &mut Context,
28-
) -> ControlFlow<CompletionRecord> {
28+
) -> ControlFlow<CompletionRecord, OpStatus> {
2929
let value = context.vm.get_register(value.into());
3030

3131
// 2. Let promise be ? PromiseResolve(%Promise%, value).
@@ -197,15 +197,18 @@ pub(crate) struct CompletePromiseCapability;
197197

198198
impl CompletePromiseCapability {
199199
#[inline(always)]
200-
pub(super) fn operation((): (), context: &mut Context) -> ControlFlow<CompletionRecord> {
200+
pub(super) fn operation(
201+
(): (),
202+
context: &mut Context,
203+
) -> ControlFlow<CompletionRecord, OpStatus> {
201204
// If the current executing function is an async function we have to resolve/reject it's promise at the end.
202205
// The relevant spec section is 3. in [AsyncBlockStart](https://tc39.es/ecma262/#sec-asyncblockstart).
203206
let Some(promise_capability) = context.vm.stack.get_promise_capability(&context.vm.frame)
204207
else {
205208
return if context.vm.pending_exception.is_some() {
206209
context.handle_thow()
207210
} else {
208-
ControlFlow::Continue(())
211+
ControlFlow::Continue(OpStatus::Finished)
209212
};
210213
};
211214

@@ -226,7 +229,7 @@ impl CompletePromiseCapability {
226229
.vm
227230
.set_return_value(promise_capability.promise().clone().into());
228231

229-
ControlFlow::Continue(())
232+
ControlFlow::Continue(OpStatus::Finished)
230233
}
231234
}
232235

core/engine/src/vm/opcode/call/mod.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use crate::{
33
builtins::{promise::PromiseCapability, Promise},
44
error::JsNativeError,
55
module::{ModuleKind, Referrer},
6-
object::FunctionObjectBuilder,
7-
vm::opcode::Operation,
6+
object::{internal_methods::ResolvedCallValue, FunctionObjectBuilder},
7+
vm::opcode::{OpStatus, Operation},
88
Context, JsObject, JsResult, JsValue, NativeFunction,
99
};
1010

@@ -177,21 +177,33 @@ pub(crate) struct Call;
177177

178178
impl Call {
179179
#[inline(always)]
180-
pub(super) fn operation(argument_count: VaryingOperand, context: &mut Context) -> JsResult<()> {
180+
pub(super) fn operation(
181+
argument_count: VaryingOperand,
182+
context: &mut Context,
183+
) -> JsResult<OpStatus> {
181184
let func = context
182185
.vm
183186
.stack
184187
.calling_convention_get_function(argument_count.into());
185188

189+
//println!("Call function: {:?}", func);
186190
let Some(object) = func.as_object() else {
187191
return Err(JsNativeError::typ()
188192
.with_message("not a callable function")
189193
.into());
190194
};
191195

192-
object.__call__(argument_count.into()).resolve(context)?;
193-
194-
Ok(())
196+
match object
197+
.__call__(argument_count.into())
198+
.async_resolve(context)?
199+
{
200+
ResolvedCallValue::Ready => Ok(OpStatus::Finished),
201+
ResolvedCallValue::Complete => Ok(OpStatus::Finished),
202+
ResolvedCallValue::Pending => {
203+
//println!("Pending call");
204+
Ok(OpStatus::Pending)
205+
}
206+
}
195207
}
196208
}
197209

0 commit comments

Comments
 (0)