Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/jsc/JSGlobalObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,14 @@ impl JSGlobalObject {
crate::from_js_host_call_generic(self, || JSC__JSGlobalObject__handleRejectedPromises(self))
}

/// Installs `context` as the current async context until the guard drops.
pub fn enter_async_context(&self, context: JSValue) -> AsyncContextScope<'_> {
AsyncContextScope {
global: self,
previous: AsyncContextFrame__exchangeAsyncContext(self, context),
}
}

// The `readableStreamTo*` consumers throw `ERR_INVALID_ARG_TYPE` when
// `value` is not a `ReadableStream` and propagate what the consumer throws.
pub fn readable_stream_to_array_buffer(&self, value: JSValue) -> JsResult<JSValue> {
Expand Down Expand Up @@ -1592,6 +1600,26 @@ unsafe extern "C" {
) -> *mut JSGlobalObject;
}

unsafe extern "C" {
safe fn AsyncContextFrame__exchangeAsyncContext(
global: &JSGlobalObject,
context: JSValue,
) -> JSValue;
}

/// Puts back the async context [`JSGlobalObject::enter_async_context`] replaced; stack-only, so `previous` stays GC-visible.
#[must_use = "dropping immediately restores the previous async context; bind to a local"]
pub struct AsyncContextScope<'a> {
global: &'a JSGlobalObject,
previous: JSValue,
}

impl Drop for AsyncContextScope<'_> {
fn drop(&mut self) {
AsyncContextFrame__exchangeAsyncContext(self.global, self.previous);
}
}

