Repository: denoland/deno_core
Pull request #410 (2024-01-03 20:14:25)

feat(core): handledPromiseRejectionHandler
Implements the deno_core equivalent of `rejectionhandled`.


Patch:
diff --git a/core/01_core.js b/core/01_core.js
index af6052e..fbc559d 100644
--- a/core/01_core.js
+++ b/core/01_core.js
@@ -180,7 +180,7 @@
         nextTickCallbacks[i]();
       }
     } else {
-      ops.op_run_microtasks();
+      op_run_microtasks();
     }
 
     // Finally drain macrotask queue.
@@ -195,7 +195,7 @@
           break;
         }
 
-        ops.op_run_microtasks();
+        op_run_microtasks();
         // If callback returned `true` then it has no more work to do, stop
         // calling it then.
         if (res === true) {
@@ -223,7 +223,7 @@
         );
         if (!handled) {
           const err = rejections[i + 1];
-          ops.op_dispatch_exception(err, true);
+          op_dispatch_exception(err, true);
         }
       }
     }
@@ -575,14 +575,14 @@
     if (!hasPromise(promiseId)) {
       return;
     }
-    ops.op_ref_op(promiseId);
+    op_ref_op(promiseId);
   }
 
   function unrefOp(promiseId) {
     if (!hasPromise(promiseId)) {
       return;
     }
-    ops.op_unref_op(promiseId);
+    op_unref_op(promiseId);
   }
 
   function refOpPromise(promise) {
@@ -594,7 +594,7 @@
   }
 
   function resources() {
-    return ObjectFromEntries(ops.op_resources());
+    return ObjectFromEntries(op_resources());
   }
 
   function metrics() {
@@ -633,7 +633,7 @@
     if (typeof cb != "function") {
       throw new TypeError("expected a function");
     }
-    return ops.op_queue_microtask(() => {
+    return op_queue_microtask(() => {
       try {
         cb();
       } catch (error) {
@@ -729,7 +729,7 @@
       }
     });
 
-    ops.op_set_promise_hooks(
+    op_set_promise_hooks(
       wrappedHooks[0],
       wrappedHooks[1],
       wrappedHooks[2],
@@ -746,7 +746,7 @@
     return new Proxy({}, {
       get(_target, opName) {
         if (ops[opName] === undefined) {
-          ops.op_panic(`Unknown or disabled op '${opName}'`);
+          op_panic(`Unknown or disabled op '${opName}'`);
         }
         let op;
         if (asyncOps[opName] !== undefined) {
@@ -782,7 +782,7 @@
   const callSiteRetBufU8 = new Uint8Array(callSiteRetBuf.buffer);
 
   function currentUserCallSite() {
-    const fileName = ops.op_current_user_call_site(callSiteRetBufU8);
+    const fileName = op_current_user_call_site(callSiteRetBufU8);
     const lineNumber = callSiteRetBuf[0];
     const columnNumber = callSiteRetBuf[1];
     return { fileName, lineNumber, columnNumber };
@@ -838,22 +838,22 @@
 
     log = (...args) => {
       const stringifiedArgs = args.map(this.#stringify);
-      ops.op_print(`${stringifiedArgs.join(" ")}\n`, false);
+      op_print(`${stringifiedArgs.join(" ")}\n`, false);
     };
 
     debug = (...args) => {
       const stringifiedArgs = args.map(this.#stringify);
-      ops.op_print(`${stringifiedArgs.join(" ")}\n`, false);
+      op_print(`${stringifiedArgs.join(" ")}\n`, false);
     };
 
     warn = (...args) => {
       const stringifiedArgs = args.map(this.#stringify);
-      ops.op_print(`${stringifiedArgs.join(" ")}\n`, false);
+      op_print(`${stringifiedArgs.join(" ")}\n`, false);
     };
 
     error = (...args) => {
       const stringifiedArgs = args.map(this.#stringify);
-      ops.op_print(`${stringifiedArgs.join(" ")}\n`, true);
+      op_print(`${stringifiedArgs.join(" ")}\n`, true);
     };
   }
 
@@ -863,6 +863,38 @@
   wrapConsole(coreConsole, v8Console);
 
   const {
+    op_abort_wasm_streaming,
+    op_current_user_call_site,
+    op_decode,
+    op_deserialize,
+    op_destructure_error,
+    op_dispatch_exception,
+    op_encode,
+    op_eval_context,
+    op_event_loop_has_more_work,
+    op_get_promise_details,
+    op_get_proxy_details,
+    op_has_tick_scheduled,
+    op_memory_usage,
+    op_op_names,
+    op_panic,
+    op_print,
+    op_queue_microtask,
+    op_ref_op,
+    op_resources,
+    op_run_microtasks,
+    op_serialize,
+    op_set_handled_promise_rejection_handler,
+    op_set_has_tick_scheduled,
+    op_set_promise_hooks,
+    op_set_wasm_streaming_callback,
+    op_str_byte_length,
+    op_timer_cancel,
+    op_timer_queue,
+    op_timer_ref,
+    op_timer_unref,
+    op_unref_op,
+
     op_is_any_array_buffer,
     op_is_arguments_object,
     op_is_array_buffer,
@@ -927,27 +959,27 @@
     readSync,
     writeSync,
     shutdown,
-    print: (msg, isErr) => ops.op_print(msg, isErr),
+    print: (msg, isErr) => op_print(msg, isErr),
     setMacrotaskCallback,
     setNextTickCallback,
-    runMicrotasks: () => ops.op_run_microtasks(),
-    hasTickScheduled: () => ops.op_has_tick_scheduled(),
-    setHasTickScheduled: (bool) => ops.op_set_has_tick_scheduled(bool),
+    runMicrotasks: () => op_run_microtasks(),
+    hasTickScheduled: () => op_has_tick_scheduled(),
+    setHasTickScheduled: (bool) => op_set_has_tick_scheduled(bool),
     evalContext: (
       source,
       specifier,
-    ) => ops.op_eval_context(source, specifier),
+    ) => op_eval_context(source, specifier),
     hostObjectBrand,
-    encode: (text) => ops.op_encode(text),
-    decode: (buffer) => ops.op_decode(buffer),
+    encode: (text) => op_encode(text),
+    decode: (buffer) => op_decode(buffer),
     serialize: (
       value,
       options,
       errorCallback,
-    ) => ops.op_serialize(value, options, errorCallback),
-    deserialize: (buffer, options) => ops.op_deserialize(buffer, options),
-    getPromiseDetails: (promise) => ops.op_get_promise_details(promise),
-    getProxyDetails: (proxy) => ops.op_get_proxy_details(proxy),
+    ) => op_serialize(value, options, errorCallback),
+    deserialize: (buffer, options) => op_deserialize(buffer, options),
+    getPromiseDetails: (promise) => op_get_promise_details(promise),
+    getProxyDetails: (proxy) => op_get_proxy_details(proxy),
     isAnyArrayBuffer: (value) => op_is_any_array_buffer(value),
     isArgumentsObject: (value) => op_is_arguments_object(value),
     isArrayBuffer: (value) => op_is_array_buffer(value),
@@ -976,25 +1008,27 @@
     isTypedArray: (value) => op_is_typed_array(value),
     isWeakMap: (value) => op_is_weak_map(value),
     isWeakSet: (value) => op_is_weak_set(value),
-    memoryUsage: () => ops.op_memory_usage(),
-    setWasmStreamingCallback: (fn) => ops.op_set_wasm_streaming_callback(fn),
+    memoryUsage: () => op_memory_usage(),
+    setWasmStreamingCallback: (fn) => op_set_wasm_streaming_callback(fn),
     abortWasmStreaming: (
       rid,
       error,
-    ) => ops.op_abort_wasm_streaming(rid, error),
-    destructureError: (error) => ops.op_destructure_error(error),
-    opNames: () => ops.op_op_names(),
-    eventLoopHasMoreWork: () => ops.op_event_loop_has_more_work(),
-    byteLength: (str) => ops.op_str_byte_length(str),
+    ) => op_abort_wasm_streaming(rid, error),
+    destructureError: (error) => op_destructure_error(error),
+    opNames: () => op_op_names(),
+    eventLoopHasMoreWork: () => op_event_loop_has_more_work(),
+    byteLength: (str) => op_str_byte_length(str),
+    setHandledPromiseRejectionHandler: (handler) =>
+      op_set_handled_promise_rejection_handler(handler),
     setUnhandledPromiseRejectionHandler: (handler) =>
       unhandledPromiseRejectionHandler = handler,
-    reportUnhandledException: (e) => ops.op_dispatch_exception(e, false),
-    reportUnhandledPromiseRejection: (e) => ops.op_dispatch_exception(e, true),
+    reportUnhandledException: (e) => op_dispatch_exception(e, false),
+    reportUnhandledPromiseRejection: (e) => op_dispatch_exception(e, true),
     queueTimer: (depth, repeat, timeout, task) =>
-      ops.op_timer_queue(depth, repeat, timeout, task),
-    cancelTimer: (id) => ops.op_timer_cancel(id),
-    refTimer: (id) => ops.op_timer_ref(id),
-    unrefTimer: (id) => ops.op_timer_unref(id),
+      op_timer_queue(depth, repeat, timeout, task),
+    cancelTimer: (id) => op_timer_cancel(id),
+    refTimer: (id) => op_timer_ref(id),
+    unrefTimer: (id) => op_timer_unref(id),
     getTimerDepth: () => timerDepth,
     build,
     setBuildInfo,
diff --git a/core/lib.deno_core.d.ts b/core/lib.deno_core.d.ts
index 0ad62a5..6c76958 100644
--- a/core/lib.deno_core.d.ts
+++ b/core/lib.deno_core.d.ts
@@ -100,6 +100,9 @@ declare namespace Deno {
     /** Encode a string to its Uint8Array representation. */
     function encode(input: string): Uint8Array;
 
+    /** Decode a string from its Uint8Array representation. */
+    function decode(input: Uint8Array): string;
+
     /**
      * Set a callback that will be called when the WebAssembly streaming APIs
      * (`WebAssembly.compileStreaming` and `WebAssembly.instantiateStreaming`)
@@ -176,6 +179,18 @@ declare namespace Deno {
       reason: any,
     ) => boolean;
 
+    /**
+     * Sets the handled promise rejection handler.
+     */
+    function setHandledPromiseRejectionHandler(
+      cb: PromiseHandledCallback,
+    ): PromiseHandledCallback;
+
+    export type PromiseHandledCallback = (
+      promise: Promise<unknown>,
+      reason: any,
+    ) => void;
+
     /**
      * Report an exception that was not handled by any runtime handler, and escaped to the
      * top level. This terminates the runtime.
diff --git a/core/ops_builtin.rs b/core/ops_builtin.rs
index 3141fd6..913287d 100644
--- a/core/ops_builtin.rs
+++ b/core/ops_builtin.rs
@@ -74,6 +74,7 @@ deno_core::extension!(
     ops_builtin_types::op_is_typed_array,
     ops_builtin_types::op_is_weak_map,
     ops_builtin_types::op_is_weak_set,
+    ops_builtin_v8::op_set_handled_promise_rejection_handler,
     ops_builtin_v8::op_timer_queue,
     ops_builtin_v8::op_timer_cancel,
     ops_builtin_v8::op_timer_ref,
@@ -81,7 +82,6 @@ deno_core::extension!(
     ops_builtin_v8::op_ref_op,
     ops_builtin_v8::op_unref_op,
     ops_builtin_v8::op_lazy_load_esm,
-    ops_builtin_v8::op_set_promise_reject_callback,
     ops_builtin_v8::op_run_microtasks,
     ops_builtin_v8::op_has_tick_scheduled,
     ops_builtin_v8::op_set_has_tick_scheduled,
diff --git a/core/ops_builtin_v8.rs b/core/ops_builtin_v8.rs
index 3bbb7be..784d8af 100644
--- a/core/ops_builtin_v8.rs
+++ b/core/ops_builtin_v8.rs
@@ -23,6 +23,15 @@ use std::rc::Rc;
 use v8::ValueDeserializerHelper;
 use v8::ValueSerializerHelper;
 
+#[op2]
+pub fn op_set_handled_promise_rejection_handler(
+  scope: &mut v8::HandleScope,
+  #[global] f: Option<v8::Global<v8::Function>>,
+) {
+  let exception_state = JsRealm::exception_state_from_scope(scope);
+  *exception_state.js_handled_promise_rejection_cb.borrow_mut() = f;
+}
+
 #[op2]
 pub fn op_ref_op(scope: &mut v8::HandleScope, promise_id: i32) {
   let context_state = JsRealm::state_from_scope(scope);
@@ -85,19 +94,6 @@ pub fn op_lazy_load_esm(
   module_map_rc.lazy_load_esm_module(scope, &module_specifier)
 }
 
-#[op2]
-pub fn op_set_promise_reject_callback<'a>(
-  scope: &mut v8::HandleScope<'a>,
-  #[global] cb: v8::Global<v8::Function>,
-) -> Option<v8::Local<'a, v8::Function>> {
-  let exception_state = JsRealm::exception_state_from_scope(scope);
-  let old = exception_state
-    .js_promise_reject_cb
-    .borrow_mut()
-    .replace(Rc::new(cb));
-  old.map(|v| v8::Local::new(scope, &*v))
-}
-
 // We run in a `nofast` op here so we don't get put into a `DisallowJavascriptExecutionScope` and we're
 // allowed to touch JS heap.
 #[op2(nofast)]
diff --git a/core/runtime/exception_state.rs b/core/runtime/exception_state.rs
index 6ea4278..782fc1b 100644
--- a/core/runtime/exception_state.rs
+++ b/core/runtime/exception_state.rs
@@ -14,10 +14,12 @@ pub(crate) struct ExceptionState {
   dispatched_exception_is_promise: Cell<bool>,
   pub(crate) pending_promise_rejections:
     RefCell<VecDeque<(v8::Global<v8::Promise>, v8::Global<v8::Value>)>>,
+  pub(crate) pending_handled_promise_rejections:
+    RefCell<VecDeque<(v8::Global<v8::Promise>, v8::Global<v8::Value>)>>,
   pub(crate) js_build_custom_error_cb:
     RefCell<Option<Rc<v8::Global<v8::Function>>>>,
-  pub(crate) js_promise_reject_cb:
-    RefCell<Option<Rc<v8::Global<v8::Function>>>>,
+  pub(crate) js_handled_promise_rejection_cb:
+    RefCell<Option<v8::Global<v8::Function>>>,
   pub(crate) js_format_exception_cb:
     RefCell<Option<Rc<v8::Global<v8::Function>>>>,
 }
@@ -31,7 +33,7 @@ impl ExceptionState {
   pub(crate) fn prepare_to_destroy(&self) {
     // TODO(mmastrac): we can probably move this to Drop eventually
     self.js_build_custom_error_cb.borrow_mut().take();
-    self.js_promise_reject_cb.borrow_mut().take();
+    self.js_handled_promise_rejection_cb.borrow_mut().take();
     self.js_format_exception_cb.borrow_mut().take();
     self.pending_promise_rejections.borrow_mut().clear();
     self.dispatched_exception.set(None);
@@ -102,8 +104,19 @@ impl ExceptionState {
     .map(|global| v8::Local::new(scope, global))
   }
 
-  /// Tracks this promise rejection until we have a chance to give it to the
-  /// unhandled promise rejection handler.
+  /// Tracks this promise rejection until we have a chance to give it to the unhandled promise rejection handler.
+  /// This performs the role of `HostPromiseRejectionTracker` from https://262.ecma-international.org/14.0/#sec-host-promise-rejection-tracker.
+  ///
+  /// Notes from ECMAScript's `HostPromiseRejectionTracker` operation:
+  ///
+  /// - HostPromiseRejectionTracker is called with the operation argument set to "reject" when a promise is rejected
+  /// without any handlers, or "handle" when a handler is added to a previously rejected promise for the first time.
+  /// - Host environments can use this operation to track promise rejections without causing abrupt completion.
+  /// - Implementations may notify developers of unhandled rejections and invalidate notifications if new handlers are attached.
+  /// - If operation is "handle", an implementation should not hold a reference to promise in a way that would
+  /// interfere with garbage collection.
+  /// - An implementation may hold a reference to promise if operation is "reject", since it is expected that rejections
+  /// will be rare and not on hot code paths.
   pub fn track_promise_rejection(
     &self,
     scope: &mut v8::HandleScope,
@@ -123,10 +136,25 @@ impl ExceptionState {
           .push_back((promise_global, error_global));
       }
       PromiseHandlerAddedAfterReject => {
-        self
-          .pending_promise_rejections
-          .borrow_mut()
-          .retain(|(key, _)| key != &promise_global);
+        // The code has until the event loop yields to attach a handler and avoid an unhandled rejection
+        // event. If we haven't delivered an unhandled exception event yet, we search for the old promise
+        // in this list and remove it. If it doesn't exist, that means it was already "handled as unhandled"
+        // and we need to fire a rejectionhandled event.
+        let mut rejections = self.pending_promise_rejections.borrow_mut();
+        let previous_len = rejections.len();
+        rejections.retain(|(key, _)| key != &promise_global);
+        if rejections.len() == previous_len {
+          // The unhandled rejection was already delivered, so this means we need to deliver a
+          // "rejectionhandled" event if anyone cares.
+          if self.js_handled_promise_rejection_cb.borrow().is_some() {
+            let error = promise.result(scope);
+            let error_global = v8::Global::new(scope, error);
+            self
+              .pending_handled_promise_rejections
+              .borrow_mut()
+              .push_back((promise_global, error_global));
+          }
+        }
       }
       PromiseRejectAfterResolved => {}
       PromiseResolveAfterResolved => {
diff --git a/core/runtime/jsruntime.rs b/core/runtime/jsruntime.rs
index 7b6a33e..388f81a 100644
--- a/core/runtime/jsruntime.rs
+++ b/core/runtime/jsruntime.rs
@@ -1730,7 +1730,7 @@ impl JsRuntime {
     {
       if pending_state.has_pending_background_tasks
         || pending_state.has_tick_scheduled
-        || pending_state.has_pending_promise_rejections
+        || pending_state.has_pending_promise_events
       {
         self.inner.state.waker.wake();
       } else
@@ -1891,7 +1891,7 @@ pub(crate) struct EventLoopPendingState {
   has_pending_module_evaluation: bool,
   has_pending_background_tasks: bool,
   has_tick_scheduled: bool,
-  has_pending_promise_rejections: bool,
+  has_pending_promise_events: bool,
 }
 
 impl EventLoopPendingState {
@@ -1909,11 +1909,16 @@ impl EventLoopPendingState {
     let has_pending_dyn_module_evaluation =
       modules.has_pending_dyn_module_evaluation();
     let has_pending_module_evaluation = modules.has_pending_module_evaluation();
-    let has_pending_promise_rejections = !state
+    let has_pending_promise_events = !state
       .exception_state
       .pending_promise_rejections
       .borrow()
-      .is_empty();
+      .is_empty()
+      || !state
+        .exception_state
+        .pending_handled_promise_rejections
+        .borrow()
+        .is_empty();
     EventLoopPendingState {
       has_pending_refed_ops: has_pending_tasks
         || has_pending_timers
@@ -1923,7 +1928,7 @@ impl EventLoopPendingState {
       has_pending_module_evaluation,
       has_pending_background_tasks: scope.has_pending_background_tasks(),
       has_tick_scheduled: state.has_next_tick_scheduled.get(),
-      has_pending_promise_rejections,
+      has_pending_promise_events,
     }
   }
 
@@ -1941,7 +1946,7 @@ impl EventLoopPendingState {
       || self.has_pending_module_evaluation
       || self.has_pending_background_tasks
       || self.has_tick_scheduled
-      || self.has_pending_promise_rejections
+      || self.has_pending_promise_events
   }
 }
 
@@ -2150,6 +2155,26 @@ impl JsRuntime {
     let has_tick_scheduled = context_state.has_next_tick_scheduled.get();
     dispatched_ops |= has_tick_scheduled;
 
+    while let Some((promise, result)) = exception_state
+      .pending_handled_promise_rejections
+      .borrow_mut()
+      .pop_front()
+    {
+      if let Some(handler) = exception_state
+        .js_handled_promise_rejection_cb
+        .borrow()
+        .as_ref()
+      {
+        let function = handler.open(scope);
+
+        let args = [
+          v8::Local::new(scope, promise).into(),
+          v8::Local::new(scope, result),
+        ];
+        function.call(scope, undefined, &args);
+      }
+    }
+
     let rejections = if !exception_state
       .pending_promise_rejections
       .borrow_mut()
diff --git a/ops/op2/dispatch_shared.rs b/ops/op2/dispatch_shared.rs
index ce0fb53..736fe47 100644
--- a/ops/op2/dispatch_shared.rs
+++ b/ops/op2/dispatch_shared.rs
@@ -37,7 +37,7 @@ pub fn v8_intermediate_to_global_arg(
   let arg = match arg {
     Arg::V8Global(_) => quote!(deno_core::v8::Global::new(&mut #isolate, #i)),
     Arg::OptionV8Global(_) => {
-      quote!(deno_core::v8::Global::new(&mut #isolate, #i))
+      quote!(match #i { None => None, Some(v) => Some(deno_core::v8::Global::new(&mut #isolate, v)) })
     }
     _ => unreachable!("Not a v8 global arg: {arg:?}"),
   };


Test patch:
diff --git a/core/runtime/tests/misc.rs b/core/runtime/tests/misc.rs
index da68a97..5928979 100644
--- a/core/runtime/tests/misc.rs
+++ b/core/runtime/tests/misc.rs
@@ -671,9 +671,9 @@ async fn test_set_macrotask_callback_set_next_tick_callback() {
         });
         Deno.core.setNextTickCallback(() => {
           results.push("nextTick");
-          Deno.core.ops.op_set_has_tick_scheduled(false);
+          Deno.core.setHasTickScheduled(false);
         });
-        Deno.core.ops.op_set_has_tick_scheduled(true);
+        Deno.core.setHasTickScheduled(true);
         await Deno.core.opAsync('op_async_sleep');
         if (results[0] != "nextTick") {
           throw new Error(`expected nextTick, got: ${results[0]}`);
@@ -722,7 +722,7 @@ fn test_has_tick_scheduled() {
           return true; // We're done.
         });
         Deno.core.setNextTickCallback(() => Deno.core.ops.op_next_tick());
-        Deno.core.ops.op_set_has_tick_scheduled(true);
+        Deno.core.setHasTickScheduled(true);
         "#,
     )
     .unwrap();
diff --git a/core/runtime/tests/ops.rs b/core/runtime/tests/ops.rs
index 1bf8f2e..6fc83ce 100644
--- a/core/runtime/tests/ops.rs
+++ b/core/runtime/tests/ops.rs
@@ -396,10 +396,9 @@ async fn test_ref_unref_ops() {
     .execute_script_static(
       "filename.js",
       r#"
-
-      var promiseIdSymbol = Symbol.for("Deno.core.internalPromiseId");
-      var p1 = Deno.core.opAsync("op_test", 42);
-      var p2 = Deno.core.opAsync("op_test", 42);
+      const { op_test } = Deno.core.ensureFastOps();
+      var p1 = op_test(42);
+      var p2 = op_test(42);
       "#,
     )
     .unwrap();
@@ -412,8 +411,8 @@ async fn test_ref_unref_ops() {
     .execute_script_static(
       "filename.js",
       r#"
-      Deno.core.ops.op_unref_op(p1[promiseIdSymbol]);
-      Deno.core.ops.op_unref_op(p2[promiseIdSymbol]);
+      Deno.core.unrefOpPromise(p1);
+      Deno.core.unrefOpPromise(p2);
       "#,
     )
     .unwrap();
@@ -426,8 +425,8 @@ async fn test_ref_unref_ops() {
     .execute_script_static(
       "filename.js",
       r#"
-      Deno.core.ops.op_ref_op(p1[promiseIdSymbol]);
-      Deno.core.ops.op_ref_op(p2[promiseIdSymbol]);
+      Deno.core.refOpPromise(p1);
+      Deno.core.refOpPromise(p2);
       "#,
     )
     .unwrap();
diff --git a/testing/checkin/runtime/__init.js b/testing/checkin/runtime/__init.js
index 24dcb11..b6b6685 100644
--- a/testing/checkin/runtime/__init.js
+++ b/testing/checkin/runtime/__init.js
@@ -10,5 +10,36 @@ globalThis.setTimeout = timers.setTimeout;
 globalThis.setInterval = timers.setInterval;
 globalThis.clearTimeout = timers.clearTimeout;
 globalThis.clearInterval = timers.clearInterval;
+Reflect.defineProperty(globalThis, "onunhandledrejection", {
+  set: (cb) => {
+    if (cb) {
+      Deno.core.setUnhandledPromiseRejectionHandler((promise, reason) => {
+        let defaultPrevented = false;
+        cb({
+          promise,
+          reason,
+          preventDefault: () => defaultPrevented = true,
+        });
+        return defaultPrevented;
+      });
+    } else {
+      Deno.core.setUnhandledPromiseRejectionHandler(null);
+    }
+  },
+});
+Reflect.defineProperty(globalThis, "onrejectionhandled", {
+  set: (cb) => {
+    if (cb) {
+      Deno.core.setHandledPromiseRejectionHandler((promise, reason) => {
+        cb({
+          promise,
+          reason,
+        });
+      });
+    } else {
+      Deno.core.setHandledPromiseRejectionHandler(null);
+    }
+  },
+});
 Deno.unrefTimer = timers.unrefTimer;
 Deno.refTimer = timers.refTimer;
diff --git a/testing/integration/error_rejection_catch/error_rejection_catch.out b/testing/integration/error_rejection_catch/error_rejection_catch.out
new file mode 100644
index 0000000..a8b958c
--- /dev/null
+++ b/testing/integration/error_rejection_catch/error_rejection_catch.out
@@ -0,0 +1,9 @@
+ok 1
+caught 1
+---
+unhandled: rejected 2
+ok 2
+caught 2
+---
+handled: rejected 2
+unhandled: rejected 3
diff --git a/testing/integration/error_rejection_catch/error_rejection_catch.ts b/testing/integration/error_rejection_catch/error_rejection_catch.ts
new file mode 100644
index 0000000..39905d4
--- /dev/null
+++ b/testing/integration/error_rejection_catch/error_rejection_catch.ts
@@ -0,0 +1,36 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+
+// This test should return the same output on a browser.
+
+globalThis.onunhandledrejection = (event) => {
+  console.log("unhandled: " + event.reason);
+  event.preventDefault();
+};
+
+globalThis.onrejectionhandled = ({ reason }) => {
+  console.log("handled: " + reason);
+};
+
+// catch handler added before event loop spins, not unhandled
+const p1 = Promise.reject("rejected 1");
+const p1a = p1.catch(() => {
+  console.log("caught 1");
+});
+console.log("ok 1");
+await p1a;
+
+console.log("---");
+
+// catch handler added after event loop spins, unhandled
+const p2 = Promise.reject("rejected 2");
+await new Promise((r) => setTimeout(r, 1));
+const p2a = p2.catch(() => {
+  console.log("caught 2");
+});
+console.log("ok 2");
+await p2a;
+
+console.log("---");
+
+const p3 = Promise.reject("rejected 3");
+await p3;
diff --git a/testing/lib.rs b/testing/lib.rs
index e472b65..93e0279 100644
--- a/testing/lib.rs
+++ b/testing/lib.rs
@@ -47,6 +47,7 @@ unit_test!(
 integration_test!(
   dyn_import_circular,
   error_async_stack,
+  error_rejection_catch,
   error_rejection_order,
   error_with_stack,
   error_without_stack,
diff --git a/testing/tsconfig.json b/testing/tsconfig.json
index db07612..d05433c 100644
--- a/testing/tsconfig.json
+++ b/testing/tsconfig.json
@@ -7,6 +7,7 @@
       "checkin:testing": ["checkin/runtime/testing.ts"]
     },
     "lib": ["ESNext", "DOM", "ES2023.Array"],
+    "module": "ES2022",
     "moduleDetection": "force"
   }
 }
diff --git a/testing/unit/encode_decode_test.ts b/testing/unit/encode_decode_test.ts
index 41f22a1..f05ba24 100644
--- a/testing/unit/encode_decode_test.ts
+++ b/testing/unit/encode_decode_test.ts
@@ -2,12 +2,12 @@
 import { assert, assertArrayEquals, assertEquals, test } from "checkin:testing";
 
 test(function testEmptyEncode() {
-  const empty = Deno.core.ops.op_encode("");
+  const empty = Deno.core.encode("");
   assertEquals(empty.length, 0);
 });
 
 test(function testEmptyDecode() {
-  const emptyBuf = Deno.core.ops.op_decode(new Uint8Array(0));
+  const emptyBuf = Deno.core.decode(new Uint8Array(0));
   assertEquals(emptyBuf, "");
 });
 
@@ -20,10 +20,10 @@ test(function testFixture1() {
     0xf0, 0x9d, 0x93, 0xbd
   ];
   assertArrayEquals(
-    Array.from(Deno.core.ops.op_encode("𝓽𝓮𝔁𝓽")),
+    Array.from(Deno.core.encode("𝓽𝓮𝔁𝓽")),
     fixture1,
   );
-  assertEquals(Deno.core.ops.op_decode(new Uint8Array(fixture1)), "𝓽𝓮𝔁𝓽");
+  assertEquals(Deno.core.decode(new Uint8Array(fixture1)), "𝓽𝓮𝔁𝓽");
 });
 
 test(function testFixture2() {
@@ -36,11 +36,11 @@ test(function testFixture2() {
     108, 100
   ];
   assertArrayEquals(
-    Array.from(Deno.core.ops.op_encode("Hello \udc12\ud834 World")),
+    Array.from(Deno.core.encode("Hello \udc12\ud834 World")),
     fixture2,
   );
   assertEquals(
-    Deno.core.ops.op_decode(new Uint8Array(fixture2)),
+    Deno.core.decode(new Uint8Array(fixture2)),
     "Hello �� World",
   );
 });
@@ -49,7 +49,7 @@ test(function testStringTooLarge() {
   // See https://github.com/denoland/deno/issues/6649
   let thrown = false;
   try {
-    Deno.core.ops.op_decode(new Uint8Array(2 ** 29));
+    Deno.core.decode(new Uint8Array(2 ** 29));
   } catch (e) {
     thrown = true;
     assert(e instanceof RangeError);