Browse Source

API to construct a `NativeFunction` from a native async function (#2542)

~~Builds off of #2529.~~ Merged.

This Pull Request allows passing any function returning `impl Future<Output = JsResult<JsValue>>` to the `NativeFunction` constructor, allowing native concurrency hooks into the engine.

It changes the following:

- Adds a `NativeFunction::from_async_fn` function.
- Adds a new `JobQueue::enqueue_future_job` method.
- Adds an example usage on `boa_examples`.
pull/2617/head
José Julián Espina 2 years ago
parent
commit
280199b07a
  1. 715
      Cargo.lock
  2. 1
      boa_cli/Cargo.toml
  3. 7
      boa_cli/src/main.rs
  4. 1
      boa_engine/Cargo.toml
  5. 33
      boa_engine/src/builtins/promise/mod.rs
  6. 22
      boa_engine/src/job.rs
  7. 101
      boa_engine/src/native_function.rs
  8. 2
      boa_examples/Cargo.toml
  9. 175
      boa_examples/src/bin/futures.rs
  10. 2
      boa_interner/src/lib.rs

715
Cargo.lock generated

File diff suppressed because it is too large Load Diff

1
boa_cli/Cargo.toml

@ -22,6 +22,7 @@ serde_json = "1.0.93"
colored = "2.0.0" colored = "2.0.0"
regex = "1.7.1" regex = "1.7.1"
phf = { version = "0.11.1", features = ["macros"] } phf = { version = "0.11.1", features = ["macros"] }
pollster = "0.3.0"
[features] [features]
default = ["intl"] default = ["intl"]

7
boa_cli/src/main.rs

@ -63,7 +63,7 @@ mod helper;
use boa_ast::StatementList; use boa_ast::StatementList;
use boa_engine::{ use boa_engine::{
context::ContextBuilder, context::ContextBuilder,
job::{JobQueue, NativeJob}, job::{FutureJob, JobQueue, NativeJob},
vm::flowgraph::{Direction, Graph}, vm::flowgraph::{Direction, Graph},
Context, JsResult, Source, Context, JsResult, Source,
}; };
@ -386,4 +386,9 @@ impl JobQueue for Jobs {
} }
} }
} }
fn enqueue_future_job(&self, future: FutureJob, _: &mut Context<'_>) {
let job = pollster::block_on(future);
self.0.borrow_mut().push_front(job);
}
} }

1
boa_engine/Cargo.toml

@ -64,6 +64,7 @@ static_assertions = "1.1.0"
thiserror = "1.0.38" thiserror = "1.0.38"
dashmap = "5.4.0" dashmap = "5.4.0"
num_enum = "0.5.10" num_enum = "0.5.10"
pollster = "0.3.0"
# intl deps # intl deps
boa_icu_provider = { workspace = true, optional = true } boa_icu_provider = { workspace = true, optional = true }

33
boa_engine/src/builtins/promise/mod.rs

