Skip to content

Commit

Permalink
feat: add "unhandledrejection" event support (denoland#12994)
Browse files Browse the repository at this point in the history
This commit adds support for "unhandledrejection" event.

This event will trigger event listeners registered using:

"globalThis.addEventListener("unhandledrejection")
"globalThis.onunhandledrejection"
This is done by registering a default handler using
"Deno.core.setPromiseRejectCallback" that allows to
handle rejected promises in JavaScript instead of Rust.

This commit will make it possible to polyfill
"process.on("unhandledRejection")" in the Node compat
layer.

Co-authored-by: Colin Ihrig <[email protected]>
  • Loading branch information
bartlomieju and cjihrig committed Jul 5, 2022
1 parent a919a5d commit daf7d6f
Show file tree
Hide file tree
Showing 11 changed files with 193 additions and 10 deletions.
11 changes: 11 additions & 0 deletions cli/dts/lib.deno.shared_globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,17 @@ declare class ErrorEvent extends Event {
constructor(type: string, eventInitDict?: ErrorEventInit);
}

interface PromiseRejectionEventInit extends EventInit {
promise: Promise<any>;
reason?: any;
}

declare class PromiseRejectionEvent extends Event {
readonly promise: Promise<any>;
readonly reason: any;
constructor(type: string, eventInitDict?: PromiseRejectionEventInit);
}

interface AbstractWorkerEventMap {
"error": ErrorEvent;
}
Expand Down
5 changes: 5 additions & 0 deletions cli/tests/integration/run_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2744,3 +2744,8 @@ itest!(followup_dyn_import_resolved {
args: "run --unstable --allow-read followup_dyn_import_resolves/main.ts",
output: "followup_dyn_import_resolves/main.ts.out",
});

itest!(unhandled_rejection {
args: "run --allow-read unhandled_rejection.js",
output: "unhandled_rejection.js.out",
});
11 changes: 11 additions & 0 deletions cli/tests/testdata/unhandled_rejection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
globalThis.addEventListener("unhandledrejection", (e) => {
console.log("unhandled rejection at:", e.promise, "reason:", e.reason);
e.preventDefault();
});

function Foo() {
this.bar = Promise.reject(new Error("bar not available"));
}

new Foo();
Promise.reject();
8 changes: 8 additions & 0 deletions cli/tests/testdata/unhandled_rejection.js.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
unhandled rejection at: Promise {
<rejected> Error: bar not available
at new Foo (file:///[WILDCARD]/testdata/unhandled_rejection.js:7:29)
at file:///[WILDCARD]/testdata/unhandled_rejection.js:10:1
} reason: Error: bar not available
at new Foo (file:///[WILDCARD]/testdata/unhandled_rejection.js:7:29)
at file:///[WILDCARD]/testdata/unhandled_rejection.js:10:1
unhandled rejection at: Promise { <rejected> undefined } reason: undefined
4 changes: 4 additions & 0 deletions core/01_core.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@
terminate: opSync.bind(null, "op_terminate"),
opNames: opSync.bind(null, "op_op_names"),
eventLoopHasMoreWork: opSync.bind(null, "op_event_loop_has_more_work"),
setPromiseRejectCallback: opSync.bind(
null,
"op_set_promise_reject_callback",
),
});

ObjectAssign(globalThis.__bootstrap, { core });
Expand Down
10 changes: 1 addition & 9 deletions core/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,6 @@ pub extern "C" fn promise_reject_callback(message: v8::PromiseRejectMessage) {
let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();

// 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.
Expand Down Expand Up @@ -323,6 +315,7 @@ pub extern "C" fn promise_reject_callback(message: v8::PromiseRejectMessage) {
}

if tc_scope.has_caught() {
// TODO(bartlomieju): ensure that TODO provided below is still valid.
// 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.
Expand All @@ -340,7 +333,6 @@ pub extern "C" fn promise_reject_callback(message: v8::PromiseRejectMessage) {
} 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();
Expand Down
6 changes: 6 additions & 0 deletions core/ops_builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) fn init_builtins() -> Extension {
op_metrics::decl(),
op_format_file_name::decl(),
op_is_proxy::decl(),
op_next_task::decl(),
])
.ops(crate::ops_builtin_v8::init_builtins_v8())
.build()
Expand Down Expand Up @@ -189,3 +190,8 @@ fn op_format_file_name(file_name: String) -> String {
fn op_is_proxy(value: serde_v8::Value) -> bool {
value.v8_value.is_proxy()
}

/// Empty op that when awaited forces a macrotask to run. Useful for
/// "unhandledrejection" event.
#[op]
async fn op_next_task() {}
48 changes: 48 additions & 0 deletions core/ops_builtin_v8.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ pub(crate) fn init_builtins_v8() -> Vec<OpDecl> {
op_apply_source_map::decl(),
op_set_format_exception_callback::decl(),
op_event_loop_has_more_work::decl(),
op_store_pending_promise_exception::decl(),
op_remove_pending_promise_exception::decl(),
op_has_pending_promise_exception::decl(),
]
}

Expand Down Expand Up @@ -810,3 +813,48 @@ fn op_event_loop_has_more_work(scope: &mut v8::HandleScope) -> bool {
|| has_pending_background_tasks
|| has_tick_scheduled
}

#[op(v8)]
fn op_store_pending_promise_exception<'a>(
scope: &mut v8::HandleScope<'a>,
promise: serde_v8::Value<'a>,
reason: serde_v8::Value<'a>,
) {
let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();
let promise_value =
v8::Local::<v8::Promise>::try_from(promise.v8_value).unwrap();
let promise_global = v8::Global::new(scope, promise_value);
let error_global = v8::Global::new(scope, reason.v8_value);
state
.pending_promise_exceptions
.insert(promise_global, error_global);
}

