Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/engine/src/builtins/iterable/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ fn iterator_concat_return_result_shape() {
}

#[test]
#[cfg(feature = "experimental")]
fn iterator_includes_basic() {
run_test_actions([
TestAction::run("const gen = () => Iterator.from([1, 3]);"),
Expand All @@ -463,6 +464,7 @@ fn iterator_includes_basic() {
}

#[test]
#[cfg(feature = "experimental")]
fn iterator_includes_generator() {
run_test_actions([
TestAction::run("function* gen() { yield 1; yield 3; }"),
Expand All @@ -479,6 +481,7 @@ fn iterator_includes_generator() {
}

#[test]
#[cfg(feature = "experimental")]
fn iterator_includes_errors() {
run_test_actions([
TestAction::run("const gen = () => Iterator.from([1, 3]);"),
Expand Down
89 changes: 62 additions & 27 deletions core/runtime/src/interval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,28 @@ use boa_engine::interop::JsRest;
use boa_engine::job::{CancellationToken, IntervalJob, NativeJobFn};
use boa_engine::job::{NativeJob, TimeoutJob};
use boa_engine::object::builtins::JsFunction;
use boa_engine::value::{IntegerOrInfinity, Nullable};

use boa_engine::{Context, IntoJsFunctionCopied, JsResult, JsValue, js_error, js_string};
use std::collections::HashMap;
use std::num::NonZeroU32;

#[cfg(test)]
mod tests;

/// The internal state of the interval module. The value is whether the interval
/// function is still active.
#[derive(Default)]
struct IntervalInnerState {
active_map: HashMap<u32, CancellationToken>,
id: u32,
active_map: HashMap<NonZeroU32, CancellationToken>,
id: NonZeroU32,
}

impl Default for IntervalInnerState {
fn default() -> Self {
Self {
active_map: HashMap::new(),
id: NonZeroU32::MIN,
}
}
}

impl IntervalInnerState {
Expand All @@ -35,7 +44,7 @@ impl IntervalInnerState {
}

/// Create an interval ID.
fn next_id(&mut self) -> JsResult<u32> {
fn next_id(&mut self) -> JsResult<NonZeroU32> {
self.active_map.retain(|_, v| !v.revoked());
let id = self.id;
self.id = id
Expand All @@ -47,8 +56,14 @@ impl IntervalInnerState {
/// Delete an interval ID from the active map.
fn clear_interval(&mut self, id: u32) -> Option<CancellationToken> {
self.active_map.retain(|_, v| !v.revoked());
let id = NonZeroU32::new(id)?;
self.active_map.remove(&id)
}

/// Drains and returns every active timer/interval token.
fn drain_tokens(&mut self) -> Vec<CancellationToken> {
std::mem::take(&mut self.active_map).into_values().collect()
}
}

/// Set a timeout to call the given function after the given delay.
Expand All @@ -69,13 +84,10 @@ pub fn set_timeout(
return Ok(0);
};

// Spec says if delay is not a number, it should be equal to 0.
let delay = delay_in_msec
.unwrap_or_default()
.to_integer_or_infinity(context)
.unwrap_or(IntegerOrInfinity::Integer(0));
// The spec converts the delay to a 32-bit signed integer.
let delay = u64::from(delay.clamp_finite(0, u32::MAX));
// The spec converts the delay to a WebIDL `long`, which maps to `i32`.
// Negative values are clamped to 0.
let delay_i32 = delay_in_msec.unwrap_or_default().to_i32(context)?;
let delay = u64::from(u32::try_from(delay_i32).unwrap_or(0));

let state = IntervalInnerState::from_context(context);
let id = state.next_id()?;
Expand Down Expand Up @@ -103,7 +115,7 @@ pub fn set_timeout(

context.enqueue_job(job.into());

Ok(id)
Ok(id.get())
}

/// Call a given function on an interval with the given delay.
Expand All @@ -124,12 +136,10 @@ pub fn set_interval(
return Ok(0);
};

// Spec says if delay is not a number, it should be equal to 0.
let delay = delay_in_msec
.unwrap_or_default()
.to_integer_or_infinity(context)
.unwrap_or(IntegerOrInfinity::Integer(0));
let delay = u64::from(delay.clamp_finite(0, u32::MAX));
// The spec converts the delay to a WebIDL `long`, which maps to `i32`.
// Negative values are clamped to 0.
let delay_i32 = delay_in_msec.unwrap_or_default().to_i32(context)?;
let delay = u64::from(u32::try_from(delay_i32).unwrap_or(0));

let state = IntervalInnerState::from_context(context);
let id = state.next_id()?;
Expand All @@ -152,21 +162,46 @@ pub fn set_interval(

context.enqueue_job(job.into());

Ok(id)
Ok(id.get())
}

/// Clears a timeout or interval currently running.
///
/// See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout).
///
/// Please note that this is the same exact method as `clearInterval`, as both can be
/// used interchangeably.
pub fn clear_timeout(id: Nullable<Option<u32>>, context: &mut Context) {
let Some(id) = id.flatten() else {
return;
};
let handler_map = IntervalInnerState::from_context(context);
if let Some(token) = handler_map.clear_interval(id) {
/// used interchangeably. Invalid, zero, or negative IDs are silently ignored,
/// matching browser behavior.
///
/// # Errors
/// Returns an error if the `id` argument cannot be converted to an `i32`.
pub fn clear_timeout(id: Option<JsValue>, context: &mut Context) -> JsResult<JsValue> {
let id = id.unwrap_or_default().to_i32(context)?;
if id > 0 {
let handler_map = IntervalInnerState::from_context(context);
if let Some(token) = handler_map.clear_interval(id.cast_unsigned()) {
token.cancel(context);
}
}
Ok(JsValue::undefined())
}

/// Cancels every currently active timer and interval registered through
/// this module's `setTimeout` / `setInterval`.
///
/// Intended for test teardown and graceful shutdown: after this call, any
/// pending [`boa_engine::job::TimeoutJob`] / [`boa_engine::job::IntervalJob`]
/// in the executor's queue will be skipped on its next tick, allowing
/// `run_jobs_async` to exit naturally without disturbing unrelated
/// `PromiseJob`, `NativeAsyncJob`, or `GenericJob` work.
///
/// Note: timers scheduled *after* this call returns are not affected.
pub fn clear_all(context: &mut Context) {
let state = IntervalInnerState::from_context(context);
// Drain first to avoid re-entrant mutation: each token's cancel callback
// tries to remove its own id from `active_map`.
let tokens = state.drain_tokens();
for token in tokens {
token.cancel(context);
}
}
Expand Down
1 change: 1 addition & 0 deletions tests/wpt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ boa_engine = { path = "../../core/engine" }
boa_gc = { path = "../../core/gc" }
boa_runtime = { path = "../../core/runtime", features = ["all"] }
rstest = "0.25.0"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
url = { version = "2.5.4", features = [] }

[build-dependencies]
Expand Down
50 changes: 29 additions & 21 deletions tests/wpt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,31 +118,19 @@ impl TestSuiteSource {

fn scripts(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut scripts: Vec<String> = Vec::new();
let dir = self
.path
.parent()
.expect("Could not get the parent directory");

'outer: for script in self.meta()?.get("script").unwrap_or(&Vec::new()) {
let script = script
.split_once('?')
.map_or(script.to_string(), |(s, _)| s.to_string());

// Resolve the source path relative to the script path, but under the wpt_path.
let script_path = Path::new(&script);
let path = if script_path.is_relative() {
dir.join(script_path)
} else {
script_path.to_path_buf()
};

for (from, to) in REWRITE_RULES {
if path.to_string_lossy().as_ref() == *from {
if script == *from {
scripts.push((*to).to_string());
continue 'outer;
}
}
scripts.push(path.to_string_lossy().to_string());
scripts.push(script);
}
Ok(scripts)
}
Expand Down Expand Up @@ -259,8 +247,13 @@ fn result_callback__(
}

#[track_caller]
fn complete_callback__(ContextData(test_done): ContextData<TestCompletion>) {
fn complete_callback__(ContextData(test_done): ContextData<TestCompletion>, context: &mut Context) {
test_done.done();
// Cancel only timers/intervals, leaving unrelated PromiseJob / NativeAsyncJob
// work intact. `run_jobs_async` will then exit naturally once the timer
// queue drains. If anything else keeps it alive, that's a real bug worth
// surfacing rather than masking by wholesale cancelling all queued jobs.
boa_runtime::interval::clear_all(context);
}

#[derive(Debug, Clone, Trace, Finalize, JsData)]
Expand All @@ -285,6 +278,10 @@ impl TestCompletion {
// in clippy.
#[allow(unused)]
fn execute_test_file(path: &Path) {
// Workspace `reqwest` uses `rustls-no-provider`; install ring as the
// process-wide CryptoProvider so `BlockingReqwestFetcher` construction
// in `create_context` doesn't panic with `No provider set`.
rustls::crypto::ring::default_provider().install_default().ok();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which test requires this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test runner needs it, not any specific test. Workspace reqwest is pinned with rustls-no-provider so reqwest::blocking::Client panics with No provider set without a CryptoProvider installed. create_context() builds a WptFetcher (and a reqwest Client) for every test so the suite won't run without it.

I removed the line locally to verify, encoding and timers both panic the same way. Checked main too and cargo test encoding fails identically there, so this is a latent harness issue ig, not something this PR introduced.

Same install already exists in cli/src/main.rs and examples/src/bin/module_fetch_async.rs. Happy to split it out or add a comment, whichever works.

let dir = path.parent().unwrap();
let wpt_path = PathBuf::from(
std::env::var("WPT_ROOT").expect("Could not find the WPT_ROOT environment variable"),
Expand Down Expand Up @@ -333,13 +330,10 @@ fn execute_test_file(path: &Path) {
let source = TestSuiteSource::new(path);
for script in source.scripts().expect("Could not get scripts") {
// Resolve the source path relative to the script path, but under the wpt_path.
let script_path = Path::new(&script);
let path = if script_path.is_relative() {
dir.join(script_path)
} else if script_path.starts_with(&wpt_path) {
script_path.to_path_buf()
let path = if script.starts_with('/') {
wpt_path.join(script.strip_prefix('/').unwrap())
} else {
wpt_path.join(script_path.strip_prefix("/").unwrap())
dir.join(&script)
};

let path = path.canonicalize().expect("Could not canonicalize path");
Expand Down Expand Up @@ -440,3 +434,17 @@ fn fetch(
) {
execute_test_file(&path);
}

/// Test the timers with the WPT test suite.
#[cfg(not(clippy))]
#[rstest::rstest]
fn timers(
#[base_dir = "${WPT_ROOT}"]
#[files("html/webappapis/timers/*.any.js")]
#[exclude("idlharness")]
// String-eval form of setTimeout is not implemented in boa_runtime.
#[exclude("evil-spec-example")]
path: PathBuf,
) {
execute_test_file(&path);
}
Loading