@ -338,16 +338,11 @@ impl BuiltInConstructor for Promise {
let promise = JsObject::from_proto_and_data( let promise = JsObject::from_proto_and_data(
promise, promise,
ObjectData::promise(Self { // 4. Set promise.[[PromiseState]] to pending.
// 4. Set promise.[[PromiseState]] to pending. // 5. Set promise.[[PromiseFulfillReactions]] to a new empty List.
state: PromiseState::Pending, // 6. Set promise.[[PromiseRejectReactions]] to a new empty List.
// 5. Set promise.[[PromiseFulfillReactions]] to a new empty List. // 7. Set promise.[[PromiseIsHandled]] to false.
fulfill_reactions: Vec::new(), ObjectData::promise(Self::new()),
// 6. Set promise.[[PromiseRejectReactions]] to a new empty List.
reject_reactions: Vec::new(),
// 7. Set promise.[[PromiseIsHandled]] to false.
handled: false,
}),
); );
// 8. Let resolvingFunctions be CreateResolvingFunctions(promise). // 8. Let resolvingFunctions be CreateResolvingFunctions(promise).
@ -378,12 +373,22 @@ impl BuiltInConstructor for Promise {
} }
#[derive(Debug)] #[derive(Debug)]
struct ResolvingFunctionsRecord { pub(crate) struct ResolvingFunctionsRecord {
resolve: JsFunction, pub(crate) resolve: JsFunction,
reject: JsFunction, pub(crate) reject: JsFunction,
} }
impl Promise { impl Promise {
/// Creates a new, pending `Promise`.
pub(crate) fn new() -> Self {
Promise {
state: PromiseState::Pending,
fulfill_reactions: Vec::default(),
reject_reactions: Vec::default(),
handled: false,
}
}
/// Gets the current state of the promise. /// Gets the current state of the promise.
pub(crate) const fn state(&self) -> &PromiseState { pub(crate) const fn state(&self) -> &PromiseState {
&self.state &self.state
@ -1266,7 +1271,7 @@ impl Promise {
/// - [ECMAScript reference][spec] /// - [ECMAScript reference][spec]
/// ///
/// [spec]: https://tc39.es/ecma262/#sec-createresolvingfunctions /// [spec]: https://tc39.es/ecma262/#sec-createresolvingfunctions
fn create_resolving_functions( pub(crate) fn create_resolving_functions(
promise: &JsObject, promise: &JsObject,
context: &mut Context<'_>, context: &mut Context<'_>,
) -> ResolvingFunctionsRecord { ) -> ResolvingFunctionsRecord {

22
boa_engine/src/job.rs

@ -17,7 +17,7 @@
//! [Job]: https://tc39.es/ecma262/#sec-jobs //! [Job]: https://tc39.es/ecma262/#sec-jobs
//! [JobCallback]: https://tc39.es/ecma262/#sec-jobcallback-records //! [JobCallback]: https://tc39.es/ecma262/#sec-jobcallback-records
use std::{any::Any, cell::RefCell, collections::VecDeque, fmt::Debug}; use std::{any::Any, cell::RefCell, collections::VecDeque, fmt::Debug, future::Future, pin::Pin};
use crate::{ use crate::{
object::{JsFunction, NativeObject}, object::{JsFunction, NativeObject},
@ -25,6 +25,9 @@ use crate::{
}; };
use boa_gc::{Finalize, Trace}; use boa_gc::{Finalize, Trace};
/// The [`Future`] job passed to the [`JobQueue::enqueue_future_job`] operation.
pub type FutureJob = Pin<Box<dyn Future<Output = NativeJob> + 'static>>;
/// An ECMAScript [Job] closure. /// An ECMAScript [Job] closure.
/// ///
/// The specification allows scheduling any [`NativeJob`] closure by the host into the job queue. /// The specification allows scheduling any [`NativeJob`] closure by the host into the job queue.
@ -86,7 +89,7 @@ impl NativeJob {
} }
} }
/// [`JobCallback`][spec] records /// [`JobCallback`][spec] records.
/// ///
/// [spec]: https://tc39.es/ecma262/#sec-jobcallback-records /// [spec]: https://tc39.es/ecma262/#sec-jobcallback-records
#[derive(Trace, Finalize)] #[derive(Trace, Finalize)]
@ -150,6 +153,14 @@ pub trait JobQueue {
/// determines if the method should loop until there are no more queued jobs or if /// determines if the method should loop until there are no more queued jobs or if
/// it should only run one iteration of the queue. /// it should only run one iteration of the queue.
fn run_jobs(&self, context: &mut Context<'_>); fn run_jobs(&self, context: &mut Context<'_>);
/// Enqueues a new [`Future`] job on the job queue.
///
/// On completion, `future` returns a new [`NativeJob`] that needs to be enqueued into the
/// job queue to update the state of the inner `Promise`, which is what ECMAScript sees. Failing
/// to do this will leave the inner `Promise` in the `pending` state, which won't call any `then`
/// or `catch` handlers, even if `future` was already completed.
fn enqueue_future_job(&self, future: FutureJob, context: &mut Context<'_>);
} }
/// A job queue that does nothing. /// A job queue that does nothing.
@ -165,6 +176,8 @@ impl JobQueue for IdleJobQueue {
fn enqueue_promise_job(&self, _: NativeJob, _: &mut Context<'_>) {} fn enqueue_promise_job(&self, _: NativeJob, _: &mut Context<'_>) {}
fn run_jobs(&self, _: &mut Context<'_>) {} fn run_jobs(&self, _: &mut Context<'_>) {}
fn enqueue_future_job(&self, _: FutureJob, _: &mut Context<'_>) {}
} }
/// A simple FIFO job queue that bails on the first error. /// A simple FIFO job queue that bails on the first error.
@ -217,4 +230,9 @@ impl JobQueue for SimpleJobQueue {
next_job = self.0.borrow_mut().pop_front(); next_job = self.0.borrow_mut().pop_front();
} }
} }
fn enqueue_future_job(&self, future: FutureJob, context: &mut Context<'_>) {
let job = pollster::block_on(future);
self.enqueue_promise_job(job, context);
}
} }

