From bc96579522f989cff417de085075da94156022ec Mon Sep 17 00:00:00 2001 From: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> Date: Tue, 19 May 2026 00:45:01 +0530 Subject: [PATCH 1/4] fix(runtime): avoid timer ID 0 sentinel collision Signed-off-by: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> --- core/runtime/src/interval.rs | 26 +++++++++---- core/runtime/src/interval/tests.rs | 61 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/core/runtime/src/interval.rs b/core/runtime/src/interval.rs index d408ce2ea49..ab160187028 100644 --- a/core/runtime/src/interval.rs +++ b/core/runtime/src/interval.rs @@ -8,16 +8,25 @@ 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, - id: u32, + active_map: HashMap, + id: NonZeroU32, +} + +impl Default for IntervalInnerState { + fn default() -> Self { + Self { + active_map: HashMap::new(), + id: NonZeroU32::MIN, + } + } } impl IntervalInnerState { @@ -35,7 +44,7 @@ impl IntervalInnerState { } /// Create an interval ID. - fn next_id(&mut self) -> JsResult { + fn next_id(&mut self) -> JsResult { self.active_map.retain(|_, v| !v.revoked()); let id = self.id; self.id = id @@ -45,7 +54,7 @@ impl IntervalInnerState { } /// Delete an interval ID from the active map. - fn clear_interval(&mut self, id: u32) -> Option { + fn clear_interval(&mut self, id: NonZeroU32) -> Option { self.active_map.retain(|_, v| !v.revoked()); self.active_map.remove(&id) } @@ -103,7 +112,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. @@ -152,7 +161,7 @@ pub fn set_interval( context.enqueue_job(job.into()); - Ok(id) + Ok(id.get()) } /// Clears a timeout or interval currently running. @@ -165,6 +174,9 @@ pub fn clear_timeout(id: Nullable>, context: &mut Context) { let Some(id) = id.flatten() else { return; }; + let Some(id) = NonZeroU32::new(id) else { + return; + }; let handler_map = IntervalInnerState::from_context(context); if let Some(token) = handler_map.clear_interval(id) { token.cancel(context); diff --git a/core/runtime/src/interval/tests.rs b/core/runtime/src/interval/tests.rs index c1bf88510ee..31c9fa00092 100644 --- a/core/runtime/src/interval/tests.rs +++ b/core/runtime/src/interval/tests.rs @@ -261,3 +261,64 @@ fn set_interval_delay() { context, ); } + +#[test] +fn timer_ids_are_positive_and_unique() { + let clock = Rc::new(FixedClock::default()); + let context = &mut create_context(clock); + + run_test_actions_with( + [ + TestAction::run(indoc! {r#" + id1 = setTimeout(() => {}, 0); + id2 = setInterval(() => {}, 100); + id3 = setTimeout(() => {}, 0); + "#}), + TestAction::inspect_context(|ctx| { + let id1 = ctx.global_object().get(js_str!("id1"), ctx).unwrap(); + let id2 = ctx.global_object().get(js_str!("id2"), ctx).unwrap(); + let id3 = ctx.global_object().get(js_str!("id3"), ctx).unwrap(); + + let id1 = id1.as_i32().expect("id1 should be an integer"); + let id2 = id2.as_i32().expect("id2 should be an integer"); + let id3 = id3.as_i32().expect("id3 should be an integer"); + + assert!(id1 > 0, "setTimeout must return ID > 0, got {id1}"); + assert!(id2 > 0, "setInterval must return ID > 0, got {id2}"); + assert!(id3 > 0, "subsequent timer ID must be > 0, got {id3}"); + + assert_ne!(id1, id2, "timer IDs must be unique"); + assert_ne!(id2, id3, "timer IDs must be unique"); + assert_ne!(id1, id3, "timer IDs must be unique"); + }), + ], + context, + ); +} + +#[test] +fn no_callback_returns_zero_sentinel() { + let clock = Rc::new(FixedClock::default()); + let context = &mut create_context(clock); + + run_test_actions_with( + [ + TestAction::run(indoc! {r#" + id_valid = setTimeout(() => {}, 0); + id_no_cb = setTimeout(); + "#}), + TestAction::inspect_context(|ctx| { + let id_valid = ctx.global_object().get(js_str!("id_valid"), ctx).unwrap(); + let id_no_cb = ctx.global_object().get(js_str!("id_no_cb"), ctx).unwrap(); + + assert_eq!(id_no_cb.as_i32(), Some(0), "no-callback sentinel must be 0"); + assert_ne!( + id_valid.as_i32(), + Some(0), + "valid timer ID must not collide with sentinel" + ); + }), + ], + context, + ); +} From 156296b55a35732c1d829011edbd4701be858308 Mon Sep 17 00:00:00 2001 From: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> Date: Wed, 20 May 2026 15:01:36 +0530 Subject: [PATCH 2/4] fix(runtime): fix timer IDs, enable WPT timers, and resolve clippy issues Signed-off-by: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> --- core/engine/src/builtins/iterable/tests.rs | 3 ++ core/engine/src/context/mod.rs | 6 +++ core/engine/src/job.rs | 8 +++ core/runtime/src/interval.rs | 50 +++++++++--------- core/runtime/src/interval/tests.rs | 61 ---------------------- tests/wpt/Cargo.toml | 1 + tests/wpt/src/lib.rs | 45 ++++++++-------- 7 files changed, 65 insertions(+), 109 deletions(-) diff --git a/core/engine/src/builtins/iterable/tests.rs b/core/engine/src/builtins/iterable/tests.rs index 2cb898ab76b..b19660f2947 100644 --- a/core/engine/src/builtins/iterable/tests.rs +++ b/core/engine/src/builtins/iterable/tests.rs @@ -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]);"), @@ -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; }"), @@ -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]);"), diff --git a/core/engine/src/context/mod.rs b/core/engine/src/context/mod.rs index 78453d26078..02b6d3ce81d 100644 --- a/core/engine/src/context/mod.rs +++ b/core/engine/src/context/mod.rs @@ -500,6 +500,12 @@ impl Context { self.job_executor().run_jobs(self) } + /// Clears all queued jobs from the job executor. + #[inline] + pub fn clear_jobs(&mut self) { + self.job_executor().clear_jobs(); + } + /// Abstract operation [`ClearKeptObjects`][clear]. /// /// Clears all objects maintained alive by calls to the [`AddToKeptObjects`][add] abstract diff --git a/core/engine/src/job.rs b/core/engine/src/job.rs index 58f514772bc..91aa6dc750a 100644 --- a/core/engine/src/job.rs +++ b/core/engine/src/job.rs @@ -815,6 +815,9 @@ pub trait JobExecutor: Any { { self.run_jobs(&mut context.borrow_mut()) } + + /// Clears all queued jobs. + fn clear_jobs(&self) {} } /// A job executor that does nothing. @@ -946,6 +949,11 @@ impl JobExecutor for SimpleJobExecutor { future::block_on(self.run_jobs_async(&RefCell::new(context))) } + fn clear_jobs(&self) { + self.clear(); + self.stop.store(true, Ordering::Release); + } + async fn run_jobs_async(self: Rc, context: &RefCell<&mut Context>) -> JsResult<()> where Self: Sized, diff --git a/core/runtime/src/interval.rs b/core/runtime/src/interval.rs index ab160187028..89050321038 100644 --- a/core/runtime/src/interval.rs +++ b/core/runtime/src/interval.rs @@ -5,7 +5,7 @@ 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; @@ -54,8 +54,9 @@ impl IntervalInnerState { } /// Delete an interval ID from the active map. - fn clear_interval(&mut self, id: NonZeroU32) -> Option { + fn clear_interval(&mut self, id: u32) -> Option { self.active_map.retain(|_, v| !v.revoked()); + let id = NonZeroU32::new(id)?; self.active_map.remove(&id) } } @@ -78,13 +79,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()?; @@ -133,12 +131,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()?; @@ -169,18 +165,20 @@ pub fn set_interval( /// 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>, context: &mut Context) { - let Some(id) = id.flatten() else { - return; - }; - let Some(id) = NonZeroU32::new(id) else { - return; - }; - let handler_map = IntervalInnerState::from_context(context); - if let Some(token) = handler_map.clear_interval(id) { - token.cancel(context); +/// 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, context: &mut Context) -> JsResult { + 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()) } /// Register the interval module into the given context. diff --git a/core/runtime/src/interval/tests.rs b/core/runtime/src/interval/tests.rs index 31c9fa00092..c1bf88510ee 100644 --- a/core/runtime/src/interval/tests.rs +++ b/core/runtime/src/interval/tests.rs @@ -261,64 +261,3 @@ fn set_interval_delay() { context, ); } - -#[test] -fn timer_ids_are_positive_and_unique() { - let clock = Rc::new(FixedClock::default()); - let context = &mut create_context(clock); - - run_test_actions_with( - [ - TestAction::run(indoc! {r#" - id1 = setTimeout(() => {}, 0); - id2 = setInterval(() => {}, 100); - id3 = setTimeout(() => {}, 0); - "#}), - TestAction::inspect_context(|ctx| { - let id1 = ctx.global_object().get(js_str!("id1"), ctx).unwrap(); - let id2 = ctx.global_object().get(js_str!("id2"), ctx).unwrap(); - let id3 = ctx.global_object().get(js_str!("id3"), ctx).unwrap(); - - let id1 = id1.as_i32().expect("id1 should be an integer"); - let id2 = id2.as_i32().expect("id2 should be an integer"); - let id3 = id3.as_i32().expect("id3 should be an integer"); - - assert!(id1 > 0, "setTimeout must return ID > 0, got {id1}"); - assert!(id2 > 0, "setInterval must return ID > 0, got {id2}"); - assert!(id3 > 0, "subsequent timer ID must be > 0, got {id3}"); - - assert_ne!(id1, id2, "timer IDs must be unique"); - assert_ne!(id2, id3, "timer IDs must be unique"); - assert_ne!(id1, id3, "timer IDs must be unique"); - }), - ], - context, - ); -} - -#[test] -fn no_callback_returns_zero_sentinel() { - let clock = Rc::new(FixedClock::default()); - let context = &mut create_context(clock); - - run_test_actions_with( - [ - TestAction::run(indoc! {r#" - id_valid = setTimeout(() => {}, 0); - id_no_cb = setTimeout(); - "#}), - TestAction::inspect_context(|ctx| { - let id_valid = ctx.global_object().get(js_str!("id_valid"), ctx).unwrap(); - let id_no_cb = ctx.global_object().get(js_str!("id_no_cb"), ctx).unwrap(); - - assert_eq!(id_no_cb.as_i32(), Some(0), "no-callback sentinel must be 0"); - assert_ne!( - id_valid.as_i32(), - Some(0), - "valid timer ID must not collide with sentinel" - ); - }), - ], - context, - ); -} diff --git a/tests/wpt/Cargo.toml b/tests/wpt/Cargo.toml index 4fbf07b47c7..602868678cc 100644 --- a/tests/wpt/Cargo.toml +++ b/tests/wpt/Cargo.toml @@ -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] diff --git a/tests/wpt/src/lib.rs b/tests/wpt/src/lib.rs index 0f57721c4e5..1a7d22ffbd9 100644 --- a/tests/wpt/src/lib.rs +++ b/tests/wpt/src/lib.rs @@ -118,31 +118,19 @@ impl TestSuiteSource { fn scripts(&self) -> Result, Box> { let mut scripts: Vec = 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) } @@ -259,8 +247,9 @@ fn result_callback__( } #[track_caller] -fn complete_callback__(ContextData(test_done): ContextData) { +fn complete_callback__(ContextData(test_done): ContextData, context: &mut Context) { test_done.done(); + context.clear_jobs(); } #[derive(Debug, Clone, Trace, Finalize, JsData)] @@ -285,6 +274,7 @@ impl TestCompletion { // in clippy. #[allow(unused)] fn execute_test_file(path: &Path) { + rustls::crypto::ring::default_provider().install_default().ok(); let dir = path.parent().unwrap(); let wpt_path = PathBuf::from( std::env::var("WPT_ROOT").expect("Could not find the WPT_ROOT environment variable"), @@ -333,13 +323,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"); @@ -398,8 +385,8 @@ fn console( fn encoding( #[base_dir = "${WPT_ROOT}"] #[files("encoding/api-*.any.js")] - #[files("encoding/textencoder-constructor-non-utf.any.js")] // TODO: re-enable those when better encoding and options are supported. + // #[files("encoding/textencoder-constructor-non-utf.any.js")] // #[files("encoding/textdecoder-*.any.js")] // #[files("encoding/textencoder-*.any.js")] #[exclude("idlharness")] @@ -440,3 +427,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); +} From bd0bd90e393aa2af95952efb4725cf52523a914f Mon Sep 17 00:00:00 2001 From: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> Date: Thu, 28 May 2026 04:14:01 +0530 Subject: [PATCH 3/4] Address reviewer feedback: add clear_all for WPT timers and remove duplicate WPT path Signed-off-by: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> --- core/engine/src/context/mod.rs | 6 ------ core/engine/src/job.rs | 8 -------- core/runtime/src/interval.rs | 25 +++++++++++++++++++++++++ tests/wpt/src/lib.rs | 11 +++++++++-- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/core/engine/src/context/mod.rs b/core/engine/src/context/mod.rs index 02b6d3ce81d..78453d26078 100644 --- a/core/engine/src/context/mod.rs +++ b/core/engine/src/context/mod.rs @@ -500,12 +500,6 @@ impl Context { self.job_executor().run_jobs(self) } - /// Clears all queued jobs from the job executor. - #[inline] - pub fn clear_jobs(&mut self) { - self.job_executor().clear_jobs(); - } - /// Abstract operation [`ClearKeptObjects`][clear]. /// /// Clears all objects maintained alive by calls to the [`AddToKeptObjects`][add] abstract diff --git a/core/engine/src/job.rs b/core/engine/src/job.rs index 91aa6dc750a..58f514772bc 100644 --- a/core/engine/src/job.rs +++ b/core/engine/src/job.rs @@ -815,9 +815,6 @@ pub trait JobExecutor: Any { { self.run_jobs(&mut context.borrow_mut()) } - - /// Clears all queued jobs. - fn clear_jobs(&self) {} } /// A job executor that does nothing. @@ -949,11 +946,6 @@ impl JobExecutor for SimpleJobExecutor { future::block_on(self.run_jobs_async(&RefCell::new(context))) } - fn clear_jobs(&self) { - self.clear(); - self.stop.store(true, Ordering::Release); - } - async fn run_jobs_async(self: Rc, context: &RefCell<&mut Context>) -> JsResult<()> where Self: Sized, diff --git a/core/runtime/src/interval.rs b/core/runtime/src/interval.rs index 89050321038..247aab04a37 100644 --- a/core/runtime/src/interval.rs +++ b/core/runtime/src/interval.rs @@ -59,6 +59,11 @@ impl IntervalInnerState { let id = NonZeroU32::new(id)?; self.active_map.remove(&id) } + + /// Drains and returns every active timer/interval token. + fn drain_tokens(&mut self) -> Vec { + std::mem::take(&mut self.active_map).into_values().collect() + } } /// Set a timeout to call the given function after the given delay. @@ -181,6 +186,26 @@ pub fn clear_timeout(id: Option, context: &mut Context) -> JsResult, context: &mut Context) { test_done.done(); - context.clear_jobs(); + // 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)] @@ -274,6 +278,9 @@ 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(); let dir = path.parent().unwrap(); let wpt_path = PathBuf::from( @@ -385,8 +392,8 @@ fn console( fn encoding( #[base_dir = "${WPT_ROOT}"] #[files("encoding/api-*.any.js")] + #[files("encoding/textencoder-constructor-non-utf.any.js")] // TODO: re-enable those when better encoding and options are supported. - // #[files("encoding/textencoder-constructor-non-utf.any.js")] // #[files("encoding/textdecoder-*.any.js")] // #[files("encoding/textencoder-*.any.js")] #[exclude("idlharness")] From fa84e1d763993a933bd2ea20bb554bfc2758f4a1 Mon Sep 17 00:00:00 2001 From: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> Date: Fri, 29 May 2026 18:24:29 +0530 Subject: [PATCH 4/4] add coverage for interval::clear_all Signed-off-by: iammdzaidalam <161572905+iammdzaidalam@users.noreply.github.com> --- core/runtime/src/interval/tests.rs | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/core/runtime/src/interval/tests.rs b/core/runtime/src/interval/tests.rs index c1bf88510ee..e3925da74f3 100644 --- a/core/runtime/src/interval/tests.rs +++ b/core/runtime/src/interval/tests.rs @@ -261,3 +261,70 @@ fn set_interval_delay() { context, ); } + +#[test] +fn clear_all_cancels_pending_timers_and_intervals() { + let clock = Rc::new(FixedClock::default()); + let context = &mut create_context(clock.clone()); + + run_test_actions_with( + [ + // Arm one of each timer kind: a one-shot `setTimeout` and a + // recurring `setInterval`, both due at t+100ms. `clear_all` must + // cancel both, regardless of whether a timer fires once or repeats. + TestAction::run(indoc! {r#" + timeout_fired = false; + interval_fired = false; + setTimeout(() => { timeout_fired = true; }, 100); + setInterval(() => { interval_fired = true; }, 100); + "#}), + TestAction::inspect_context_async(async move |ctx| { + let job_executor = ctx.downcast_job_executor::().unwrap(); + let ctx = &RefCell::new(ctx); + + // Drive the event loop once. With the clock still short of the + // 100ms deadline, both timers stay pending and the loop parks + // (`is_none`) rather than completing. + let mut event_loop = pin!(poll_once(job_executor.run_jobs_async(ctx))); + clock.forward(50); + assert!( + event_loop.as_mut().await.is_none(), + "timers are not yet due; event loop must keep waiting", + ); + + // Cancel everything while the jobs are still queued. This is + // the behaviour under test. + interval::clear_all(&mut ctx.borrow_mut()); + + // Advance well past both deadlines. Because the queue was + // drained, no callback runs and the loop has no remaining work, + // so `run_jobs_async` completes (`is_some`) on its next tick. + clock.forward(100); + assert!( + event_loop.as_mut().await.is_some(), + "after clear_all the queue is empty; event loop must exit", + ); + + // Neither callback should have produced an observable effect. + let global = ctx.borrow().global_object(); + let timeout_fired = global + .get(js_str!("timeout_fired"), &mut ctx.borrow_mut()) + .unwrap(); + let interval_fired = global + .get(js_str!("interval_fired"), &mut ctx.borrow_mut()) + .unwrap(); + assert_eq!( + timeout_fired.as_boolean(), + Some(false), + "cleared setTimeout callback must not run", + ); + assert_eq!( + interval_fired.as_boolean(), + Some(false), + "cleared setInterval callback must not run", + ); + }), + ], + context, + ); +}