Skip to content

Commit 15c713a

Browse files
authored
Merge pull request #308 from obeli-sk/wasi-job-executor-sleep
wasi job executor sleep
2 parents 7f3b3ff + 4024b18 commit 15c713a

5 files changed

Lines changed: 400 additions & 324 deletions

File tree

crates/activity-js-runtime/src/activity_js_runtime.rs

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,10 @@ use crate::generated::{
1111
obelisk::log::log::error as host_fn_error,
1212
};
1313
use boa_common::console::{ObeliskLogger, setup_console};
14-
use boa_common::esm::{EsmError, get_default_export};
14+
use boa_common::esm::{EsmError, get_default_export, resolve_promise};
1515
use boa_common::wasi_fetcher::WasiFetcher;
1616
use boa_common::wasi_job_executor::WasiJobExecutor;
17-
use boa_engine::{
18-
Context, JsError, JsResult, JsValue, Source, builtins::promise::PromiseState,
19-
object::builtins::JsPromise,
20-
};
17+
use boa_engine::{Context, JsResult, JsValue, Source};
2118
use boa_runtime::extensions::FetchExtension;
2219
use std::cell::RefCell;
2320
use std::rc::Rc;
@@ -64,8 +61,21 @@ pub fn execute(
6461
// Set up fetch
6562
setup_fetch(&mut context).expect("fetch setup must work");
6663

64+
// Run the async execution inside a single wstd reactor
65+
wstd::runtime::block_on(execute_async(js_code, params_json, &mut context, &executor))
66+
}
67+
68+
/// Async implementation of JS execution.
69+
async fn execute_async(
70+
js_code: &str,
71+
params_json: &[String],
72+
context: &mut Context,
73+
executor: &Rc<WasiJobExecutor>,
74+
) -> Result<Result<String, String>, JsRuntimeError> {
75+
let context = RefCell::new(context);
76+
6777
// Get the default export function from the ES module
68-
let default_fn = match get_default_export(js_code, &mut context) {
78+
let default_fn = match get_default_export(js_code, &context, executor).await {
6979
Ok(func) => func,
7080
Err(EsmError::ParseError(msg)) => {
7181
host_fn_error(&format!("module parse error: {msg}"));
@@ -95,20 +105,29 @@ pub fn execute(
95105
.map(|param| {
96106
// Each param is a JSON value — parse it as a JS value
97107
context
108+
.borrow_mut()
98109
.eval(Source::from_bytes(param))
99110
.expect("already verified that params_json elements are parseable")
100111
})
101112
.collect();
102113

103114
// Call the default export function with the params
104-
let result = default_fn.call(&JsValue::undefined(), &args, &mut context);
115+
let result = default_fn.call(&JsValue::undefined(), &args, *context.borrow_mut());
105116

106117
// If the result is a Promise, drive it to completion.
107118
let result = match result {
108-
Ok(ref js_value) => resolve_if_promise(js_value, &mut context, &executor),
119+
Ok(ref js_value) => resolve_promise(js_value, &context, executor).await,
109120
err => err,
110121
};
111122

123+
convert_result(result, &context)
124+
}
125+
126+
/// Convert JS result to Rust result.
127+
fn convert_result(
128+
result: JsResult<JsValue>,
129+
context: &RefCell<&mut Context>,
130+
) -> Result<Result<String, String>, JsRuntimeError> {
112131
match result {
113132
Ok(js_value) => {
114133
if let Some(string) = js_value.as_string() {
@@ -120,7 +139,7 @@ pub fn execute(
120139
}
121140
}
122141
Err(js_err) => {
123-
if let Ok(native_err) = js_err.try_native(&mut context) {
142+
if let Ok(native_err) = js_err.try_native(*context.borrow_mut()) {
124143
// `throw new Error('foo')` goes here
125144
Ok(Err(native_err.message().to_string()))
126145
} else if let Some(err_str) = extract_error_string(&js_err) {
@@ -146,47 +165,6 @@ fn extract_error_string(err: &boa_engine::JsError) -> Option<String> {
146165
None
147166
}
148167

149-
/// If `value` is a Promise, drive it to completion and return the resolved value.
150-
///
151-
/// We avoid [`JsPromise::await_blocking`] because it creates a tight loop calling
152-
/// `run_jobs()` → `wstd::runtime::block_on()` repeatedly. Each `block_on` creates a
153-
/// new wstd reactor that exits immediately when no pollables are pending (e.g. when
154-
/// only synchronous promise microtasks remain). This busy-loop starves the tokio
155-
/// runtime, preventing wiremock (or any other async task) from running on
156-
/// single-threaded tokio.
157-
///
158-
/// Instead, we drive the entire promise resolution inside a **single**
159-
/// `wstd::runtime::block_on` call. The wstd reactor persists across all job iterations,
160-
/// so WASIp2 pollables registered by `fetch()` are properly tracked and the reactor
161-
/// blocks on them via `wasi:io/poll::poll`, yielding the wasmtime fiber to tokio.
162-
fn resolve_if_promise(
163-
value: &JsValue,
164-
context: &mut Context,
165-
executor: &Rc<WasiJobExecutor>,
166-
) -> JsResult<JsValue> {
167-
let Some(object) = value.as_object() else {
168-
return Ok(value.clone());
169-
};
170-
let Ok(promise) = JsPromise::from_object(object) else {
171-
return Ok(value.clone());
172-
};
173-
174-
// Drive promise resolution inside a single wstd reactor.
175-
let executor = executor.clone();
176-
wstd::runtime::block_on(async {
177-
let context = RefCell::new(context);
178-
loop {
179-
match promise.state() {
180-
PromiseState::Pending => {
181-
executor.clone().drive_jobs(&context).await?;
182-
}
183-
PromiseState::Fulfilled(v) => return Ok(v),
184-
PromiseState::Rejected(e) => return Err(JsError::from_opaque(e)),
185-
}
186-
}
187-
})
188-
}
189-
190168
/// Register the `fetch` API backed by WASIp2 HTTP.
191169
fn setup_fetch(context: &mut Context) -> JsResult<()> {
192170
boa_runtime::register(FetchExtension(WasiFetcher), None, context)

crates/boa-common/src/esm.rs

Lines changed: 45 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
55
use crate::wasi_job_executor::WasiJobExecutor;
66
use boa_engine::{
7-
Context, JsError, Source,
7+
Context, JsError, JsResult, JsValue, Source,
88
builtins::promise::PromiseState,
9-
module::{IdleModuleLoader, Module},
10-
object::builtins::JsFunction,
9+
module::Module,
10+
object::builtins::{JsFunction, JsPromise},
1111
};
1212
use std::cell::RefCell;
1313
use std::rc::Rc;
@@ -39,102 +39,21 @@ impl EsmError {
3939
/// Parse an ES module and extract its default export as a callable function.
4040
///
4141
/// This function:
42-
/// 1. Uses `IdleModuleLoader` (rejects all imports - fail fast)
43-
/// 2. Parses the JS code as an ES Module
44-
/// 3. Loads module dependencies (should resolve immediately with no imports)
45-
/// 4. Links the module
46-
/// 5. Evaluates the module
47-
/// 6. Extracts the `default` export from the module namespace
48-
/// 7. Verifies it's a callable function
42+
/// 1. Parses the JS code as an ES Module
43+
/// 2. Loads module dependencies (should resolve immediately with no imports)
44+
/// 3. Links the module
45+
/// 4. Evaluates the module
46+
/// 5. Extracts the `default` export from the module namespace
47+
/// 6. Verifies it's a callable function
4948
///
5049
/// # Arguments
5150
/// * `js_code` - JavaScript source code with `export default function(...) { ... }`
52-
/// * `context` - Boa JS context (must be configured before calling)
51+
/// * `context` - Boa JS context wrapped in RefCell
52+
/// * `executor` - The WasiJobExecutor for driving async jobs
5353
///
5454
/// # Returns
5555
/// The default export as a `JsFunction`, or an `EsmError` if any step fails.
56-
pub fn get_default_export(js_code: &str, context: &mut Context) -> Result<JsFunction, EsmError> {
57-
// Ensure we use IdleModuleLoader which rejects any imports
58-
// Note: The context should already be configured with IdleModuleLoader by default,
59-
// but we explicitly set it here to be safe.
60-
let _loader = Rc::new(IdleModuleLoader);
61-
// Context's module_loader is already set in the builder, we rely on that.
62-
63-
// 1. Parse the JS code as an ES Module
64-
let module = Module::parse(Source::from_bytes(js_code), None, context)
65-
.map_err(|err| EsmError::from_js_error(err, EsmError::ParseError))?;
66-
67-
// 2. Load module dependencies
68-
let load_promise = module.load(context);
69-
70-
// Drive the load promise to completion (should resolve immediately with no imports)
71-
context
72-
.run_jobs()
73-
.map_err(|err| EsmError::from_js_error(err, EsmError::LoadError))?;
74-
75-
match load_promise.state() {
76-
PromiseState::Fulfilled(_) => {}
77-
PromiseState::Rejected(err) => {
78-
return Err(EsmError::LoadError(JsError::from_opaque(err).to_string()));
79-
}
80-
PromiseState::Pending => {
81-
return Err(EsmError::LoadError(
82-
"module load promise is still pending".to_string(),
83-
));
84-
}
85-
}
86-
87-
// 3. Link the module
88-
module
89-
.link(context)
90-
.map_err(|err| EsmError::from_js_error(err, EsmError::LinkError))?;
91-
92-
// 4. Evaluate the module
93-
let eval_promise = module.evaluate(context);
94-
95-
// Drive the evaluate promise to completion
96-
context
97-
.run_jobs()
98-
.map_err(|err| EsmError::from_js_error(err, EsmError::EvalError))?;
99-
100-
match eval_promise.state() {
101-
PromiseState::Fulfilled(_) => {}
102-
PromiseState::Rejected(err) => {
103-
return Err(EsmError::EvalError(JsError::from_opaque(err).to_string()));
104-
}
105-
PromiseState::Pending => {
106-
return Err(EsmError::EvalError(
107-
"module evaluate promise is still pending".to_string(),
108-
));
109-
}
110-
}
111-
112-
// 5. Get the module namespace and extract the default export
113-
let namespace = module.namespace(context);
114-
let default_export = namespace
115-
.get(boa_engine::js_string!("default"), context)
116-
.map_err(|err| EsmError::from_js_error(err, EsmError::EvalError))?;
117-
118-
// 6. Check if default export exists and is undefined
119-
if default_export.is_undefined() {
120-
return Err(EsmError::NoDefaultExport);
121-
}
122-
123-
// 7. Verify it's a callable function
124-
let Some(func) = default_export.as_callable() else {
125-
return Err(EsmError::DefaultNotCallable);
126-
};
127-
128-
// Convert JsObject to JsFunction
129-
JsFunction::from_object(func.clone()).ok_or(EsmError::DefaultNotCallable)
130-
}
131-
132-
/// Async version of [`get_default_export`] for use in async contexts.
133-
///
134-
/// This version uses `WasiJobExecutor::drive_jobs` instead of `context.run_jobs()`,
135-
/// which avoids nesting `block_on` calls when already inside wstd's async runtime
136-
/// (e.g., in webhook handlers).
137-
pub async fn get_default_export_async(
56+
pub async fn get_default_export(
13857
js_code: &str,
13958
context: &RefCell<&mut Context>,
14059
executor: &Rc<WasiJobExecutor>,
@@ -210,3 +129,36 @@ pub async fn get_default_export_async(
210129

211130
JsFunction::from_object(func.clone()).ok_or(EsmError::DefaultNotCallable)
212131
}
132+
133+
/// If `value` is a Promise, drive it to completion and return the resolved value.
134+
///
135+
/// This function drives the executor until the specific promise resolves,
136+
/// then returns immediately (abandoning any orphaned jobs like unwaited timers).
137+
pub async fn resolve_promise(
138+
value: &JsValue,
139+
context: &RefCell<&mut Context>,
140+
executor: &Rc<WasiJobExecutor>,
141+
) -> JsResult<JsValue> {
142+
let Some(object) = value.as_object() else {
143+
return Ok(value.clone());
144+
};
145+
let Ok(promise) = JsPromise::from_object(object) else {
146+
return Ok(value.clone());
147+
};
148+
149+
// Drive jobs until this specific promise resolves, then stop immediately.
150+
// This abandons orphaned jobs (like unwaited setTimeout callbacks).
151+
executor
152+
.clone()
153+
.drive_jobs_until(context, || {
154+
!matches!(promise.state(), PromiseState::Pending)
155+
})
156+
.await?;
157+
158+
// Return the resolved value
159+
match promise.state() {
160+
PromiseState::Fulfilled(v) => Ok(v),
161+
PromiseState::Rejected(e) => Err(JsError::from_opaque(e)),
162+
PromiseState::Pending => unreachable!("promise should be resolved after drive_jobs_until"),
163+
}
164+
}

0 commit comments

Comments
 (0)