Repository: denoland/deno_core
Pull request #384 (2023-12-12 01:43:10)

refactor(core): clean up timer and promise code 
Deno PR: https://github.com/denoland/deno/pull/21519

Note: `PollEventLoopOptions` was changed so the defaults are now the ones used in the majority of cases.

Started working on cleaning up the call_and_await and resolve_value code in JsRuntime. I managed to turn these into pure futures that will resolve independently of the event loop (though still requiring someone to poll it), which means that you can now do something like this:

```
#[op2]
fn call(scope: &mut v8::HandleScope, #[global] f: v8::Global<v8::Function>) -> impl Future<...> {
  JsRuntime::scoped_call(f).await
}
```

This should allow for some simplification of any code that's currently using `call_and_await`.  

`with_event_loop` is split into a "promise" and "future" version. The promise version will throw if the event loop terminates without the promise being resolved. The future version will continue polling until the future resolves (even if the event loop completes). This matches the current behaviour that we are relying on in deno.



Patch:
diff --git a/core/benches/ops/async.rs b/core/benches/ops/async.rs
index 4f06e75..05d5b7e 100644
--- a/core/benches/ops/async.rs
+++ b/core/benches/ops/async.rs
@@ -142,6 +142,7 @@ fn bench_op(
     .unwrap();
   let guard = tokio.enter();
   let run = runtime.execute_script("", ascii_str!("run()")).unwrap();
+  #[allow(deprecated)]
   let bench = tokio.block_on(runtime.resolve_value(run)).unwrap();
   let mut scope = runtime.handle_scope();
   let bench: v8::Local<v8::Function> =
@@ -152,7 +153,11 @@ fn bench_op(
   b.iter(move || {
     tokio.block_on(async {
       let guard = tokio.enter();
-      runtime.call_and_await(&bench).await.unwrap();
+      let call = runtime.call(&bench);
+      runtime
+        .with_event_loop_promise(call, PollEventLoopOptions::default())
+        .await
+        .unwrap();
       drop(guard);
     });
   });
diff --git a/core/examples/schedule_task.rs b/core/examples/schedule_task.rs
deleted file mode 100644
index fbb78a1..0000000
--- a/core/examples/schedule_task.rs
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
-
-use deno_core::anyhow::Error;
-use deno_core::op2;
-use deno_core::Extension;
-use deno_core::JsRuntime;
-use deno_core::Op;
-use deno_core::OpState;
-use deno_core::RuntimeOptions;
-use futures::channel::mpsc;
-use futures::stream::StreamExt;
-use std::task::Poll;
-
-type Task = Box<dyn FnOnce()>;
-
-fn main() {
-  let my_ext = Extension {
-    name: "my_ext",
-    ops: std::borrow::Cow::Borrowed(&[op_schedule_task::DECL]),
-    event_loop_middleware: Some(|state_rc, cx| {
-      let mut state = state_rc.borrow_mut();
-      let recv = state.borrow_mut::<mpsc::UnboundedReceiver<Task>>();
-      let mut ref_loop = false;
-      while let Poll::Ready(Some(call)) = recv.poll_next_unpin(cx) {
-        call();
-        ref_loop = true; // `call` can callback into runtime and schedule new callbacks :-)
-      }
-      ref_loop
-    }),
-    op_state_fn: Some(Box::new(move |state| {
-      let (tx, rx) = mpsc::unbounded::<Task>();
-      state.put(tx);
-      state.put(rx);
-    })),
-    ..Default::default()
-  };
-
-  // Initialize a runtime instance
-  let mut js_runtime = JsRuntime::new(RuntimeOptions {
-    extensions: vec![my_ext],
-    ..Default::default()
-  });
-  let runtime = tokio::runtime::Builder::new_current_thread()
-    .enable_all()
-    .build()
-    .unwrap();
-
-  let future = async move {
-    // Schedule 10 tasks.
-    js_runtime
-      .execute_script_static(
-        "<usage>",
-        r#"for (let i = 1; i <= 10; i++) Deno.core.ops.op_schedule_task(i);"#,
-      )
-      .unwrap();
-    js_runtime.run_event_loop(Default::default()).await
-  };
-  runtime.block_on(future).unwrap();
-}
-
-#[op2(fast)]
-fn op_schedule_task(state: &mut OpState, i: u8) -> Result<(), Error> {
-  let tx = state.borrow_mut::<mpsc::UnboundedSender<Task>>();
-  tx.unbounded_send(Box::new(move || println!("Hello, world! x{i}")))
-    .expect("unbounded_send failed");
-  Ok(())
-}
diff --git a/core/extensions.rs b/core/extensions.rs
index 1a73491..535a4fd 100644
--- a/core/extensions.rs
+++ b/core/extensions.rs
@@ -4,10 +4,7 @@ use crate::OpState;
 use anyhow::Context as _;
 use anyhow::Error;
 use std::borrow::Cow;
-use std::cell::RefCell;
-use std::rc::Rc;
 use std::sync::Arc;
-use std::task::Context;
 use v8::fast_api::FastFunction;
 use v8::ExternalReference;
 
@@ -82,7 +79,6 @@ pub trait Op {
   const NAME: &'static str;
   const DECL: OpDecl;
 }
-pub type EventLoopMiddlewareFn = fn(Rc<RefCell<OpState>>, &mut Context) -> bool;
 pub type GlobalTemplateMiddlewareFn =
   for<'s> fn(
     &mut v8::HandleScope<'s, ()>,
@@ -276,7 +272,6 @@ macro_rules! or {
 ///  * config: a structure-like definition for configuration parameters which will be required when initializing this extension, eg: `config = { my_param: Option<usize> }`
 ///  * middleware: an [`OpDecl`] middleware function with the signature `fn (OpDecl) -> OpDecl`
 ///  * state: a state initialization function, with the signature `fn (&mut OpState, ...) -> ()`, where `...` are parameters matching the fields of the config struct
-///  * event_loop_middleware: an event-loop middleware function (see [`ExtensionBuilder::event_loop_middleware`])
 ///  * global_template_middleware: a global template middleware function (see [`ExtensionBuilder::global_template_middleware`])
 ///  * global_object_middleware: a global object middleware function (see [`ExtensionBuilder::global_object_middleware`])
 ///  * docs: comma separated list of toplevel #[doc=...] tags to be applied to the extension's resulting struct
@@ -296,7 +291,6 @@ macro_rules! extension {
     $(, options = { $( $options_id:ident : $options_type:ty ),* $(,)? } )?
     $(, middleware = $middleware_fn:expr )?
     $(, state = $state_fn:expr )?
-    $(, event_loop_middleware = $event_loop_middleware_fn:expr )?
     $(, global_template_middleware = $global_template_middleware_fn:expr )?
     $(, global_object_middleware = $global_object_middleware_fn:expr )?
     $(, external_references = [ $( $external_reference:expr ),* $(,)? ] )?
@@ -354,7 +348,6 @@ macro_rules! extension {
             $( $op )::+ $( :: < $($op_param),* > )? :: DECL
           ),+)?]),
           external_references: std::borrow::Cow::Borrowed(&[ $( $external_reference ),* ]),
-          event_loop_middleware: None,
           global_template_middleware: None,
           global_object_middleware: None,
           // Computed at runtime:
@@ -382,10 +375,6 @@ macro_rules! extension {
       {
         $crate::extension!(! __config__ ext $( parameters = [ $( $param : $type ),* ] )? $( config = { $( $options_id : $options_type ),* } )? $( state_fn = $state_fn )? );
 
-        $(
-          ext.event_loop_middleware = Some($event_loop_middleware_fn);
-        )?
-
         $(
           ext.global_template_middleware = Some($global_template_middleware_fn);
         )?
@@ -508,7 +497,6 @@ pub struct Extension {
   pub esm_entry_point: Option<&'static str>,
   pub ops: Cow<'static, [OpDecl]>,
   pub external_references: Cow<'static, [v8::ExternalReference<'static>]>,
-  pub event_loop_middleware: Option<EventLoopMiddlewareFn>,
   pub global_template_middleware: Option<GlobalTemplateMiddlewareFn>,
   pub global_object_middleware: Option<GlobalObjectMiddlewareFn>,
   pub op_state_fn: Option<Box<OpStateFn>>,
@@ -527,7 +515,6 @@ impl Default for Extension {
       esm_entry_point: None,
       ops: Cow::Borrowed(&[]),
       external_references: Cow::Borrowed(&[]),
-      event_loop_middleware: None,
       global_template_middleware: None,
       global_object_middleware: None,
       op_state_fn: None,
@@ -621,10 +608,6 @@ impl Extension {
     self.middleware_fn.take()
   }
 
-  pub fn get_event_loop_middleware(&mut self) -> Option<EventLoopMiddlewareFn> {
-    self.event_loop_middleware
-  }
-
   pub fn get_global_template_middleware(
     &mut self,
   ) -> Option<GlobalTemplateMiddlewareFn> {
@@ -662,7 +645,6 @@ pub struct ExtensionBuilder {
   ops: Vec<OpDecl>,
   state: Option<Box<OpStateFn>>,
   middleware: Option<Box<OpMiddlewareFn>>,
-  event_loop_middleware: Option<EventLoopMiddlewareFn>,
   global_template_middleware: Option<GlobalTemplateMiddlewareFn>,
   global_object_middleware: Option<GlobalObjectMiddlewareFn>,
   external_references: Vec<ExternalReference<'static>>,
@@ -715,14 +697,6 @@ impl ExtensionBuilder {
     self
   }
 
-  pub fn event_loop_middleware<F>(
-    &mut self,
-    middleware_fn: EventLoopMiddlewareFn,
-  ) -> &mut Self {
-    self.event_loop_middleware = Some(middleware_fn);
-    self
-  }
-
   pub fn global_template_middleware<F>(
     &mut self,
     middleware_fn: GlobalTemplateMiddlewareFn,
@@ -758,7 +732,6 @@ impl ExtensionBuilder {
       esm_entry_point: self.esm_entry_point,
       ops: Cow::Owned(self.ops),
       external_references: Cow::Owned(self.external_references),
-      event_loop_middleware: self.event_loop_middleware,
       global_template_middleware: self.global_template_middleware,
       global_object_middleware: self.global_object_middleware,
       op_state_fn: self.state,
@@ -781,7 +754,6 @@ impl ExtensionBuilder {
       external_references: Cow::Owned(std::mem::take(
         &mut self.external_references,
       )),
-      event_loop_middleware: self.event_loop_middleware.take(),
       global_template_middleware: self.global_template_middleware.take(),
       global_object_middleware: self.global_object_middleware.take(),
       op_state_fn: self.state.take(),
diff --git a/core/runtime/bindings.rs b/core/runtime/bindings.rs
index d70fd57..47e463d 100644
--- a/core/runtime/bindings.rs
+++ b/core/runtime/bindings.rs
@@ -538,10 +538,10 @@ pub(crate) fn watch_promise<'s, F>(
 ) -> Option<v8::Local<'s, v8::Promise>>
 where
   F: FnOnce(
-    &mut v8::HandleScope,
-    v8::ReturnValue,
-    Result<v8::Local<v8::Value>, v8::Local<v8::Value>>,
-  ),
+      &mut v8::HandleScope,
+      v8::ReturnValue,
+      Result<v8::Local<v8::Value>, v8::Local<v8::Value>>,
+    ) + 'static,
 {
   let external =
     v8::External::new(scope, Box::into_raw(Box::new(Some(f))) as _);
@@ -564,7 +564,7 @@ where
   .data(external.into())
   .build(scope);
 
-  let on_rejected = v8::Function::builder(
+  let on_rejected: Option<v8::Local<'_, v8::Function>> = v8::Function::builder(
     |scope: &mut v8::HandleScope,
      args: v8::FunctionCallbackArguments,
      rv: v8::ReturnValue| {
diff --git a/core/runtime/jsruntime.rs b/core/runtime/jsruntime.rs
index 4573f15..042e486 100644
--- a/core/runtime/jsruntime.rs
+++ b/core/runtime/jsruntime.rs
@@ -1,15 +1,13 @@
 // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
-
 use super::bindings;
 use super::bindings::watch_promise;
 use super::exception_state::ExceptionState;
 use super::jsrealm::JsRealmInner;
 use super::snapshot_util;
 use crate::error::exception_to_err_result;
-use crate::error::generic_error;
+use crate::error::AnyError;
 use crate::error::GetErrorClassFn;
 use crate::error::JsError;
-use crate::extensions::EventLoopMiddlewareFn;
 use crate::extensions::GlobalObjectMiddlewareFn;
 use crate::extensions::GlobalTemplateMiddlewareFn;
 use crate::extensions::OpDecl;
@@ -42,7 +40,7 @@ use crate::OpResult;
 use crate::OpState;
 use crate::V8_WRAPPER_OBJECT_INDEX;
 use crate::V8_WRAPPER_TYPE_INDEX;
-use anyhow::bail;
+use anyhow::anyhow;
 use anyhow::Context as AnyhowContext;
 use anyhow::Error;
 use futures::future::poll_fn;
@@ -69,6 +67,7 @@ use std::sync::Mutex;
 use std::sync::Once;
 use std::task::Context;
 use std::task::Poll;
+use std::task::Waker;
 use v8::Isolate;
 
 const STATE_DATA_OFFSET: u32 = 0;
@@ -218,6 +217,37 @@ impl InitMode {
   }
 }
 
+#[derive(Default)]
+struct PromiseFuture {
+  resolved: Cell<Option<Result<v8::Global<v8::Value>, Error>>>,
+  waker: Cell<Option<Waker>>,
+}
+
+#[derive(Clone, Default)]
+struct RcPromiseFuture(Rc<PromiseFuture>);
+
+impl RcPromiseFuture {
+  pub fn new(res: Result<v8::Global<v8::Value>, Error>) -> Self {
+    Self(Rc::new(PromiseFuture {
+      resolved: Some(res).into(),
+      ..Default::default()
+    }))
+  }
+}
+
+impl Future for RcPromiseFuture {
+  type Output = Result<v8::Global<v8::Value>, Error>;
+  fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+    let this = self.get_mut();
+    if let Some(resolved) = this.0.resolved.take() {
+      Poll::Ready(resolved)
+    } else {
+      this.0.waker.set(Some(cx.waker().clone()));
+      Poll::Pending
+    }
+  }
+}
+
 pub(crate) const BUILTIN_SOURCES: [ExtensionFileSource; 3] = include_js_files!(
   core
   "00_primordials.js",
@@ -243,7 +273,6 @@ pub struct JsRuntime {
   pub(crate) inner: InnerIsolateState,
   pub(crate) allocations: IsolateAllocations,
   extensions: Vec<Extension>,
-  event_loop_middlewares: Vec<EventLoopMiddlewareFn>,
   init_mode: InitMode,
   // Marks if this is considered the top-level runtime. Used only by inspector.
   is_main_runtime: bool,
@@ -481,7 +510,7 @@ pub struct PollEventLoopOptions {
 impl Default for PollEventLoopOptions {
   fn default() -> Self {
     Self {
-      wait_for_inspector: true,
+      wait_for_inspector: false,
       pump_v8_message_loop: true,
     }
   }
@@ -577,10 +606,8 @@ impl JsRuntime {
     let init_mode = InitMode::from_options(&options);
     let (op_state, ops) = Self::create_opstate(&mut options);
 
-    // Collect event-loop middleware, global template middleware, global object
+    // Collect global template middleware, global object
     // middleware, and additional ExternalReferences from extensions.
-    let mut event_loop_middlewares =
-      Vec::with_capacity(options.extensions.len());
     let mut global_template_middlewares =
       Vec::with_capacity(options.extensions.len());
     let mut global_object_middlewares =
@@ -588,9 +615,6 @@ impl JsRuntime {
     let mut additional_references =
       Vec::with_capacity(options.extensions.len());
     for extension in &mut options.extensions {
-      if let Some(middleware) = extension.get_event_loop_middleware() {
-        event_loop_middlewares.push(middleware);
-      }
       if let Some(middleware) = extension.get_global_template_middleware() {
         global_template_middlewares.push(middleware);
       }
@@ -814,7 +838,6 @@ impl JsRuntime {
       },
       init_mode,
       allocations: IsolateAllocations::default(),
-      event_loop_middlewares,
       extensions: options.extensions,
       is_main_runtime: options.is_main,
     };
@@ -1240,89 +1263,123 @@ impl JsRuntime {
     )
   }
 
+  /// Call a function and return a future resolving with the return value of the
+  /// function. If the function returns a promise, the future will resolve only once the
+  /// event loop resolves the underlying promise. If the future rejects, the future will
+  /// resolve with the underlying error.
+  ///
+  /// The event loop must be polled seperately for this future to resolve. If the event loop
+  /// is not polled, the future will never make progress.
+  pub fn call(
+    &mut self,
+    function: &v8::Global<v8::Function>,
+  ) -> impl Future<Output = Result<v8::Global<v8::Value>, Error>> {
+    self.call_with_args(function, &[])
+  }
+
+  /// Call a function and returns a future resolving with the return value of the
+  /// function. If the function returns a promise, the future will resolve only once the
+  /// event loop resolves the underlying promise. If the future rejects, the future will
+  /// resolve with the underlying error.
+  ///
+  /// The event loop must be polled seperately for this future to resolve. If the event loop
+  /// is not polled, the future will never make progress.
+  pub fn scoped_call(
+    scope: &mut v8::HandleScope,
+    function: &v8::Global<v8::Function>,
+  ) -> impl Future<Output = Result<v8::Global<v8::Value>, Error>> {
+    Self::scoped_call_with_args(scope, function, &[])
+  }
+
+  /// Call a function and returns a future resolving with the return value of the
+  /// function. If the function returns a promise, the future will resolve only once the
+  /// event loop resolves the underlying promise. If the future rejects, the future will
+  /// resolve with the underlying error.
+  ///
+  /// The event loop must be polled seperately for this future to resolve. If the event loop
+  /// is not polled, the future will never make progress.
+  pub fn call_with_args(
+    &mut self,
+    function: &v8::Global<v8::Function>,
+    args: &[v8::Global<v8::Value>],
+  ) -> impl Future<Output = Result<v8::Global<v8::Value>, Error>> {
+    let scope = &mut self.handle_scope();
+    Self::scoped_call_with_args(scope, function, args)
+  }
+
+  /// Call a function and returns a future resolving with the return value of the
+  /// function. If the function returns a promise, the future will resolve only once the
+  /// event loop resolves the underlying promise. If the future rejects, the future will
+  /// resolve with the underlying error.
+  ///
+  /// The event loop must be polled seperately for this future to resolve. If the event loop
+  /// is not polled, the future will never make progress.
+  pub fn scoped_call_with_args(
+    scope: &mut v8::HandleScope,
+    function: &v8::Global<v8::Function>,
+    args: &[v8::Global<v8::Value>],
+  ) -> impl Future<Output = Result<v8::Global<v8::Value>, Error>> {
+    let scope = &mut v8::TryCatch::new(scope);
+    let cb = function.open(scope);
+    let this = v8::undefined(scope).into();
+    let promise = if args.is_empty() {
+      cb.call(scope, this, &[])
+    } else {
+      let mut local_args: SmallVec<[v8::Local<v8::Value>; 8]> =
+        SmallVec::with_capacity(args.len());
+      for v in args {
+        local_args.push(v8::Local::new(scope, v));
+      }
+      cb.call(scope, this, &local_args)
+    };
+
+    if promise.is_none() {
+      if scope.is_execution_terminating() {
+        let undefined = v8::undefined(scope).into();
+        return RcPromiseFuture::new(exception_to_err_result(
+          scope, undefined, false, true,
+        ));
+      }
+      let exception = scope.exception().unwrap();
+      return RcPromiseFuture::new(exception_to_err_result(
+        scope, exception, false, true,
+      ));
+    }
+    let promise = promise.unwrap();
+    if !promise.is_promise() {
+      return RcPromiseFuture::new(Ok(v8::Global::new(scope, promise)));
+    }
+    let promise = v8::Local::<v8::Promise>::try_from(promise).unwrap();
+    Self::resolve_promise_inner(scope, promise)
+  }
+
   /// Call a function. If it returns a promise, run the event loop until that
   /// promise is settled. If the promise rejects or there is an uncaught error
   /// in the event loop, return `Err(error)`. Or return `Ok(<await returned>)`.
+  #[deprecated = "Use call"]
   pub async fn call_and_await(
     &mut self,
     function: &v8::Global<v8::Function>,
   ) -> Result<v8::Global<v8::Value>, Error> {
-    self.call_with_args_and_await(function, &[]).await
+    let call = self.call(function);
+    self
+      .with_event_loop_promise(call, PollEventLoopOptions::default())
+      .await
   }
 
   /// Call a function with args. If it returns a promise, run the event loop until that
   /// promise is settled. If the promise rejects or there is an uncaught error
   /// in the event loop, return `Err(error)`. Or return `Ok(<await returned>)`.
+  #[deprecated = "Use call_with_args"]
   pub async fn call_with_args_and_await(
     &mut self,
     function: &v8::Global<v8::Function>,
     args: &[v8::Global<v8::Value>],
   ) -> Result<v8::Global<v8::Value>, Error> {
-    let promise = {
-      let scope = &mut self.handle_scope();
-      let scope = &mut v8::TryCatch::new(scope);
-      let cb = function.open(scope);
-      let this = v8::undefined(scope).into();
-      let promise = if args.is_empty() {
-        cb.call(scope, this, &[])
-      } else {
-        let mut local_args: SmallVec<[v8::Local<v8::Value>; 8]> =
-          SmallVec::with_capacity(args.len());
-        for v in args {
-          local_args.push(v8::Local::new(scope, v));
-        }
-        cb.call(scope, this, &local_args)
-      };
-      if promise.is_none() {
-        if scope.is_execution_terminating() {
-          let undefined = v8::undefined(scope).into();
-          return exception_to_err_result(scope, undefined, false, true);
-        }
-        let exception = scope.exception().unwrap();
-        return exception_to_err_result(scope, exception, false, true);
-      }
-      let promise = promise.unwrap();
-      if !promise.is_promise() {
-        return Ok(v8::Global::new(scope, promise));
-      }
-      let promise = v8::Local::<v8::Promise>::try_from(promise).unwrap();
-      let Some(promise) =
-        watch_promise(scope, promise, |scope, mut rv, res| {
-          let array = v8::Array::new(scope, 2);
-          match res {
-            Ok(value) => {
-              let b = v8::Boolean::new(scope, true);
-              array.set_index(scope, 0, b.into());
-              array.set_index(scope, 1, value);
-            }
-            Err(value) => {
-              let b = v8::Boolean::new(scope, false);
-              array.set_index(scope, 0, b.into());
-              array.set_index(scope, 1, value);
-            }
-          }
-          rv.set(array.into());
-        })
-      else {
-        bail!("Runtime shutdown");
-      };
-      v8::Global::new(scope, v8::Local::<v8::Value>::from(promise))
-    };
-
-    let res = self.resolve_value(promise).await?;
-    let scope = &mut self.handle_scope();
-    let res = v8::Local::new(scope, res);
-    if let Ok(array) = v8::Local::<v8::Array>::try_from(res) {
-      if array.get_index(scope, 0).unwrap().boolean_value(scope) {
-        let value = array.get_index(scope, 1).unwrap();
-        Ok(v8::Global::new(scope, value))
-      } else {
-        let value = array.get_index(scope, 1).unwrap();
-        Err(JsError::from_v8_exception(scope, value).into())
-      }
-    } else {
-      bail!("Unexpected V8 type");
-    }
+    let call = self.call_with_args(function, args);
+    self
+      .with_event_loop_promise(call, PollEventLoopOptions::default())
+      .await
   }
 
   /// Returns the namespace object of a module.
@@ -1419,93 +1476,68 @@ impl JsRuntime {
     ));
   }
 
-  pub fn poll_value(
+  /// Waits for the given value to resolve while polling the event loop.
+  ///
+  /// This future resolves when either the value is resolved or the event loop runs to
+  /// completion.
+  pub fn resolve(
     &mut self,
-    cx: &mut Context,
-    global: &v8::Global<v8::Value>,
-  ) -> Poll<Result<v8::Global<v8::Value>, Error>> {
-    Self::with_context_scope(
-      self.v8_isolate_ptr(),
-      self.inner.main_realm.context_ptr(),
-      |scope| self.poll_value_inner(cx, scope, global),
-    )
+    promise: v8::Global<v8::Value>,
+  ) -> impl Future<Output = Result<v8::Global<v8::Value>, Error>> {
+    let scope = &mut self.handle_scope();
+    Self::scoped_resolve(scope, promise)
   }
 
-  fn poll_value_inner(
-    &mut self,
-    cx: &mut Context,
+  /// Waits for the given value to resolve while polling the event loop.
+  ///
+  /// This future resolves when either the value is resolved or the event loop runs to
+  /// completion.
+  pub fn scoped_resolve(
     scope: &mut v8::HandleScope,
-    global: &v8::Global<v8::Value>,
-  ) -> Poll<Result<v8::Global<v8::Value>, Error>> {
-    let promise = v8::Local::new(scope, global);
-
-    // TODO(mmastrac): this would be better if we just type-tested at the top level
-    let Ok(promise) = v8::Local::<v8::Promise>::try_from(promise) else {
-      return Poll::Ready(Ok(global.clone()));
-    };
-
-    // Check if the value is not a promise or already settled before polling the
-    // event loop.
-    match promise.state() {
-      v8::PromiseState::Pending => {}
-      v8::PromiseState::Fulfilled => {
-        let value = promise.result(scope);
-        let value_handle = v8::Global::new(scope, value);
-        return Poll::Ready(Ok(value_handle));
-      }
-      v8::PromiseState::Rejected => {
-        let exception = promise.result(scope);
-        let promise_global = v8::Global::new(scope, promise);
-        JsRealm::exception_state_from_scope(scope)
-          .pending_promise_rejections
-          .borrow_mut()
-          .retain(|(key, _)| key != &promise_global);
-        return Poll::Ready(exception_to_err_result(
-          scope, exception, false, true,
-        ));
-      }
-    }
-
-    // Poll the event loop.
-    let event_loop_result = self.poll_event_loop_inner(
-      cx,
-      scope,
-      PollEventLoopOptions {
-        wait_for_inspector: false,
-        pump_v8_message_loop: true,
-      },
-    )?;
-
-    // Check the promise state again.
-    match promise.state() {
-      v8::PromiseState::Pending => match event_loop_result {
-        Poll::Ready(_) => {
-          let msg = "Promise resolution is still pending but the event loop has already resolved.";
-          Poll::Ready(Err(generic_error(msg)))
-        }
-        Poll::Pending => Poll::Pending,
-      },
-      v8::PromiseState::Fulfilled => {
-        let value = promise.result(scope);
-        let value_handle = v8::Global::new(scope, value);
-        Poll::Ready(Ok(value_handle))
-      }
-      v8::PromiseState::Rejected => {
-        let exception = promise.result(scope);
-        Poll::Ready(exception_to_err_result(scope, exception, false, true))
-      }
+    promise: v8::Global<v8::Value>,
+  ) -> impl Future<Output = Result<v8::Global<v8::Value>, Error>> {
+    let promise = v8::Local::new(scope, promise);
+    if !promise.is_promise() {
+      return RcPromiseFuture::new(Ok(v8::Global::new(scope, promise)));
     }
+    let promise = v8::Local::<v8::Promise>::try_from(promise).unwrap();
+    Self::resolve_promise_inner(scope, promise)
   }
 
   /// Waits for the given value to resolve while polling the event loop.
   ///
   /// This future resolves when either the value is resolved or the event loop runs to
   /// completion.
+  #[deprecated = "Use resolve"]
   pub async fn resolve_value(
     &mut self,
     global: v8::Global<v8::Value>,
   ) -> Result<v8::Global<v8::Value>, Error> {
-    poll_fn(|cx| self.poll_value(cx, &global)).await
+    let resolve = self.resolve(global);
+    self
+      .with_event_loop_promise(resolve, PollEventLoopOptions::default())
+      .await
+  }
+
+  /// Given a promise, returns a future that resolves when it does.
+  fn resolve_promise_inner<'s>(
+    scope: &mut v8::HandleScope<'s>,
+    promise: v8::Local<'s, v8::Promise>,
+  ) -> RcPromiseFuture {
+    let future = RcPromiseFuture::default();
+    let f = future.clone();
+    watch_promise(scope, promise, move |scope, _rv, res| {
+      let res = match res {
+        Ok(l) => Ok(v8::Global::new(scope, l)),
+        Err(e) => exception_to_err_result(scope, e, true, true),
+      };
+      f.0.resolved.set(Some(res));
+      if let Some(waker) = f.0.waker.take() {
+        waker.wake();
+      }
+    });
+
+    future
   }
 
   /// Runs event loop to completion
@@ -1524,23 +1556,60 @@ impl JsRuntime {
 
   /// A utility function that run provided future concurrently with the event loop.
   ///
-  /// Useful for interacting with local inspector session.
-  pub async fn with_event_loop<'fut, T>(
+  /// If the event loop resolves while polling the future, it return an error with the text
+  /// `Promise resolution is still pending but the event loop has already resolved.`
+  pub async fn with_event_loop_promise<'fut, T, E>(
     &mut self,
-    mut fut: Pin<Box<dyn Future<Output = T> + 'fut>>,
+    mut fut: impl Future<Output = Result<T, E>> + Unpin + 'fut,
     poll_options: PollEventLoopOptions,
-  ) -> T {
-    loop {
-      tokio::select! {
-        biased;
-
-        result = &mut fut => {
-          return result;
-        },
+  ) -> Result<T, AnyError>
+  where
+    AnyError: From<E>,
+  {
+    // Manually implement tokio::select
+    poll_fn(|cx| {
+      if let Poll::Ready(t) = fut.poll_unpin(cx) {
+        return Poll::Ready(t.map_err(|e| e.into()));
+      }
+      if let Poll::Ready(t) = self.poll_event_loop(cx, poll_options) {
+        t?;
+        if let Poll::Ready(t) = fut.poll_unpin(cx) {
+          return Poll::Ready(t.map_err(|e| e.into()));
+        }
+        return Poll::Ready(Err(anyhow!("Promise resolution is still pending but the event loop has already resolved.")));
+      }
+      Poll::Pending
+    }).await
+  }
 
-        _ = self.run_event_loop(poll_options) => {}
+  /// A utility function that run provided future concurrently with the event loop.
+  ///
+  /// If the event loop resolves while polling the future, it will continue to be polled,
+  /// regardless of whether it returned an error or success.
+  ///
+  /// Useful for interacting with local inspector session.
+  pub async fn with_event_loop_future<'fut, T, E>(
+    &mut self,
+    mut fut: impl Future<Output = Result<T, E>> + Unpin + 'fut,
+    poll_options: PollEventLoopOptions,
+  ) -> Result<T, AnyError>
+  where
+    AnyError: From<E>,
+  {
+    // Manually implement tokio::select
+    poll_fn(|cx| {
+      if let Poll::Ready(t) = fut.poll_unpin(cx) {
+        return Poll::Ready(t.map_err(|e| e.into()));
       }
-    }
+      if let Poll::Ready(t) = self.poll_event_loop(cx, poll_options) {
+        // TODO(mmastrac): We need to ignore this error for things like the repl to behave as
+        // they did before, but this is definitely not correct. It's just something we're
+        // relying on. :(
+        _ = t;
+      }
+      Poll::Pending
+    })
+    .await
   }
 
   /// Runs a single tick of event loop
@@ -1597,22 +1666,11 @@ impl JsRuntime {
     )?;
     exception_state.check_exception_condition(scope)?;
 
-    // Event loop middlewares
-    let mut maybe_scheduling = false;
-    {
-      let op_state = self.inner.state.op_state.clone();
-      for f in &self.event_loop_middlewares {
-        if f(op_state.clone(), cx) {
-          maybe_scheduling = true;
-        }
-      }
-    }
-
     // Get the pending state from the main realm, or all realms
     let pending_state =
       EventLoopPendingState::new(scope, context_state, modules);
 
-    if !pending_state.is_pending() && !maybe_scheduling {
+    if !pending_state.is_pending() {
       if has_inspector {
         let inspector = self.inspector();
         let has_active_sessions = inspector.borrow().has_active_sessions();
@@ -1647,7 +1705,6 @@ impl JsRuntime {
       if pending_state.has_pending_background_tasks
         || pending_state.has_tick_scheduled
         || pending_state.has_pending_promise_rejections
-        || maybe_scheduling
       {
         self.inner.state.waker.wake();
       } else
@@ -1666,7 +1723,6 @@ impl JsRuntime {
         || pending_state.has_pending_dyn_module_evaluation
         || pending_state.has_pending_background_tasks
         || pending_state.has_tick_scheduled
-        || maybe_scheduling
       {
         // pass, will be polled again
       } else {
diff --git a/core/web_timeout.rs b/core/web_timeout.rs
index 2fb1509..3460432 100644
--- a/core/web_timeout.rs
+++ b/core/web_timeout.rs
@@ -380,11 +380,16 @@ impl<T: Clone> WebTimers<T> {
     self.data_map.borrow().is_empty()
   }
 
-  #[cfg(test)]
+  /// The total number of timers in this collection.
   pub fn len(&self) -> usize {
     self.data_map.borrow().len()
   }
 
+  /// The number of unref'd timers in this collection.
+  pub fn unref_len(&self) -> usize {
+    self.unrefd_count.get()
+  }
+
   #[cfg(test)]
   pub fn assert_consistent(&self) {
     if self.data_map.borrow().is_empty() {
@@ -402,7 +407,7 @@ impl<T: Clone> WebTimers<T> {
   }
 
   pub fn has_pending_timers(&self) -> bool {
-    self.timers.borrow().len() > self.unrefd_count.get()
+    self.len() > self.unref_len()
   }
 }
 


Test patch:
diff --git a/core/runtime/tests/error.rs b/core/runtime/tests/error.rs
index 0f665b0..c225ae3 100644
--- a/core/runtime/tests/error.rs
+++ b/core/runtime/tests/error.rs
@@ -3,6 +3,7 @@ use crate::error::custom_error;
 use crate::error::JsError;
 use crate::op2;
 use crate::JsRuntime;
+use crate::PollEventLoopOptions;
 use crate::RuntimeOptions;
 use anyhow::Error;
 use futures::future::poll_fn;
@@ -61,11 +62,10 @@ async fn test_error_context() {
     ..Default::default()
   });
 
-  poll_fn(move |cx| {
-    runtime
-      .execute_script_static(
-        "test_error_context_sync.js",
-        r#"
+  runtime
+    .execute_script_static(
+      "test_error_context_sync.js",
+      r#"
 let errMessage;
 try {
   Deno.core.ops.op_err_sync();
@@ -76,37 +76,32 @@ if (errMessage !== "higher-level sync error: original sync error") {
   throw new Error("unexpected error message from op_err_sync: " + errMessage);
 }
 "#,
-      )
-      .unwrap();
-
-    let promise = runtime
-      .execute_script_static(
-        "test_error_context_async.js",
-        r#"
+    )
+    .unwrap();
 
+  let promise = runtime
+    .execute_script_static(
+      "test_error_context_async.js",
+      r#"
 (async () => {
-let errMessage;
-try {
-  await Deno.core.opAsync("op_err_async");
-} catch (err) {
-  errMessage = err.message;
-}
-if (errMessage !== "higher-level async error: original async error") {
-  throw new Error("unexpected error message from op_err_async: " + errMessage);
-}
+  let errMessage;
+  try {
+    await Deno.core.opAsync("op_err_async");
+  } catch (err) {
+    errMessage = err.message;
+  }
+  if (errMessage !== "higher-level async error: original async error") {
+    throw new Error("unexpected error message from op_err_async: " + errMessage);
+  }
 })()
 "#,
-      )
-      .unwrap();
+    ).unwrap();
 
-    match runtime.poll_value(cx, &promise) {
-      Poll::Ready(Ok(_)) => {}
-      Poll::Ready(Err(err)) => panic!("{err:?}"),
-      _ => panic!(),
-    }
-    Poll::Ready(())
-  })
-  .await;
+  let resolve = runtime.resolve(promise);
+  runtime
+    .with_event_loop_promise(resolve, PollEventLoopOptions::default())
+    .await
+    .unwrap();
 }
 
 #[test]
diff --git a/core/runtime/tests/jsrealm.rs b/core/runtime/tests/jsrealm.rs
index 0e1fabc..cb32998 100644
--- a/core/runtime/tests/jsrealm.rs
+++ b/core/runtime/tests/jsrealm.rs
@@ -174,6 +174,7 @@ fn es_snapshot() {
         "import('mod:test').then(module => module.TEST)",
       )
       .unwrap();
+    #[allow(deprecated)]
     let test_export =
       futures::executor::block_on(runtime.resolve_value(test_export_promise))
         .unwrap();
diff --git a/core/runtime/tests/misc.rs b/core/runtime/tests/misc.rs
index 218a3dd..da68a97 100644
--- a/core/runtime/tests/misc.rs
+++ b/core/runtime/tests/misc.rs
@@ -143,50 +143,36 @@ async fn test_wakers_for_async_ops() {
   panic!("The waker was never woken after the future completed");
 }
 
+#[rstest]
+#[case("Promise.resolve(1 + 2)", Ok(3))]
+#[case("Promise.resolve(new Promise(resolve => resolve(2 + 2)))", Ok(4))]
+#[case(
+  "Promise.reject(new Error('fail'))",
+  Err("Error: fail\n    at a.js:1:16")
+)]
+#[case("new Promise(resolve => {})", Err("Promise resolution is still pending but the event loop has already resolved."))]
 #[tokio::test]
-async fn test_poll_value() {
+async fn test_resolve_promise(
+  #[case] script: &'static str,
+  #[case] result: Result<i32, &'static str>,
+) {
   let mut runtime = JsRuntime::new(Default::default());
-  poll_fn(move |cx| {
-    let value_global = runtime
-      .execute_script_static("a.js", "Promise.resolve(1 + 2)")
-      .unwrap();
-    let v = runtime.poll_value(cx, &value_global);
-    {
-      let scope = &mut runtime.handle_scope();
-      assert!(
-        matches!(v, Poll::Ready(Ok(v)) if v.open(scope).integer_value(scope).unwrap() == 3)
-      );
-    }
-
-    let value_global = runtime
-      .execute_script_static(
-        "a.js",
-        "Promise.resolve(new Promise(resolve => resolve(2 + 2)))",
-      )
-      .unwrap();
-    let v = runtime.poll_value(cx, &value_global);
-    {
-      let scope = &mut runtime.handle_scope();
-      assert!(
-        matches!(v, Poll::Ready(Ok(v)) if v.open(scope).integer_value(scope).unwrap() == 4)
-      );
+  let value_global = runtime.execute_script_static("a.js", script).unwrap();
+  let resolve = runtime.resolve(value_global);
+  let out = runtime
+    .with_event_loop_promise(resolve, PollEventLoopOptions::default())
+    .await;
+  let scope = &mut runtime.handle_scope();
+  match result {
+    Ok(value) => {
+      let out = v8::Local::new(scope, out.expect("expected success"));
+      assert_eq!(out.int32_value(scope).unwrap(), value);
     }
-
-    let value_global = runtime
-      .execute_script_static("a.js", "Promise.reject(new Error('fail'))")
-      .unwrap();
-    let v = runtime.poll_value(cx, &value_global);
-    assert!(
-      matches!(v, Poll::Ready(Err(e)) if e.downcast_ref::<JsError>().unwrap().exception_message == "Uncaught Error: fail")
-    );
-
-    let value_global = runtime
-      .execute_script_static("a.js", "new Promise(resolve => {})")
-      .unwrap();
-    let v = runtime.poll_value(cx, &value_global);
-    matches!(v, Poll::Ready(Err(e)) if e.to_string() == "Promise resolution is still pending but the event loop has already resolved.");
-    Poll::Ready(())
-  }).await;
+    Err(err) => assert_eq!(
+      out.expect_err("expected error").to_string(),
+      err.to_string()
+    ),
+  }
 }
 
 #[rstest]
@@ -199,14 +185,14 @@ async fn test_poll_value() {
 #[case(
   "script",
   "Promise.reject(new Error('fail'))",
-  Err("Uncaught Error: fail")
+  Err("Uncaught (in promise) Error: fail")
 )]
 #[case("script", "new Promise(resolve => {})", Ok(None))]
 #[case("call", "async () => 1 + 2", Ok(Some(3)))]
 #[case(
   "call",
   "async () => { throw new Error('fail'); }",
-  Err("Uncaught Error: fail")
+  Err("Uncaught (in promise) Error: fail")
 )]
 #[case("call", "async () => new Promise(resolve => {})", Ok(None))]
 #[case("call", "() => Promise.resolve(1 + 2)", Ok(Some(3)))]
@@ -218,7 +204,7 @@ async fn test_poll_value() {
 #[case(
   "call",
   "() => Promise.reject(new Error('fail'))",
-  Err("Uncaught Error: fail")
+  Err("Uncaught (in promise) Error: fail")
 )]
 #[case("call", "() => new Promise(resolve => {})", Ok(None))]
 #[case(
@@ -258,11 +244,13 @@ async fn test_resolve_value_generic(
   let result_global = if runner == "script" {
     let value_global: v8::Global<v8::Value> =
       runtime.execute_script_static("a.js", code).unwrap();
+    #[allow(deprecated)]
     runtime.resolve_value(value_global).await
   } else if runner == "call" {
     let value_global = runtime.execute_script_static("a.js", code).unwrap();
     let function: v8::Global<v8::Function> =
       unsafe { std::mem::transmute(value_global) };
+    #[allow(deprecated)]
     runtime.call_and_await(&function).await
   } else {
     unreachable!()
@@ -316,6 +304,7 @@ fn terminate_execution_webassembly() {
                                 globalThis.wasmInstance = new WebAssembly.Instance(wasmModule);
                                 })()
                                     "#).unwrap();
+  #[allow(deprecated)]
   futures::executor::block_on(runtime.resolve_value(promise)).unwrap();
   let terminator_thread = std::thread::spawn(move || {
     std::thread::sleep(std::time::Duration::from_millis(1000));
@@ -405,6 +394,7 @@ async fn wasm_streaming_op_invocation_in_import() {
                                }
                              });
                             "#).unwrap();
+  #[allow(deprecated)]
   let value = runtime.resolve_value(promise).await.unwrap();
   let val = value.open(&mut runtime.handle_scope());
   assert!(val.is_object());
diff --git a/core/runtime/tests/ops.rs b/core/runtime/tests/ops.rs
index 5a0cccb..1bf8f2e 100644
--- a/core/runtime/tests/ops.rs
+++ b/core/runtime/tests/ops.rs
@@ -385,6 +385,7 @@ async fn test_dispatch_many_ops() {
   Promise.all(promises);
   "#;
   let promise = runtime.execute_script_static("test", src).unwrap();
+  #[allow(deprecated)]
   runtime.resolve_value(promise).await.unwrap();
 }
 
@@ -693,6 +694,7 @@ pub async fn test_op_metrics() {
     "#,
   )
   .unwrap();
+  #[allow(deprecated)]
   runtime
     .resolve_value(promise)
     .await
@@ -764,6 +766,7 @@ pub async fn test_op_metrics_summary_tracker() {
     "#,
   )
   .unwrap();
+  #[allow(deprecated)]
   runtime
     .resolve_value(promise)
     .await
diff --git a/core/runtime/tests/snapshot.rs b/core/runtime/tests/snapshot.rs
index af2ef61..c83274f 100644
--- a/core/runtime/tests/snapshot.rs
+++ b/core/runtime/tests/snapshot.rs
@@ -246,6 +246,7 @@ fn es_snapshot() {
     return mod.f400() + " " + Deno.core.ops.op_test();
   })();"#;
   let val = runtime3.execute_script_static(".", source_code).unwrap();
+  #[allow(deprecated)]
   let val = futures::executor::block_on(runtime3.resolve_value(val)).unwrap();
   {
     let scope = &mut runtime3.handle_scope();
@@ -304,6 +305,7 @@ pub(crate) fn es_snapshot_without_runtime_module_loader() {
       "import('ext:module_snapshot/test.js')",
     )
     .unwrap();
+  #[allow(deprecated)]
   let dyn_import_result =
     futures::executor::block_on(runtime.resolve_value(dyn_import_promise));
   assert_eq!(
@@ -319,6 +321,7 @@ pub(crate) fn es_snapshot_without_runtime_module_loader() {
       "import('ext:module_snapshot/test2.js')",
     )
     .unwrap();
+  #[allow(deprecated)]
   let dyn_import_result =
     futures::executor::block_on(runtime.resolve_value(dyn_import_promise));
   assert!(dyn_import_result.is_err());
@@ -385,6 +388,7 @@ pub(crate) fn generic_preserve_snapshotted_modules_test(test_snapshot: bool) {
         "import('test:not-preserved')",
       )
       .unwrap();
+    #[allow(deprecated)]
     let dyn_import_result =
       futures::executor::block_on(runtime.resolve_value(dyn_import_promise));
     assert!(dyn_import_result.is_err());
@@ -408,6 +412,7 @@ pub(crate) fn generic_preserve_snapshotted_modules_test(test_snapshot: bool) {
         "import('test:preserved').then(module => module.TEST)",
       )
       .unwrap();
+    #[allow(deprecated)]
     let dyn_import_result =
       futures::executor::block_on(runtime.resolve_value(dyn_import_promise))
         .unwrap();
diff --git a/testing/checkin.d.ts b/testing/checkin.d.ts
index 28d144d..8121c35 100644
--- a/testing/checkin.d.ts
+++ b/testing/checkin.d.ts
@@ -11,3 +11,8 @@ interface PromiseConstructor {
     reject: (reason?: any) => void;
   };
 }
+
+declare namespace Deno {
+  export function refTimer(id);
+  export function unrefTimer(id);
+}
diff --git a/testing/checkin/runner/mod.rs b/testing/checkin/runner/mod.rs
index 25af0ab..7f9c5a4 100644
--- a/testing/checkin/runner/mod.rs
+++ b/testing/checkin/runner/mod.rs
@@ -90,10 +90,7 @@ async fn run_integration_test_task(
   let f = runtime.mod_evaluate(module);
   let mut actual_output = String::new();
   if let Err(e) = runtime
-    .run_event_loop(PollEventLoopOptions {
-      pump_v8_message_loop: true,
-      wait_for_inspector: false,
-    })
+    .run_event_loop(PollEventLoopOptions::default())
     .await
   {
     for line in e.to_string().split('\n') {
@@ -137,22 +134,16 @@ async fn run_unit_test_task(
   let module = runtime.load_main_module(&url, None).await?;
   let f = runtime.mod_evaluate(module);
   runtime
-    .run_event_loop(PollEventLoopOptions {
-      pump_v8_message_loop: true,
-      wait_for_inspector: false,
-    })
+    .run_event_loop(PollEventLoopOptions::default())
     .await?;
   f.await?;
 
   let tests: TestFunctions = runtime.op_state().borrow_mut().take();
   for (name, function) in tests.functions {
     println!("Testing {name}...");
-    runtime.call_and_await(&function).await?;
+    let call = runtime.call(&function);
     runtime
-      .run_event_loop(PollEventLoopOptions {
-        pump_v8_message_loop: true,
-        wait_for_inspector: false,
-      })
+      .with_event_loop_promise(call, PollEventLoopOptions::default())
       .await?;
   }
 
diff --git a/testing/checkin/runtime/__init.js b/testing/checkin/runtime/__init.js
index acae428..24dcb11 100644
--- a/testing/checkin/runtime/__init.js
+++ b/testing/checkin/runtime/__init.js
@@ -10,3 +10,5 @@ globalThis.setTimeout = timers.setTimeout;
 globalThis.setInterval = timers.setInterval;
 globalThis.clearTimeout = timers.clearTimeout;
 globalThis.clearInterval = timers.clearInterval;
+Deno.unrefTimer = timers.unrefTimer;
+Deno.refTimer = timers.refTimer;
diff --git a/testing/checkin/runtime/timers.ts b/testing/checkin/runtime/timers.ts
index c7b4be1..3c423d3 100644
--- a/testing/checkin/runtime/timers.ts
+++ b/testing/checkin/runtime/timers.ts
@@ -37,3 +37,17 @@ export function clearTimeout(id) {
 export function clearInterval(id) {
   Deno.core.cancelTimer(id);
 }
+
+/**
+ * Mark a timer as not blocking event loop exit.
+ */
+export function unrefTimer(id) {
+  Deno.core.unrefTimer(id);
+}
+
+/**
+ * Mark a timer as blocking event loop exit.
+ */
+export function refTimer(id) {
+  Deno.core.refTimer(id);
+}
diff --git a/testing/integration/timer_ref_and_cancel/timer_ref_and_cancel.out b/testing/integration/timer_ref_and_cancel/timer_ref_and_cancel.out
new file mode 100644
index 0000000..422c2b7
--- /dev/null
+++ b/testing/integration/timer_ref_and_cancel/timer_ref_and_cancel.out
@@ -0,0 +1,2 @@
+a
+b
diff --git a/testing/integration/timer_ref_and_cancel/timer_ref_and_cancel.ts b/testing/integration/timer_ref_and_cancel/timer_ref_and_cancel.ts
new file mode 100644
index 0000000..5724619
--- /dev/null
+++ b/testing/integration/timer_ref_and_cancel/timer_ref_and_cancel.ts
@@ -0,0 +1,8 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+setTimeout(() => console.log("a"), 1000);
+setTimeout(() => console.log("b"), 2000);
+const c = setTimeout(() => console.log("c"), 3000);
+const d = setTimeout(() => console.log("d"), 4000);
+// c should not run, if d is cancelled
+Deno.unrefTimer(c);
+clearTimeout(d);
diff --git a/testing/lib.rs b/testing/lib.rs
index c165077..104e7c3 100644
--- a/testing/lib.rs
+++ b/testing/lib.rs
@@ -50,5 +50,6 @@ integration_test!(
   error_without_stack,
   smoke_test,
   timer_ref,
+  timer_ref_and_cancel,
   timer_many,
 );
diff --git a/testing/tsconfig.json b/testing/tsconfig.json
index 20fc973..c93cc57 100644
--- a/testing/tsconfig.json
+++ b/testing/tsconfig.json
@@ -5,6 +5,7 @@
       "checkin:console": ["checkin/runtime/console.ts"],
       "checkin:testing": ["checkin/runtime/testing.ts"]
     },
-    "lib": ["ESNext", "DOM", "ES2023.Array"]
+    "lib": ["ESNext", "DOM", "ES2023.Array"],
+    "moduleDetection": "force"
   }
 }