101
boa_engine/src/native_function.rs

@ -3,9 +3,16 @@
//! [`NativeFunction`] is the main type of this module, providing APIs to create native callables //! [`NativeFunction`] is the main type of this module, providing APIs to create native callables
//! from native Rust functions and closures. //! from native Rust functions and closures.
use std::future::Future;
use boa_gc::{custom_trace, Finalize, Gc, Trace}; use boa_gc::{custom_trace, Finalize, Gc, Trace};
use crate::{Context, JsResult, JsValue}; use crate::{
builtins::Promise,
job::NativeJob,
object::{JsObject, ObjectData},
Context, JsResult, JsValue,
};
/// The required signature for all native built-in function pointers. /// The required signature for all native built-in function pointers.
/// ///
@ -113,6 +120,98 @@ impl NativeFunction {
} }
} }
/// Creates a `NativeFunction` from a function returning a [`Future`].
///
/// The returned `NativeFunction` will return an ECMAScript `Promise` that will be fulfilled
/// or rejected when the returned [`Future`] completes.
///
/// # Caveats
///
/// Consider the next snippet:
///
/// ```compile_fail
/// # use boa_engine::{
/// # JsValue,
/// # Context,
/// # JsResult,
/// # NativeFunction
/// # };
/// async fn test(
/// _this: &JsValue,
/// args: &[JsValue],
/// _context: &mut Context<'_>,
/// ) -> JsResult<JsValue> {
/// let arg = args.get(0).cloned();
/// std::future::ready(()).await;
/// drop(arg);
/// Ok(JsValue::null())
/// }
/// NativeFunction::from_async_fn(test);
/// ```
///
/// Seems like a perfectly fine code, right? `args` is not used after the await point, which
/// in theory should make the whole future `'static` ... in theory ...
///
/// This code unfortunately fails to compile at the moment. This is because `rustc` currently
/// cannot determine that `args` can be dropped before the await point, which would trivially
/// make the future `'static`. Track [this issue] for more information.
///
/// In the meantime, a manual desugaring of the async function does the trick:
///
/// ```
/// # use std::future::Future;
/// # use boa_engine::{
/// # JsValue,
/// # Context,
/// # JsResult,
/// # NativeFunction
/// # };
/// fn test(
/// _this: &JsValue,
/// args: &[JsValue],
/// _context: &mut Context<'_>,
/// ) -> impl Future<Output = JsResult<JsValue>> {
/// let arg = args.get(0).cloned();
/// async move {
/// std::future::ready(()).await;
/// drop(arg);
/// Ok(JsValue::null())
/// }
/// }
/// NativeFunction::from_async_fn(test);
/// ```
/// [this issue]: https://github.com/rust-lang/rust/issues/69663
pub fn from_async_fn<Fut>(f: fn(&JsValue, &[JsValue], &mut Context<'_>) -> Fut) -> Self
where
Fut: Future<Output = JsResult<JsValue>> + 'static,
{
Self::from_copy_closure(move |this, args, context| {
let proto = context.intrinsics().constructors().promise().prototype();
let promise = JsObject::from_proto_and_data(proto, ObjectData::promise(Promise::new()));
let resolving_functions = Promise::create_resolving_functions(&promise, context);
let future = f(this, args, context);
let future = async move {
let result = future.await;
NativeJob::new(move |ctx| match result {
Ok(v) => resolving_functions
.resolve
.call(&JsValue::undefined(), &[v], ctx),
Err(e) => {
let e = e.to_opaque(ctx);
resolving_functions
.reject
.call(&JsValue::undefined(), &[e], ctx)
}
})
};
context
.job_queue()
.enqueue_future_job(Box::pin(future), context);
Ok(promise.into())
})
}
/// Creates a `NativeFunction` from a `Copy` closure. /// Creates a `NativeFunction` from a `Copy` closure.
pub fn from_copy_closure<F>(closure: F) -> Self pub fn from_copy_closure<F>(closure: F) -> Self
where where

2
boa_examples/Cargo.toml

@ -17,3 +17,5 @@ boa_ast.workspace = true
boa_interner.workspace = true boa_interner.workspace = true
boa_gc.workspace = true boa_gc.workspace = true
boa_parser.workspace = true boa_parser.workspace = true
smol = "1.3.0"
futures-util = "0.3.25"

175
boa_examples/src/bin/futures.rs

@ -0,0 +1,175 @@
use std::{
cell::{Cell, RefCell},
collections::VecDeque,
time::{Duration, Instant},
};
use boa_engine::{
context::ContextBuilder,
job::{FutureJob, JobQueue, NativeJob},
native_function::NativeFunction,
Context, JsArgs, JsResult, JsValue, Source,
};
use futures_util::{stream::FuturesUnordered, Future};
use smol::{future, stream::StreamExt, LocalExecutor};
/// An event queue that also drives futures to completion.
struct Queue<'a> {
executor: LocalExecutor<'a>,
futures: RefCell<FuturesUnordered<FutureJob>>,
jobs: RefCell<VecDeque<NativeJob>>,
}
impl<'a> Queue<'a> {
fn new(executor: LocalExecutor<'a>) -> Self {
Self {
executor,
futures: RefCell::default(),
jobs: RefCell::default(),
}
}
}
impl<'a> JobQueue for Queue<'a> {
fn enqueue_promise_job(&self, job: NativeJob, _context: &mut boa_engine::Context<'_>) {
self.jobs.borrow_mut().push_back(job);
}
fn enqueue_future_job(&self, future: FutureJob, _context: &mut boa_engine::Context<'_>) {
self.futures.borrow().push(future)
}
fn run_jobs(&self, context: &mut boa_engine::Context<'_>) {
// Early return in case there were no jobs scheduled.
if self.jobs.borrow().is_empty() && self.futures.borrow().is_empty() {
return;
}
let context = RefCell::new(context);
future::block_on(self.executor.run(async move {
// Used to sync the finalization of both tasks
let finished = Cell::new(0b00u8);
let fqueue = async {
loop {
if self.futures.borrow().is_empty() {
finished.set(finished.get() | 0b01);
if finished.get() >= 0b11 {
// All possible futures and jobs were completed. Exit.
return;
}
// All possible jobs were completed, but `jqueue` could have
// pending jobs. Yield to the executor to try to progress on
// `jqueue` until we have more pending futures.
future::yield_now().await;
continue;
}
finished.set(finished.get() & 0b10);
// Blocks on all the enqueued futures, driving them all to completion.
let futures = &mut std::mem::take(&mut *self.futures.borrow_mut());
while let Some(job) = futures.next().await {
// Important to schedule the returned `job` into the job queue, since that's
// what allows updating the `Promise` seen by ECMAScript for when the future
// completes.
self.enqueue_promise_job(job, &mut context.borrow_mut());
}
}
};
let jqueue = async {
loop {
if self.jobs.borrow().is_empty() {
finished.set(finished.get() | 0b10);
if finished.get() >= 0b11 {
// All possible futures and jobs were completed. Exit.
return;
}
// All possible jobs were completed, but `fqueue` could have
// pending futures. Yield to the executor to try to progress on
// `fqueue` until we have more pending jobs.
future::yield_now().await;
continue;
};
finished.set(finished.get() & 0b01);
let jobs = std::mem::take(&mut *self.jobs.borrow_mut());
for job in jobs {
if let Err(e) = job.call(&mut context.borrow_mut()) {
eprintln!("Uncaught {e}");
}
future::yield_now().await;
}
}
};
// Wait for both queues to complete
future::zip(fqueue, jqueue).await;
}))
}
}
// Example async code. Note that the returned future must be 'static.
fn delay(
_this: &JsValue,
args: &[JsValue],
context: &mut Context<'_>,
) -> impl Future<Output = JsResult<JsValue>> {
let millis = args.get_or_undefined(0).to_u32(context);
async move {
let millis = millis?;
println!("Delaying for {millis} milliseconds ...");
let now = Instant::now();
smol::Timer::after(Duration::from_millis(millis as u64)).await;
let elapsed = now.elapsed().as_secs_f64();
Ok(elapsed.into())
}
}
fn main() {
// Initialize the required executors and the context
let executor = LocalExecutor::new();
let queue = Queue::new(executor);
let context = &mut ContextBuilder::new().job_queue(&queue).build().unwrap();
// Bind the defined async function to the ECMAScript function "delay".
context.register_global_builtin_callable("delay", 1, NativeFunction::from_async_fn(delay));
// Multiple calls to multiple async timers.
let script = r#"
function print(elapsed) {
console.log(`Finished. elapsed time: ${elapsed * 1000} ms`)
}
delay(1000).then(print);
delay(500).then(print);
delay(200).then(print);
delay(600).then(print);
delay(30).then(print);
"#;
let now = Instant::now();
context.eval_script(Source::from_bytes(script)).unwrap();
// Important to run this after evaluating, since this is what triggers to run the enqueued jobs.
context.run_jobs();
println!("Total elapsed time: {:?}", now.elapsed());
// Example output:
// Delaying for 1000 milliseconds ...
// Delaying for 500 milliseconds ...
// Delaying for 200 milliseconds ...
// Delaying for 600 milliseconds ...
// Delaying for 30 milliseconds ...
// Finished. elapsed time: 30.073821000000002 ms
// Finished. elapsed time: 200.079116 ms
// Finished. elapsed time: 500.10745099999997 ms
// Finished. elapsed time: 600.098433 ms
// Finished. elapsed time: 1000.118099 ms
// Total elapsed time: 1.002628715s
// The queue concurrently drove several timers to completion!
}

2
boa_interner/src/lib.rs

@ -142,7 +142,7 @@ impl<'a, const N: usize> From<&'a [u16; N]> for JStrRef<'a> {
/// [`JSInternedStrRef::utf8`] returns an [`Option`], since not every `UTF-16` string is fully /// [`JSInternedStrRef::utf8`] returns an [`Option`], since not every `UTF-16` string is fully
/// representable as a `UTF-8` string (because of unpaired surrogates). However, every `UTF-8` /// representable as a `UTF-8` string (because of unpaired surrogates). However, every `UTF-8`
/// string is representable as a `UTF-16` string, so `JSInternedStrRef::utf8` returns a /// string is representable as a `UTF-16` string, so `JSInternedStrRef::utf8` returns a
/// [<code>&\[u16\]</code>][std::slice]. /// [<code>&\[u16\]</code>][core::slice].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JSInternedStrRef<'a, 'b> { pub struct JSInternedStrRef<'a, 'b> {
utf8: Option<&'a str>, utf8: Option<&'a str>,

Loading…
Cancel
Save