#[op(v8)]
fn op_remove_pending_promise_exception<'a>(
scope: &mut v8::HandleScope<'a>,
promise: serde_v8::Value<'a>,
) {
let state_rc = JsRuntime::state(scope);
let mut state = state_rc.borrow_mut();
let promise_value =
v8::Local::<v8::Promise>::try_from(promise.v8_value).unwrap();
let promise_global = v8::Global::new(scope, promise_value);
state.pending_promise_exceptions.remove(&promise_global);
}

#[op(v8)]
fn op_has_pending_promise_exception<'a>(
scope: &mut v8::HandleScope<'a>,
promise: serde_v8::Value<'a>,
) -> bool {
let state_rc = JsRuntime::state(scope);
let state = state_rc.borrow();
let promise_value =
v8::Local::<v8::Promise>::try_from(promise.v8_value).unwrap();
let promise_global = v8::Global::new(scope, promise_value);
state
.pending_promise_exceptions
.contains_key(&promise_global)
}
53 changes: 53 additions & 0 deletions ext/web/02_event.js
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,58 @@
[SymbolToStringTag] = "ProgressEvent";
}

class PromiseRejectionEvent extends Event {
#promise = null;
#reason = null;

get promise() {
return this.#promise;
}
get reason() {
return this.#reason;
}

constructor(
type,
{
bubbles,
cancelable,
composed,
promise,
reason,
} = {},
) {
super(type, {
bubbles: bubbles,
cancelable: cancelable,
composed: composed,
});

this.#promise = promise;
this.#reason = reason;
}

[SymbolFor("Deno.privateCustomInspect")](inspect) {
return inspect(consoleInternal.createFilteredInspectProxy({
object: this,
evaluate: this instanceof PromiseRejectionEvent,
keys: [
...EVENT_PROPS,
"promise",
"reason",
],
}));
}

// TODO(lucacasonato): remove when this interface is spec aligned
[SymbolToStringTag] = "PromiseRejectionEvent";
}

defineEnumerableProps(PromiseRejectionEvent, [
"promise",
"reason",
]);

