Skip to content

Commit

Permalink
Adding support for uncaughtException and unhandledRejection events (
Browse files Browse the repository at this point in the history
#213)

* Centralizing uncaught exception reporting

* Adding uncaught_exception_monitor_callback pre-hook for exceptions

* Connecting exception capture callbacks with process events

* Running uncaught_exception capture callback on exceptions (WIP)

* Fixing REPL output when a capture is in place

* Using unhandledRejection capture callback for uncaught promises

* Handling multiple promise rejections

* Fixing double capture execution on errored module

* Refactoring

* Fixing duplicate error capturing on errored modules

* Fixing borrow panics when running capture callbacks

* Adding documentation to README.md

* Updating the the-runtime.md documentation
  • Loading branch information
aalykiot authored Apr 25, 2024
1 parent 9cefa5d commit 194182b
Show file tree
Hide file tree
Showing 13 changed files with 420 additions and 109 deletions.
47 changes: 33 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ For more examples look at the <a href="./examples">examples</a> directory.
- [x] `stdin`: Points to system's `stdin` stream.
- [x] `stderr`: Points to system's `stderr` stream.

##### Events

- [x] `uncaughtException`: Emitted when an uncaught exception bubbles up to Dune.
- [x] `unhandledRejection`: Emitted when a Promise is rejected with no handler.

> Signal events will be emitted when the Dune process receives a signal. Please refer to [signal(7)](https://man7.org/linux/man-pages/man7/signal.7.html) for a listing of standard POSIX signal names.
### File System

> This module also includes a `Sync` method for every async operation available.
Expand Down Expand Up @@ -174,10 +181,13 @@ For more examples look at the <a href="./examples">examples</a> directory.
- [x] `accept()`: Waits for a TCP client to connect and accepts the connection.
- [x] `address()`: Returns the bound address.
- [x] `close()`: Stops the server from accepting new connections and keeps existing connections.
- [x] `Event: 'listening'`: Emitted when the server has been bound after calling `server.listen`.
- [x] `Event: 'connection'`: Emitted when a new connection is made.
- [x] `Event: 'close'`: Emitted when the server stops accepting new connections.
- [x] `Event: 'error'`: Emitted when an error occurs.

##### Events

- [x] `listening`: Emitted when the server has been bound after calling `server.listen`.
- [x] `connection`: Emitted when a new connection is made.
- [x] `close`: Emitted when the server stops accepting new connections.
- [x] `error`: Emitted when an error occurs.

#### `net.Socket`

Expand All @@ -195,12 +205,15 @@ For more examples look at the <a href="./examples">examples</a> directory.
- [x] `remotePort`: The numeric representation of the remote port.
- [x] `bytesRead`: The amount of received bytes.
- [x] `bytesWritten`: The amount of bytes sent.
- [x] `Event: 'connect'`: Emitted when a socket connection is successfully established.
- [x] `Event: 'data'`: Emitted when data is received.
- [x] `Event: 'end'`: Emitted when the other end of the socket sends a FIN packet.
- [x] `Event: 'error'`: Emitted when an error occurs.
- [x] `Event: 'close'`: Emitted once the socket is fully closed.
- [x] `Event: 'timeout'`: Emitted if the socket times out from (read) inactivity.

##### Events

- [x] `connect`: Emitted when a socket connection is successfully established.
- [x] `data`: Emitted when data is received.
- [x] `end`: Emitted when the other end of the socket sends a FIN packet.
- [x] `error`: Emitted when an error occurs.
- [x] `close`: Emitted once the socket is fully closed.
- [x] `timeout`: Emitted if the socket times out from (read) inactivity.

### HTTP

Expand Down Expand Up @@ -244,9 +257,12 @@ Body Mixins
- [x] `listen(port, host?)`: Starts the HTTP server listening for connections.
- [x] `close()`: Stops the server from accepting new connections.
- [x] `accept()`: Waits for a client to connect and accepts the HTTP request.
- [x] `Event: 'request'`: Emitted each time there is a request.
- [x] `Event: 'close'`: Emitted when the server closes.
- [x] `Event: 'clientError'`: Emitted when a client connection emits an 'error' event.

##### Events

- [x] `request`: Emitted each time there is a request.
- [x] `close`: Emitted when the server closes.
- [x] `clientError`: Emitted when a client connection emits an 'error' event.

#### `http.ServerRequest`

Expand Down Expand Up @@ -274,7 +290,10 @@ Body Mixins
- [x] `removeHeader(name)`: Removes a header that's queued for implicit sending.
- [x] `headersSent`: Boolean (read-only). True if headers were sent, false otherwise.
- [x] `socket`: Reference to the underlying socket.
- [x] `Event: 'finish'`: Emitted when the (full) response has been sent.

##### Events

- [x] `finish`: Emitted when the (full) response has been sent.

### Stream

Expand Down
27 changes: 13 additions & 14 deletions docs/the-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,24 +131,21 @@ The provided code snippet is part of the **console** module. It takes multiple a
Let's take a look how the `process.stdout.write` is implemented.

```js
// File: /src/js/main.js
// File: /src/js/process.js
//
Object.defineProperty(process, 'stdout', {
get() {
return {
write: process.binding('stdio').write,
end() {},
};
},
configurable: true,
});
const io = process.binding('stdio');

defineStream('stdout', () => ({
write: io.write,
end() {},
}));
```

Let's break it down:

1. **`Object.defineProperty(process, 'stdout', {...});`**: This line alters the stdout property of the process object using the `Object.defineProperty` method. It defines how the stdout property can be accessed and manipulated.
1. **`defineStream('stdout', () => {...});`**: This line alters the stdout property of the process object using the `Object.defineProperty` method under the hood. It defines how the stdout property can be accessed and manipulated.

2. **`write: process.binding('stdio').write`**: This line assigns the write property to the write method from the **stdio** namespace. In simpler terms, it means that when `process.stdout.write` is invoked, it calls the write method from the `stdio` binding, governing the handling of output data.
2. **`write: io.write`**: This line assigns the write property to the write method from the **stdio** namespace. In simpler terms, it means that when `process.stdout.write` is invoked, it calls the write method from the `stdio` binding, governing the handling of output data.

This approach is widespread throughout the codebase. Whenever we need to use a Rust function, we employ the `process.binding` method and specify the desired namespace.

Expand Down Expand Up @@ -215,8 +212,8 @@ pub struct JsRuntimeState {
pub time_origin: u128,
/// Holds callbacks scheduled by nextTick.
pub next_tick_queue: NextTickQueue,
/// Holds exceptions from promises with no rejection handler.
pub promise_exceptions: HashMap<v8::Global<v8::Promise>, v8::Global<v8::Value>>,
/// Stores and manages uncaught exceptions.
pub exceptions: ExceptionState,
/// Runtime options.
pub options: JsRuntimeOptions,
/// Tracks wake event for current loop iteration.
Expand All @@ -226,6 +223,8 @@ pub struct JsRuntimeState {
}
```

> The above snippet might not be in sync with the current structure in the main branch. ☝️
To enable access to this state from various V8 structures, we store it within the isolate's `slot`.

```rust
Expand Down
2 changes: 2 additions & 0 deletions src/bindings.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::dns;
use crate::errors::extract_error_code;
use crate::errors::IoError;
use crate::exceptions;
use crate::file;
use crate::http_parser;
use crate::net;
Expand Down Expand Up @@ -30,6 +31,7 @@ lazy_static! {
("promise", promise::initialize),
("http_parser", http_parser::initialize),
("signals", signals::initialize),
("exceptions", exceptions::initialize),
];
HashMap::from_iter(bindings.into_iter())
};
Expand Down
5 changes: 5 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ pub fn unwrap_or_exit<T>(result: Result<T, Error>) -> T {
}
}

pub fn report_and_exit(error: JsError) {
eprint!("{error:?}");
std::process::exit(1);
}

/// Returns a string representation of the IO error's code.
pub fn extract_error_code(err: &IoError) -> Option<&'static str> {
match err.kind() {
Expand Down
135 changes: 135 additions & 0 deletions src/exceptions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use crate::bindings::set_function_to;
use crate::runtime::JsRuntime;
use std::collections::HashMap;

pub struct ExceptionState {
/// Holds the current uncaught exception.
pub exception: Option<v8::Global<v8::Value>>,
/// Holds uncaught promise rejections.
pub promise_rejections: HashMap<v8::Global<v8::Promise>, v8::Global<v8::Value>>,
/// Hook to run on an uncaught exception.
pub uncaught_exception_cb: Option<v8::Global<v8::Function>>,
/// Hook to run on an uncaught promise rejection.
pub unhandled_rejection_cb: Option<v8::Global<v8::Function>>,
}

impl ExceptionState {
/// Creates a new store with given report policy.
pub fn new() -> Self {
ExceptionState {
exception: None,
promise_rejections: HashMap::new(),
uncaught_exception_cb: None,
unhandled_rejection_cb: None,
}
}

/// Registers the uncaught exception.
pub fn capture_exception(&mut self, exception: v8::Global<v8::Value>) {
if self.exception.is_none() {
self.exception = Some(exception);
}
}

/// Registers a promise rejection to the store.
pub fn capture_promise_rejection(
&mut self,
promise: v8::Global<v8::Promise>,
reason: v8::Global<v8::Value>,
) {
self.promise_rejections.insert(promise, reason);
}

pub fn has_promise_rejection(&self) -> bool {
!self.promise_rejections.is_empty()
}

pub fn remove_promise_rejection(&mut self, promise: &v8::Global<v8::Promise>) {
self.promise_rejections.remove(promise);
}

pub fn remove_promise_rejection_entry(&mut self, exception: &v8::Global<v8::Value>) {
// Find the correct entry to remove.
let mut key_to_remove = None;
for (key, value) in self.promise_rejections.iter() {
if value == exception {
key_to_remove = Some(key.clone());
break;
}
}

if let Some(promise) = key_to_remove {
self.promise_rejections.remove(&promise);
}
}

pub fn set_uncaught_exception_callback(&mut self, callback: Option<v8::Global<v8::Function>>) {
self.uncaught_exception_cb = callback;
}

pub fn set_unhandled_rejection_callback(&mut self, callback: Option<v8::Global<v8::Function>>) {
self.unhandled_rejection_cb = callback;
}
}

pub fn initialize(scope: &mut v8::HandleScope) -> v8::Global<v8::Object> {
// Create local JS object.
let target = v8::Object::new(scope);

set_function_to(
scope,
target,
"setUncaughtExceptionCallback",
set_uncaught_exception_callback,
);

set_function_to(
scope,
target,
"setUnhandledRejectionCallback",
set_unhandled_rejection_callback,
);

// Return v8 global handle.
v8::Global::new(scope, target)
}

/// Setting the `uncaught_exception_callback` from JavaScript.
fn set_uncaught_exception_callback(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut rv: v8::ReturnValue,
) {
// Note: Passing `null` from JavaScript essentially will unset the defined callback.
let callback = match v8::Local::<v8::Function>::try_from(args.get(0)) {
Ok(callback) => Some(v8::Global::new(scope, callback)),
Err(_) => None,
};

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

state.exceptions.set_uncaught_exception_callback(callback);

rv.set(v8::Boolean::new(scope, true).into());
}

/// Setting the `unhandled_rejection_callback` from JavaScript.
fn set_unhandled_rejection_callback(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut rv: v8::ReturnValue,
) {
// Note: Passing `null` from JavaScript essentially will unset the defined callback.
let callback = match v8::Local::<v8::Function>::try_from(args.get(0)) {
Ok(callback) => Some(v8::Global::new(scope, callback)),
Err(_) => None,
};

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

state.exceptions.set_unhandled_rejection_callback(callback);

rv.set(v8::Boolean::new(scope, true).into());
}
6 changes: 4 additions & 2 deletions src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub extern "C" fn host_initialize_import_meta_object_cb(
) {
// Get `CallbackScope` from context.
let scope = &mut unsafe { v8::CallbackScope::new(context) };
let scope = &mut v8::HandleScope::new(scope);

let state = JsRuntime::state(scope);
let state = state.borrow();

Expand Down Expand Up @@ -127,11 +129,11 @@ pub extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {

match event {
PromiseHandlerAddedAfterReject => {
state.promise_exceptions.remove(&promise);
state.exceptions.remove_promise_rejection(&promise);
}
PromiseRejectWithNoHandler => {
let reason = v8::Global::new(scope, reason);
state.promise_exceptions.insert(promise, reason);
state.exceptions.capture_promise_rejection(promise, reason);
}
PromiseRejectAfterResolved | PromiseResolveAfterResolved => {}
}
Expand Down
37 changes: 37 additions & 0 deletions src/js/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,57 @@ function stopListeningIfNoListener(type) {
}
}

const exceptions = process.binding('exceptions');

const exceptionEmitFunction = (type, ...args) => {
// Emit the event.
process.emit(type, ...args);
// Remove captures if no listeners.
if (process.listenerCount(type) === 0) {
setCapturesIfExceptionEvent(type, true);
}
};

function setCapturesIfExceptionEvent(type, unset = false) {
// Note: We use the same function for both setting and removing the internal
// capture callbacks. To remove one, simply pass null as the JS callback.
const cb = !unset ? exceptionEmitFunction.bind(this, type) : null;

// https://nodejs.org/docs/latest/api/process.html#event-uncaughtexception
if (type === 'uncaughtException') {
exceptions.setUncaughtExceptionCallback(cb);
return;
}
// https://nodejs.org/docs/latest/api/process.html#event-unhandledrejection
if (type === 'unhandledRejection') {
exceptions.setUnhandledRejectionCallback(cb);
return;
}
}

function removeCapturesIfNoListener(type) {
// Remove the internal capture callback.
if (process.listenerCount(type) === 0) {
setCapturesIfExceptionEvent(type, true);
}
}

// Note: To ensure the full functionality, it's essential to 'override'
// specific methods inherited from the EventEmitter prototype.

for (const method of ['on', 'once']) {
process[method] = (event, ...args) => {
EventEmitter.prototype[method].call(process, event, ...args);
startListeningIfSignal(event);
setCapturesIfExceptionEvent(event);
};
}

for (const method of ['removeListener', 'removeAllListeners']) {
process[method] = (event, ...args) => {
EventEmitter.prototype[method].call(process, event, ...args);
stopListeningIfNoListener(event);
removeCapturesIfNoListener(event);
};
}

Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod cli;
mod dns;
mod dotenv;
mod errors;
mod exceptions;
mod file;
mod hooks;
mod http_parser;
Expand Down
Loading

0 comments on commit 194182b

Please sign in to comment.