Skip to content

Commit

Permalink
feat(core): intercept unhandled promise rejections (denoland#12910)
Browse files Browse the repository at this point in the history
Provide a programmatic means of intercepting rejected promises without a
.catch() handler. Needed for Node compat mode.

Also do a first pass at uncaughtException support because they're
closely intertwined in Node. It's like that Frank Sinatra song:
you can't have one without the other.

Stepping stone for denoland#7013.
  • Loading branch information
bnoordhuis committed Nov 27, 2021
1 parent 993a1dd commit 2d830c2
Show file tree
Hide file tree
Showing 3 changed files with 271 additions and 37 deletions.
188 changes: 151 additions & 37 deletions core/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ lazy_static::lazy_static! {
v8::ExternalReference {
function: set_nexttick_callback.map_fn_to()
},
v8::ExternalReference {
function: set_promise_reject_callback.map_fn_to()
},
v8::ExternalReference {
function: set_uncaught_exception_callback.map_fn_to()
},
v8::ExternalReference {
function: run_microtasks.map_fn_to()
},
Expand Down Expand Up @@ -171,6 +177,18 @@ pub fn initialize_context<'s>(
"setNextTickCallback",
set_nexttick_callback,
);
set_func(
scope,
core_val,
"setPromiseRejectCallback",
set_promise_reject_callback,
);
set_func(
scope,
core_val,
"setUncaughtExceptionCallback",
set_uncaught_exception_callback,
);
set_func(scope, core_val, "runMicrotasks", run_microtasks);
set_func(scope, core_val, "hasTickScheduled", has_tick_scheduled);
set_func(
Expand Down Expand Up @@ -320,30 +338,89 @@ pub extern "C" fn host_initialize_import_meta_object_callback(
}

pub extern "C" fn promise_reject_callback(message: v8::PromiseRejectMessage) {
use v8::PromiseRejectEvent::*;

let scope = &mut unsafe { v8::CallbackScope::new(&message) };

let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();

let promise = message.get_promise();
let promise_global = v8::Global::new(scope, promise);
// Node compat: perform synchronous process.emit("unhandledRejection").
//
// Note the callback follows the (type, promise, reason) signature of Node's
// internal promiseRejectHandler from lib/internal/process/promises.js, not
// the (promise, reason) signature of the "unhandledRejection" event listener.
//
// Short-circuits Deno's regular unhandled rejection logic because that's
// a) asynchronous, and b) always terminates.
if let Some(js_promise_reject_cb) = state.js_promise_reject_cb.clone() {
let js_uncaught_exception_cb = state.js_uncaught_exception_cb.clone();
drop(state); // Drop borrow, callbacks can call back into runtime.

let tc_scope = &mut v8::TryCatch::new(scope);
let undefined: v8::Local<v8::Value> = v8::undefined(tc_scope).into();
let type_ = v8::Integer::new(tc_scope, message.get_event() as i32);
let promise = message.get_promise();

let reason = match message.get_event() {
PromiseRejectWithNoHandler
| PromiseRejectAfterResolved
| PromiseResolveAfterResolved => message.get_value().unwrap_or(undefined),
PromiseHandlerAddedAfterReject => undefined,
};

match message.get_event() {
v8::PromiseRejectEvent::PromiseRejectWithNoHandler => {
let error = message.get_value().unwrap();
let error_global = v8::Global::new(scope, error);
state
.pending_promise_exceptions
.insert(promise_global, error_global);
let args = &[type_.into(), promise.into(), reason];
js_promise_reject_cb
.open(tc_scope)
.call(tc_scope, undefined, args);

if let Some(exception) = tc_scope.exception() {
if let Some(js_uncaught_exception_cb) = js_uncaught_exception_cb {
tc_scope.reset(); // Cancel pending exception.
js_uncaught_exception_cb.open(tc_scope).call(
tc_scope,
undefined,
&[exception],
);
}
}
v8::PromiseRejectEvent::PromiseHandlerAddedAfterReject => {
state.pending_promise_exceptions.remove(&promise_global);

if tc_scope.has_caught() {
// If we get here, an exception was thrown by the unhandledRejection
// handler and there is ether no uncaughtException handler or the
// handler threw an exception of its own.
//
// TODO(bnoordhuis) Node terminates the process or worker thread
// but we don't really have that option. The exception won't bubble
// up either because V8 cancels it when this function returns.
let exception = tc_scope
.stack_trace()
.or_else(|| tc_scope.exception())
.map(|value| value.to_rust_string_lossy(tc_scope))
.unwrap_or_else(|| "no exception".into());
eprintln!("Unhandled exception: {}", exception);
}
v8::PromiseRejectEvent::PromiseRejectAfterResolved => {}
v8::PromiseRejectEvent::PromiseResolveAfterResolved => {
// Should not warn. See #1272
} else {
let promise = message.get_promise();
let promise_global = v8::Global::new(scope, promise);

match message.get_event() {
PromiseRejectWithNoHandler => {
let error = message.get_value().unwrap();
let error_global = v8::Global::new(scope, error);
state
.pending_promise_exceptions
.insert(promise_global, error_global);
}
PromiseHandlerAddedAfterReject => {
state.pending_promise_exceptions.remove(&promise_global);
}
PromiseRejectAfterResolved => {}
PromiseResolveAfterResolved => {
// Should not warn. See #1272
}
}
};
}
}

fn opcall_sync<'s>(
Expand Down Expand Up @@ -545,31 +622,68 @@ fn set_nexttick_callback(
args: v8::FunctionCallbackArguments,
_rv: v8::ReturnValue,
) {
let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();

let cb = match v8::Local::<v8::Function>::try_from(args.get(0)) {
Ok(cb) => cb,
Err(err) => return throw_type_error(scope, err.to_string()),
};

state.js_nexttick_cbs.push(v8::Global::new(scope, cb));
if let Ok(cb) = arg0_to_cb(scope, args) {
JsRuntime::state(scope)
.borrow_mut()
.js_nexttick_cbs
.push(cb);
}
}

fn set_macrotask_callback(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
_rv: v8::ReturnValue,
) {
let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();
if let Ok(cb) = arg0_to_cb(scope, args) {
JsRuntime::state(scope)
.borrow_mut()
.js_macrotask_cbs
.push(cb);
}
}

let cb = match v8::Local::<v8::Function>::try_from(args.get(0)) {
Ok(cb) => cb,
Err(err) => return throw_type_error(scope, err.to_string()),
};
fn set_promise_reject_callback(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut rv: v8::ReturnValue,
) {
if let Ok(new) = arg0_to_cb(scope, args) {
if let Some(old) = JsRuntime::state(scope)
.borrow_mut()
.js_promise_reject_cb
.replace(new)
{
let old = v8::Local::new(scope, old);
rv.set(old.into());
}
}
}

state.js_macrotask_cbs.push(v8::Global::new(scope, cb));
fn set_uncaught_exception_callback(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut rv: v8::ReturnValue,
) {
if let Ok(new) = arg0_to_cb(scope, args) {
if let Some(old) = JsRuntime::state(scope)
.borrow_mut()
.js_uncaught_exception_cb
.replace(new)
{
let old = v8::Local::new(scope, old);
rv.set(old.into());
}
}
}

fn arg0_to_cb(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
) -> Result<v8::Global<v8::Function>, ()> {
v8::Local::<v8::Function>::try_from(args.get(0))
.map(|cb| v8::Global::new(scope, cb))
.map_err(|err| throw_type_error(scope, err.to_string()))
}

fn eval_context(
Expand Down Expand Up @@ -707,19 +821,19 @@ fn set_wasm_streaming_callback(
) {
use crate::ops_builtin::WasmStreamingResource;

let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();

let cb = match v8::Local::<v8::Function>::try_from(args.get(0)) {
let cb = match arg0_to_cb(scope, args) {
Ok(cb) => cb,
Err(err) => return throw_type_error(scope, err.to_string()),
Err(()) => return,
};

let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();

// The callback to pass to the v8 API has to be a unit type, so it can't
// borrow or move any local variables. Therefore, we're storing the JS
// callback in a JsRuntimeState slot.
if let slot @ None = &mut state.js_wasm_streaming_cb {
slot.replace(v8::Global::new(scope, cb));
slot.replace(cb);
} else {
return throw_type_error(
scope,
Expand Down
26 changes: 26 additions & 0 deletions core/lib.deno_core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,31 @@ declare namespace Deno {
function setMacrotaskCallback(
cb: () => bool,
): void;

/**
* Set a callback that will be called when a promise without a .catch
* handler is rejected. Returns the old handler or undefined.
*/
function setPromiseRejectCallback(
cb: PromiseRejectCallback,
): undefined | PromiseRejectCallback;

export type PromiseRejectCallback = (
type: number,
promise: Promise,
reason: any,
) => void;

/**
* Set a callback that will be called when an exception isn't caught
* by any try/catch handlers. Currently only invoked when the callback
* to setPromiseRejectCallback() throws an exception but that is expected
* to change in the future. Returns the old handler or undefined.
*/
function setUncaughtExceptionCallback(
cb: UncaughtExceptionCallback,
): undefined | UncaughtExceptionCallback;

export type UncaughtExceptionCallback = (err: any) => void;
}
}
94 changes: 94 additions & 0 deletions core/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ pub(crate) struct JsRuntimeState {
pub(crate) js_sync_cb: Option<v8::Global<v8::Function>>,
pub(crate) js_macrotask_cbs: Vec<v8::Global<v8::Function>>,
pub(crate) js_nexttick_cbs: Vec<v8::Global<v8::Function>>,
pub(crate) js_promise_reject_cb: Option<v8::Global<v8::Function>>,
pub(crate) js_uncaught_exception_cb: Option<v8::Global<v8::Function>>,
pub(crate) has_tick_scheduled: bool,
pub(crate) js_wasm_streaming_cb: Option<v8::Global<v8::Function>>,
pub(crate) pending_promise_exceptions:
Expand Down Expand Up @@ -351,6 +353,8 @@ impl JsRuntime {
js_sync_cb: None,
js_macrotask_cbs: vec![],
js_nexttick_cbs: vec![],
js_promise_reject_cb: None,
js_uncaught_exception_cb: None,
has_tick_scheduled: false,
js_wasm_streaming_cb: None,
js_error_create_fn,
Expand Down Expand Up @@ -2642,4 +2646,94 @@ assertEquals(1, notify_return_value);
.to_string()
.contains("JavaScript execution has been terminated"));
}

#[tokio::test]
async fn test_set_promise_reject_callback() {
let promise_reject = Arc::new(AtomicUsize::default());
let promise_reject_ = Arc::clone(&promise_reject);

let uncaught_exception = Arc::new(AtomicUsize::default());
let uncaught_exception_ = Arc::clone(&uncaught_exception);

let op_promise_reject = move |_: &mut OpState, _: (), _: ()| {
promise_reject_.fetch_add(1, Ordering::Relaxed);
Ok(())
};

let op_uncaught_exception = move |_: &mut OpState, _: (), _: ()| {
uncaught_exception_.fetch_add(1, Ordering::Relaxed);
Ok(())
};

let extension = Extension::builder()
.ops(vec![("op_promise_reject", op_sync(op_promise_reject))])
.ops(vec![(
"op_uncaught_exception",
op_sync(op_uncaught_exception),
)])
.build();

let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![extension],
..Default::default()
});

runtime
.execute_script(
"promise_reject_callback.js",
r#"
// Note: |promise| is not the promise created below, it's a child.
Deno.core.setPromiseRejectCallback((type, promise, reason) => {
if (type !== /* PromiseRejectWithNoHandler */ 0) {
throw Error("unexpected type: " + type);
}
if (reason.message !== "reject") {
throw Error("unexpected reason: " + reason);
}
Deno.core.opSync("op_promise_reject");
throw Error("promiseReject"); // Triggers uncaughtException handler.
});
Deno.core.setUncaughtExceptionCallback((err) => {
if (err.message !== "promiseReject") throw err;
Deno.core.opSync("op_uncaught_exception");
});
new Promise((_, reject) => reject(Error("reject")));
"#,
)
.unwrap();
runtime.run_event_loop(false).await.unwrap();

assert_eq!(1, promise_reject.load(Ordering::Relaxed));
assert_eq!(1, uncaught_exception.load(Ordering::Relaxed));

runtime
.execute_script(
"promise_reject_callback.js",
r#"
{
const prev = Deno.core.setPromiseRejectCallback((...args) => {
prev(...args);
});
}
{
const prev = Deno.core.setUncaughtExceptionCallback((...args) => {
prev(...args);
throw Error("fail");
});
}
new Promise((_, reject) => reject(Error("reject")));
"#,
)
.unwrap();
// Exception from uncaughtException handler doesn't bubble up but is
// printed to stderr.
runtime.run_event_loop(false).await.unwrap();

assert_eq!(2, promise_reject.load(Ordering::Relaxed));
assert_eq!(2, uncaught_exception.load(Ordering::Relaxed));
}
}

0 comments on commit 2d830c2

Please sign in to comment.