const _eventHandlers = Symbol("eventHandlers");

function makeWrappedHandler(handler, isSpecialErrorEventHandler) {
Expand Down Expand Up @@ -1426,6 +1478,7 @@
window.MessageEvent = MessageEvent;
window.CustomEvent = CustomEvent;
window.ProgressEvent = ProgressEvent;
window.PromiseRejectionEvent = PromiseRejectionEvent;
window.dispatchEvent = EventTarget.prototype.dispatchEvent;
window.addEventListener = EventTarget.prototype.addEventListener;
window.removeEventListener = EventTarget.prototype.removeEventListener;
Expand Down
46 changes: 46 additions & 0 deletions runtime/js/99_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ delete Intl.v8BreakIterator;
PerformanceEntry: util.nonEnumerable(performance.PerformanceEntry),
PerformanceMark: util.nonEnumerable(performance.PerformanceMark),
PerformanceMeasure: util.nonEnumerable(performance.PerformanceMeasure),
PromiseRejectionEvent: util.nonEnumerable(PromiseRejectionEvent),
ProgressEvent: util.nonEnumerable(ProgressEvent),
ReadableStream: util.nonEnumerable(streams.ReadableStream),
ReadableStreamDefaultReader: util.nonEnumerable(
Expand Down Expand Up @@ -553,6 +554,43 @@ delete Intl.v8BreakIterator;
postMessage: util.writable(postMessage),
};

function promiseRejectCallback(type, promise, reason) {
switch (type) {
case 0: {
core.opSync("op_store_pending_promise_exception", promise, reason);
break;
}
case 1: {
core.opSync("op_remove_pending_promise_exception", promise);
break;
}
default:
return;
}
core.opAsync("op_next_task").then(() => {
const hasPendingException = core.opSync(
"op_has_pending_promise_exception",
promise,
);

if (!hasPendingException) {
return;
}

const event = new PromiseRejectionEvent("unhandledrejection", {
cancelable: true,
promise,
reason,
});
globalThis.dispatchEvent(event);

// If event was not prevented we will let Rust side handle it.
if (event.defaultPrevented) {
core.opSync("op_remove_pending_promise_exception", promise);
}
});
}

let hasBootstrapped = false;

function bootstrapMainRuntime(runtimeOptions) {
Expand Down Expand Up @@ -585,6 +623,10 @@ delete Intl.v8BreakIterator;
defineEventHandler(window, "load");
defineEventHandler(window, "beforeunload");
defineEventHandler(window, "unload");
defineEventHandler(window, "unhandledrejection");

core.setPromiseRejectCallback(promiseRejectCallback);

const isUnloadDispatched = SymbolFor("isUnloadDispatched");
// Stores the flag for checking whether unload is dispatched or not.
// This prevents the recursive dispatches of unload events.
Expand Down Expand Up @@ -685,6 +727,10 @@ delete Intl.v8BreakIterator;

defineEventHandler(self, "message");
defineEventHandler(self, "error", undefined, true);
defineEventHandler(self, "unhandledrejection");

core.setPromiseRejectCallback(promiseRejectCallback);

// `Deno.exit()` is an alias to `self.close()`. Setting and exit
// code using an op in worker context is a no-op.
os.setExitHandler((_exitCode) => {
Expand Down
1 change: 0 additions & 1 deletion tools/wpt/expectation.json
Original file line number Diff line number Diff line change
Expand Up @@ -4360,7 +4360,6 @@
"The CanvasPath interface object should be exposed.",
"The TextMetrics interface object should be exposed.",
"The Path2D interface object should be exposed.",
"The PromiseRejectionEvent interface object should be exposed.",
"The EventSource interface object should be exposed.",
"The XMLHttpRequestEventTarget interface object should be exposed.",
"The XMLHttpRequestUpload interface object should be exposed.",
Expand Down

0 comments on commit daf7d6f

Please sign in to comment.