impl ScriptExecutionContextIdentifier {
/// Returns `None` if the context referred to by `self` no longer exists.
pub(crate) fn global_object(self) -> Option<GlobalRef> {
Expand Down
93 changes: 68 additions & 25 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3848,28 +3848,71 @@ impl VirtualMachine {
reason: JSValue,
promise: JSValue,
) {
self.unhandled_rejection_in_context(global_object, reason, promise, None);
}

/// [`Self::unhandled_rejection`] with `rejection_context` (the promise's rejection-time async context) installed for the dispatch; a throwing listener is reported after the restore, as in Node.
pub fn unhandled_rejection_in_context(
&mut self,
global_object: &JSGlobalObject,
reason: JSValue,
promise: JSValue,
rejection_context: Option<JSValue>,
) {
let scope = rejection_context.map(|context| global_object.enter_async_context(context));
let dispatched = self.dispatch_unhandled_rejection(global_object, reason, promise);
drop(scope);
if let Err(e) = dispatched {
// A termination is not this frame's to take; it stays pending for the frames above.
if global_object.has_pending_termination_exception() {
return;
}
let exception = global_object.take_exception(e);
if exception.is_termination_exception() {
return;
}
// The early return skipped the mode's drain. A handled throw resumes the turn, so what the listener and the handler queued runs now; an unhandled one ends it, as on Node.
if self.uncaught_exception(global_object, exception, false) {
let _cleared = global_object.enter_async_context(JSValue::UNDEFINED);
let _ = self.event_loop_mut().drain_microtasks();
}
}
}

/// `Err` when an `unhandledRejection` listener threw: the exception is left pending for the caller.
fn dispatch_unhandled_rejection(
&mut self,
global_object: &JSGlobalObject,
reason: JSValue,
promise: JSValue,
) -> JsResult<()> {
use bun_options_types::schema::api::UnhandledRejections as Mode;

if self.is_shutting_down() || !self.script_allowed() || reason.is_termination_exception() {
bun_core::debug_warn!("unhandledRejection during shutdown.");
return;
return Ok(());
}

if isBunTest.load(core::sync::atomic::Ordering::Relaxed) {
self.unhandled_error_counter += 1;
(self.on_unhandled_rejection)(self, global_object, reason);
return;
return Ok(());
}

// Each arm drains microtasks on exit — hoisted into a closure.
// Each arm drains microtasks on exit with the context cleared, so a continuation with none of its own does not inherit the dispatch's.
let drain = |this: &mut Self| {
let _scope = global_object.enter_async_context(JSValue::UNDEFINED);
let _ = this.event_loop_mut().drain_microtasks();
};
// Wrapper over the `Bun__handleUnhandledRejection` FFI call (returns
// whether a JS handler claimed it). Captures `global_object` / `reason`
// / `promise` so the six branches below stay concise.
let handle_unhandled =
|| -> bool { Bun__handleUnhandledRejection(global_object, reason, promise) > 0 };
let handle_unhandled = || -> JsResult<bool> {
let handled = jsc::from_js_host_call_generic(global_object, || {
Bun__handleUnhandledRejection(global_object, reason, promise)
})?;
Ok(handled > 0)
};
let emit_warning = |this: &mut Self| {
let r = jsc::from_js_host_call_generic(global_object, || {
Bun__promises__emitUnhandledRejectionWarning(global_object, reason, promise)
Expand All @@ -3883,64 +3926,64 @@ impl VirtualMachine {

match self.unhandled_rejections_mode() {
Mode::Bun => {
if handle_unhandled() {
return;
if handle_unhandled()? {
return Ok(());
}
// continue to default handler
}
Mode::None => {
let _ = handle_unhandled();
handle_unhandled()?;
drain(self);
return; // ignore the unhandled rejection
return Ok(()); // ignore the unhandled rejection
}
Mode::Warn => {
let _ = handle_unhandled();
handle_unhandled()?;
emit_warning(self);
drain(self);
return;
return Ok(());
}
Mode::WarnWithErrorCode => {
let handled = handle_unhandled();
if !handled {
if !handle_unhandled()? {
emit_warning(self);
self.exit_handler.exit_code = 1;
}
drain(self);
if handled {
return;
}
return;
return Ok(());
}
Mode::Strict => {
let wrapped = unhandled_rejection_as_uncaught_error(global_object, reason);
let _ = self.uncaught_exception(global_object, wrapped, true);
let handled = handle_unhandled();
if !handled {
if !handle_unhandled()? {
emit_warning(self);
}
drain(self);
return;
return Ok(());
}
Mode::Throw => {
if handle_unhandled() {
if handle_unhandled()? {
drain(self);
return;
return Ok(());
}
let wrapped = unhandled_rejection_as_uncaught_error(global_object, reason);
if self.uncaught_exception(global_object, wrapped, true) {
drain(self);
return;
return Ok(());
}
// continue to default handler — but RETURN if this drain
// errors (the VM is dead; don't bump the counter or invoke the
// handler).
if self.event_loop_mut().drain_microtasks().is_err() {
return;
let drained = {
let _scope = global_object.enter_async_context(JSValue::UNDEFINED);
self.event_loop_mut().drain_microtasks()
};
if drained.is_err() {
return Ok(());
}
}
}
self.unhandled_error_counter += 1;
(self.on_unhandled_rejection)(self, global_object, reason);
Ok(())
}

/// After a hot reload, surfaces the entry-point promise's rejection (if any) and re-arms the watcher.
Expand Down
9 changes: 9 additions & 0 deletions src/jsc/bindings/AsyncContextFrame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ extern "C" JSC::EncodedJSValue AsyncContextFrame__withAsyncContextIfNeeded(JSGlo
return JSValue::encode(AsyncContextFrame::withAsyncContextIfNeeded(globalObject, JSValue::decode(callback)));
}

// Installs `context` as the current async context and returns the previous one. A node:vm context's global shares the main global's slot.
extern "C" JSC::EncodedJSValue AsyncContextFrame__exchangeAsyncContext(JSGlobalObject* globalObject, JSC::EncodedJSValue context)
{
auto* asyncContextData = globalObject->m_asyncContextData.get();
JSValue previous = asyncContextData->getInternalField(0);
asyncContextData->putInternalField(JSC::getVM(globalObject), 0, JSValue::decode(context));
return JSValue::encode(previous);
}

#define ASYNCCONTEXTFRAME_CALL_IMPL(...) \
if (!functionObject.isCell()) \
return jsUndefined(); \
Expand Down
14 changes: 13 additions & 1 deletion src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ extern "C" bool Bun__getEnvValue(JSC::JSGlobalObject* globalObject, const Encode
extern "C" bool Bun__Node__ProcessThrowDeprecation;
extern "C" bool Bun__Node__ProcessPendingDeprecation;
extern "C" void Bun__writeProfilesBeforeSelfKill();
extern "C" void JSC__JSGlobalObject__throwTerminationException(JSC::JSGlobalObject*);
extern "C" int32_t bun_stdio_tty[3];

namespace Bun {
Expand Down Expand Up @@ -1493,7 +1494,18 @@ extern "C" int Bun__handleUnhandledRejection(JSC::JSGlobalObject* lexicalGlobalO
MarkedArgumentBuffer args;
args.append(reason);
args.append(promise);
wrapped.emit(eventType, args);
// A throwing listener ends the dispatch; the caller reports it after restoring the async context, as Node does.
WTF::NakedPtr<JSC::Exception> listenerException;
wrapped.emit(eventType, args, listenerException);
if (listenerException) [[unlikely]] {
// A listener that stopped the VM (process.exit() in a worker, terminate()) left a termination whose request the entry scope exit already dropped: re-arm it instead of rethrowing that cell.
if (vm.isTerminationException(listenerException.get())) {
JSC__JSGlobalObject__throwTerminationException(globalObject);
return true;
}
auto throwScope = DECLARE_THROW_SCOPE(vm);
throwScope.throwException(globalObject, listenerException.get());
}
Comment thread
robobun marked this conversation as resolved.
return true;
}

Expand Down
37 changes: 28 additions & 9 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1131,18 +1131,31 @@ void GlobalObject::reportUncaughtExceptionAtEventLoop(JSGlobalObject* globalObje

extern "C" void Bun__handleHandledPromise(Zig::GlobalObject* JSGlobalObject, JSC::JSPromise* promise);

static JSC::JSPromise* rejectedPromiseFromEntry(JSC::JSCell* entry, JSC::JSValue* asyncContext = nullptr)
{
if (auto* frame = dynamicDowncast<AsyncContextFrame>(entry)) {
if (asyncContext)
*asyncContext = frame->context.get();
return uncheckedDowncast<JSC::JSPromise>(frame->callback.get());
}
return uncheckedDowncast<JSC::JSPromise>(entry);
}

void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise* promise,
JSC::JSPromiseRejectionOperation operation)
{
auto* globalObj = static_cast<GlobalObject*>(obj);

switch (operation) {
case JSPromiseRejectionOperation::Reject:
globalObj->m_aboutToBeNotifiedRejectedPromises.append(obj->vm(), globalObj, promise);
case JSPromiseRejectionOperation::Reject: {
// Snapshot the rejection-time async context; the event is only emitted at the end of the tick.
JSC::JSCell* entry = AsyncContextFrame::withAsyncContextIfNeeded(obj, promise).asCell();
globalObj->m_aboutToBeNotifiedRejectedPromises.append(obj->vm(), globalObj, entry);
break;
case JSPromiseRejectionOperation::Handle:
bool removed = globalObj->m_aboutToBeNotifiedRejectedPromises.removeFirstMatching(globalObj, [&](JSC::WriteBarrier<JSC::JSPromise>& unhandledPromise) {
return unhandledPromise.get() == promise;
}
case JSPromiseRejectionOperation::Handle: {
bool removed = globalObj->m_aboutToBeNotifiedRejectedPromises.removeFirstMatching(globalObj, [&](JSC::WriteBarrier<JSC::JSCell>& entry) {
return rejectedPromiseFromEntry(entry.get()) == promise;
Comment thread
robobun marked this conversation as resolved.
});
if (removed) break;
// handleRejectedPromises() drains the list into a local buffer before
Expand All @@ -1153,14 +1166,15 @@ void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise*
// handleRejectedPromises(), so there may be more than one).
for (auto* inflight = globalObj->m_rejectedPromisesBeingProcessed; inflight; inflight = inflight->outer) {
for (size_t i = inflight->index, n = inflight->buffer->size(); i < n; ++i) {
if (inflight->buffer->at(i).asCell() == promise)
if (rejectedPromiseFromEntry(inflight->buffer->at(i).asCell()) == promise)
return;
}
}
// The promise rejection has already been notified, now we need to queue it for the rejectionHandled event
Bun__handleHandledPromise(globalObj, promise);
break;
}
}
}

void GlobalObject::setConsole(void* console)
Expand Down Expand Up @@ -3096,6 +3110,9 @@ uint8_t GlobalObject::drainMicrotasks()
}
}
vm.drainMicrotasks();
// The job that constructed the first AsyncLocalStorage entered with tracking off, so nothing restored a frame it installed (the entry module's top-level enterWith()): end the checkpoint as it started.
if (!vm.entryScope)
m_asyncContextData.get()->putInternalField(vm, 0, jsUndefined());
if (auto* exception = scope.exception()) {
if (vm.isTerminationException(exception)) {
Bun__VM__takeTerminationOutsideScript(this);
Expand Down Expand Up @@ -3301,7 +3318,8 @@ RefPtr<Performance> GlobalObject::performance()
return m_performance;
}

extern "C" void Bun__handleRejectedPromise(Zig::GlobalObject* JSGlobalObject, JSC::JSPromise* promise);
// `asyncContext` is the promise's rejection-time async context (empty for a promise rejected with none).
extern "C" void Bun__handleRejectedPromise(Zig::GlobalObject* JSGlobalObject, JSC::JSPromise* promise, JSC::EncodedJSValue asyncContext);

void GlobalObject::handleRejectedPromises()
{
Expand All @@ -3324,12 +3342,13 @@ void GlobalObject::handleRejectedPromises()
InFlightRejections inflight { &promises, 0, m_rejectedPromisesBeingProcessed };
WTF::SetForScope inflightScope(m_rejectedPromisesBeingProcessed, &inflight);
for (size_t i = 0, size = promises.size(); i < size; ++i) {
auto* promise = static_cast<JSC::JSPromise*>(promises.at(i).asCell());
JSC::JSValue asyncContext;
auto* promise = rejectedPromiseFromEntry(promises.at(i).asCell(), &asyncContext);
if (promise->isHandled())
continue;
inflight.index = i + 1;

Bun__handleRejectedPromise(this, promise);
Bun__handleRejectedPromise(this, promise, JSC::JSValue::encode(asyncContext));
if (auto ex = scope.exception()) {
if (virtual_machine.isTerminationException(ex)) [[unlikely]]
return;
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/bindings/ZigGlobalObject.h
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,8 @@ class GlobalObject : public Bun::GlobalScope {
DOMGuardedObjectSet m_guardedObjects WTF_GUARDED_BY_LOCK(m_gcLock);
WebCore::SubtleCrypto* m_subtleCrypto = nullptr;

Bun::WriteBarrierList<JSC::JSPromise> m_aboutToBeNotifiedRejectedPromises;
// Each entry is a JSPromise, or an AsyncContextFrame holding one (as `callback`) plus its rejection-time context.
Bun::WriteBarrierList<JSC::JSCell> m_aboutToBeNotifiedRejectedPromises;

public:
// While handleRejectedPromises() is iterating its drained snapshot, this
Expand Down
15 changes: 12 additions & 3 deletions src/jsc/bindings/webcore/EventEmitter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ bool EventEmitter::emit(const Identifier& eventType, const MarkedArgumentBuffer&
return fireEventListeners(eventType, arguments);
}

bool EventEmitter::emit(const Identifier& eventType, const MarkedArgumentBuffer& arguments, WTF::NakedPtr<JSC::Exception>& returnedException)
{
return fireEventListeners(eventType, arguments, &returnedException);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Vector<Identifier> EventEmitter::getEventNames()
{
auto* data = eventTargetData();
Expand Down Expand Up @@ -182,7 +187,7 @@ Vector<JSObject*> EventEmitter::getListeners(const Identifier& eventType)
}

// https://dom.spec.whatwg.org/#concept-event-listener-invoke
bool EventEmitter::fireEventListeners(const Identifier& eventType, const MarkedArgumentBuffer& arguments)
bool EventEmitter::fireEventListeners(const Identifier& eventType, const MarkedArgumentBuffer& arguments, WTF::NakedPtr<JSC::Exception>* returnedException)
{

auto* data = eventTargetData();
Expand All @@ -205,15 +210,15 @@ bool EventEmitter::fireEventListeners(const Identifier& eventType, const MarkedA

bool prevFiringEventListeners = data->isFiringEventListeners;
data->isFiringEventListeners = true;
auto fired = innerInvokeEventListeners(eventType, *listenersVector, arguments);
auto fired = innerInvokeEventListeners(eventType, *listenersVector, arguments, returnedException);
data->isFiringEventListeners = prevFiringEventListeners;
return fired;
}

// Intentionally creates a copy of the listeners vector to avoid event listeners added after this point from being run.
// Note that removal still has an effect due to the removed field in RegisteredEventListener.
// https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke
bool EventEmitter::innerInvokeEventListeners(const Identifier& eventType, SimpleEventListenerVector listeners, const MarkedArgumentBuffer& arguments)
bool EventEmitter::innerInvokeEventListeners(const Identifier& eventType, SimpleEventListenerVector listeners, const MarkedArgumentBuffer& arguments, WTF::NakedPtr<JSC::Exception>* returnedException)
{
Ref<EventEmitter> protectedThis(*this);
ASSERT(!listeners.isEmpty());
Expand Down Expand Up @@ -261,6 +266,10 @@ bool EventEmitter::innerInvokeEventListeners(const Identifier& eventType, Simple
auto* exception = exceptionPtr.get();

if (exception) [[unlikely]] {
if (returnedException) {
*returnedException = exception;
return fired;
}
Comment thread
robobun marked this conversation as resolved.
auto errorIdentifier = vm.propertyNames->error;
auto hasErrorListener = this->hasActiveEventListeners(errorIdentifier);
if (!hasErrorListener || eventType == errorIdentifier) {
Expand Down
Loading
Loading