From 81d0d90493cea67bcf9ce6d1aa11111b36f98d9f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:45:21 +0000 Subject: [PATCH] Preserve AsyncLocalStorage context in unhandledRejection handlers The promise rejection tracker records the rejection-time async context with the promise, and the unhandledRejection dispatch runs with that context installed and restores the previous one after, so listeners and the default printer observe the store the promise was rejected in, as in Node. A throwing listener halts later listeners and is reported after the restore; a handled throw still drains what the listener queued. --- src/jsc/JSGlobalObject.rs | 28 + src/jsc/VirtualMachine.rs | 93 +++- src/jsc/bindings/AsyncContextFrame.cpp | 9 + src/jsc/bindings/BunProcess.cpp | 14 +- src/jsc/bindings/ZigGlobalObject.cpp | 37 +- src/jsc/bindings/ZigGlobalObject.h | 3 +- src/jsc/bindings/webcore/EventEmitter.cpp | 15 +- src/jsc/bindings/webcore/EventEmitter.h | 5 +- src/jsc/lib.rs | 2 +- src/jsc/virtual_machine_exports.rs | 14 +- src/runtime/test_runner/jest.rs | 2 + .../AsyncLocalStorage-tracking.test.ts | 53 +- .../async_hooks/AsyncLocalStorage.test.ts | 520 +++++++++++++++++- ...nc-context-unhandled-rejection-async-fn.js | 98 ++++ ...xt-unhandled-rejection-finally-thenable.js | 40 ++ ...sync-context-unhandled-rejection-native.js | 52 ++ ...xt-unhandled-rejection-then-passthrough.js | 88 +++ .../async-context-unhandled-rejection.js | 86 +++ test/js/node/process/process.test.js | 76 ++- .../test-async-local-storage-errors.js | 121 ++++ 20 files changed, 1278 insertions(+), 78 deletions(-) create mode 100644 test/js/node/async_hooks/async-context/async-context-unhandled-rejection-async-fn.js create mode 100644 test/js/node/async_hooks/async-context/async-context-unhandled-rejection-finally-thenable.js create mode 100644 test/js/node/async_hooks/async-context/async-context-unhandled-rejection-native.js create mode 100644 test/js/node/async_hooks/async-context/async-context-unhandled-rejection-then-passthrough.js create mode 100644 test/js/node/async_hooks/async-context/async-context-unhandled-rejection.js create mode 100644 test/js/node/test/parallel/test-async-local-storage-errors.js diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 11115339f3f0..7b57790a6f1a 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -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 { @@ -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 { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 053879072380..95bdd6e324f1 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -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, + ) { + 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 { + 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) @@ -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. diff --git a/src/jsc/bindings/AsyncContextFrame.cpp b/src/jsc/bindings/AsyncContextFrame.cpp index bf654c7a0854..f92128c0f1f2 100644 --- a/src/jsc/bindings/AsyncContextFrame.cpp +++ b/src/jsc/bindings/AsyncContextFrame.cpp @@ -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(); \ diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 4deedb2a8b8f..3d4af8b02d54 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -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 { @@ -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 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()); + } return true; } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index bef187094985..603c683a22d9 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -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(entry)) { + if (asyncContext) + *asyncContext = frame->context.get(); + return uncheckedDowncast(frame->callback.get()); + } + return uncheckedDowncast(entry); +} + void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise* promise, JSC::JSPromiseRejectionOperation operation) { auto* globalObj = static_cast(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& unhandledPromise) { - return unhandledPromise.get() == promise; + } + case JSPromiseRejectionOperation::Handle: { + bool removed = globalObj->m_aboutToBeNotifiedRejectedPromises.removeFirstMatching(globalObj, [&](JSC::WriteBarrier& entry) { + return rejectedPromiseFromEntry(entry.get()) == promise; }); if (removed) break; // handleRejectedPromises() drains the list into a local buffer before @@ -1153,7 +1166,7 @@ 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; } } @@ -1161,6 +1174,7 @@ void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise* Bun__handleHandledPromise(globalObj, promise); break; } + } } void GlobalObject::setConsole(void* console) @@ -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); @@ -3301,7 +3318,8 @@ RefPtr 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() { @@ -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(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; diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 71be788a977a..c27b3d5598d0 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -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 m_aboutToBeNotifiedRejectedPromises; + // Each entry is a JSPromise, or an AsyncContextFrame holding one (as `callback`) plus its rejection-time context. + Bun::WriteBarrierList m_aboutToBeNotifiedRejectedPromises; public: // While handleRejectedPromises() is iterating its drained snapshot, this diff --git a/src/jsc/bindings/webcore/EventEmitter.cpp b/src/jsc/bindings/webcore/EventEmitter.cpp index f511c5500242..ddc6b8717ef6 100644 --- a/src/jsc/bindings/webcore/EventEmitter.cpp +++ b/src/jsc/bindings/webcore/EventEmitter.cpp @@ -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& returnedException) +{ + return fireEventListeners(eventType, arguments, &returnedException); +} + Vector EventEmitter::getEventNames() { auto* data = eventTargetData(); @@ -182,7 +187,7 @@ Vector 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* returnedException) { auto* data = eventTargetData(); @@ -205,7 +210,7 @@ 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; } @@ -213,7 +218,7 @@ bool EventEmitter::fireEventListeners(const Identifier& eventType, const MarkedA // 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* returnedException) { Ref protectedThis(*this); ASSERT(!listeners.isEmpty()); @@ -261,6 +266,10 @@ bool EventEmitter::innerInvokeEventListeners(const Identifier& eventType, Simple auto* exception = exceptionPtr.get(); if (exception) [[unlikely]] { + if (returnedException) { + *returnedException = exception; + return fired; + } auto errorIdentifier = vm.propertyNames->error; auto hasErrorListener = this->hasActiveEventListeners(errorIdentifier); if (!hasErrorListener || eventType == errorIdentifier) { diff --git a/src/jsc/bindings/webcore/EventEmitter.h b/src/jsc/bindings/webcore/EventEmitter.h index 475ebfd9bd81..4fa801c7f61b 100644 --- a/src/jsc/bindings/webcore/EventEmitter.h +++ b/src/jsc/bindings/webcore/EventEmitter.h @@ -57,6 +57,7 @@ class EventEmitter final : public ScriptWrappable, public CanMakeWeakPtr& returnedException); WEBCORE_EXPORT Vector getEventNames(); WEBCORE_EXPORT Vector getListeners(const Identifier& eventType); @@ -73,7 +74,7 @@ class EventEmitter final : public ScriptWrappable, public CanMakeWeakPtr* returnedException = nullptr); bool isFiringEventListeners() const; const EventEmitterData* eventTargetData() const; @@ -102,7 +103,7 @@ class EventEmitter final : public ScriptWrappable, public CanMakeWeakPtr* returnedException = nullptr); EventEmitterData m_eventTargetData; unsigned m_maxListeners { 10 }; diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index db5140a194ba..d8585989d497 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -772,7 +772,7 @@ pub use abort_signal::{AbortSignal, AbortSignalRef}; // re-exported here so `crate::VM` and `crate::vm::VM` name the same nominal // type (and likewise for `JSGlobalObject`). Both structs carry `UnsafeCell` // so `&T → *mut T` for FFI is sound under Stacked Borrows. -pub use self::js_global_object::{GlobalRef, JSGlobalObject, MicrotaskCallback}; +pub use self::js_global_object::{AsyncContextScope, GlobalRef, JSGlobalObject, MicrotaskCallback}; pub use self::vm::VM; /// Options for `JSGlobalObject::validate_integer_range` / `validate_bigint_range`. diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 8960511b7419..ea92ed6cf2dd 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -109,8 +109,13 @@ pub fn vm_handle_queue_task_concurrently( unsafe { crate::VmHandle::borrow_ref(r).post_cpp_task(crate::LoopKind::Regular, task) }; } +/// `async_context`: the promise's rejection-time async context; empty installs undefined, so a contextless rejection does not inherit a re-entrant drain's. // HOST_EXPORT(Bun__handleRejectedPromise, c) -pub fn handle_rejected_promise(global: &JSGlobalObject, promise: &mut JSPromise) { +pub fn handle_rejected_promise( + global: &JSGlobalObject, + promise: &mut JSPromise, + async_context: JSValue, +) { crate::mark_binding!(); let result = promise.result(global.vm()); @@ -121,7 +126,12 @@ pub fn handle_rejected_promise(global: &JSGlobalObject, promise: &mut JSPromise) return; } - jsc_vm.unhandled_rejection(global, result, promise.to_js()); + let rejection_context = if async_context.is_empty() { + JSValue::UNDEFINED + } else { + async_context + }; + jsc_vm.unhandled_rejection_in_context(global, result, promise.to_js(), Some(rejection_context)); jsc_vm.auto_garbage_collect(); } diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index d0ddb7033f31..5e95c2bb70d9 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -597,6 +597,8 @@ pub(crate) mod on_unhandled_rejection { rejection: JSValue, ) { if let Some(buntest_strong) = bun_test::clone_active_strong() { + // The erroring callback's async context may still be installed; `BunTest::run` below runs the next user callback. + let _async_context = global_object.enter_async_context(JSValue::UNDEFINED); // `buntest_strong` released by Rc drop. // SAFETY: single-threaded JS VM; `buntest_strong` is the only handle // dereferenced for this scope and is dropped before `BunTest::run` diff --git a/test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts b/test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts index 59cbb711dc82..87072fbd0917 100644 --- a/test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts +++ b/test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts @@ -1,5 +1,5 @@ import { Glob } from "bun"; -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isBroken, isLinux, nodeExe } from "harness"; import { basename, join } from "path"; @@ -10,26 +10,47 @@ describe.concurrent("AsyncLocalStorage passes context to callbacks", () => { if (isASAN && isBroken && isLinux) { todos.push("async-context-dns-resolveTxt.js"); } + // Fixtures node passes and bun fails. Each asserts that split, so it goes red (and moves + // to the plain list) when bun catches up. + const bunFails: Record = { + // Both settle a promise from a JSC internal microtask that does not carry the async + // context yet; bun passes once WEBKIT_VERSION includes oven-sh/WebKit#268. + "async-context-unhandled-rejection-finally-thenable.js": "oven-sh/WebKit#268", + "async-context-unhandled-rejection-then-passthrough.js": "oven-sh/WebKit#268", + // A promise that Bun's native layer rejects from an event-loop task (fetch, fs.promises) + // is rejected with no context installed, so the handler reads none. Node installs the + // resource's creation context around native settlement. + "async-context-unhandled-rejection-native.js": "native settlement installs no async context", + }; - files = files.filter(file => !todos.includes(basename(file))); + files = files.filter(file => !todos.includes(basename(file)) && !(basename(file) in bunFails)); + + async function run(exe: string, filepath: string, stdio: "inherit" | "ignore" = "inherit") { + const { exited } = Bun.spawn({ + cmd: [exe, filepath], + stdout: stdio, + stderr: stdio, + env: bunEnv, + }); + + if (await exited) { + throw new Error(`${basename(exe)} failed in ${filepath}`); + } + } for (const filepath of files) { const file = basename(filepath).replaceAll("async-context-", "").replaceAll(".js", ""); test(file, async () => { - async function run(exe) { - const { exited } = Bun.spawn({ - cmd: [exe, filepath], - stdout: "inherit", - stderr: "inherit", - env: bunEnv, - }); - - if (await exited) { - throw new Error(`${basename(exe)} failed in ${filepath}`); - } - } - - await Promise.all([run(bunExe()), run(nodeExe())]); + await Promise.all([run(bunExe(), filepath), run(nodeExe()!, filepath)]); + }); + } + + for (const [file, reason] of Object.entries(bunFails)) { + const filepath = join(import.meta.dir, "async-context", file); + const name = file.replaceAll("async-context-", "").replaceAll(".js", ""); + test(`${name} (node passes, bun fails: ${reason})`, async () => { + await run(nodeExe()!, filepath); + await expect(run(bunExe(), filepath, "ignore")).rejects.toThrow(`${basename(bunExe())} failed in ${filepath}`); }); } diff --git a/test/js/node/async_hooks/AsyncLocalStorage.test.ts b/test/js/node/async_hooks/AsyncLocalStorage.test.ts index 8b79bb118156..78d490877136 100644 --- a/test/js/node/async_hooks/AsyncLocalStorage.test.ts +++ b/test/js/node/async_hooks/AsyncLocalStorage.test.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage, AsyncResource } from "async_hooks"; import { heapStats } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, nodeExe, tempDir } from "harness"; import http2 from "http2"; describe("AsyncLocalStorage", () => { @@ -1622,3 +1622,521 @@ test("exit() and nested run() release the shadowed outer store", async () => { for (const n of alive()) expect(n).toBeLessThanOrEqual(N / 2); for (const t of timers) clearTimeout(t); }); + +describe.concurrent("unhandledRejection async context", () => { + const node = nodeExe(); + const runtimes: [string, string][] = node + ? [ + ["bun", bunExe()], + ["node", node], + ] + : [["bun", bunExe()]]; + + // Bun replays the context the promise was *rejected* in. Node >= 24 (and Node 22 + // with --experimental-async-context-frame) does the same; Node 22's default + // async_hooks-based AsyncLocalStorage replays the *creation* context instead. + // The dual-runtime fixture in async-context/ only covers cases where the two + // agree, so this one is bun-only. + test("the rejection-time context wins over the creation-time context", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const seen = []; + process.on("unhandledRejection", reason => { + seen.push(reason.message + "=" + JSON.stringify(als.getStore() ?? null)); + if (seen.length === 2) console.log(seen.join(" ")); + }); + + let rejectCreatedInA; + als.run("A", () => { new Promise((_, reject) => { rejectCreatedInA = reject; }); }); + als.run("B", () => rejectCreatedInA(new Error("created-in-A"))); + + let rejectCreatedInC; + als.run("C", () => { new Promise((_, reject) => { rejectCreatedInC = reject; }); }); + rejectCreatedInC(new Error("created-in-C"));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: `created-in-A="B" created-in-C=null\n`, + stderr: "", + exitCode: 0, + }); + }); + + // Rejections raised inside a context are stored wrapped in an AsyncContextFrame, + // so both places that match a promise against the pending list have to unwrap. + test("a promise handled in the same tick emits neither unhandledRejection nor rejectionHandled", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const events = []; + process.on("unhandledRejection", reason => events.push("unhandled:" + reason.message + ":" + als.getStore())); + process.on("rejectionHandled", () => events.push("rejectionHandled")); + + let late; + als.run("ctx", () => { + Promise.reject(new Error("same-tick")).catch(() => {}); + late = Promise.reject(new Error("late")); + }); + await new Promise(resolve => setImmediate(resolve)); + late.catch(() => {}); + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); + console.log(events.join(","));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "unhandled:late:ctx,rejectionHandled\n", + stderr: "", + exitCode: 0, + }); + }); + + // expect(fn).toThrow() drains pending rejections synchronously, so the drain can + // run while a context is installed. A promise rejected without one must still + // replay "no context" rather than inherit whatever the caller had, and the caller + // must get its own context back once the dispatch returns. + test("a contextless rejection drained from inside a context replays an undefined store", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { expect } = require("bun:test"); + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + process.on("unhandledRejection", () => { + console.log("store:", JSON.stringify(als.getStore() ?? null)); + }); + + Promise.reject(new Error("no-context")); + als.run({ id: "X" }, () => { + expect(() => { throw new Error("boom"); }).toThrow("boom"); + console.log("after the drain:", JSON.stringify(als.getStore() ?? null)); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: `store: null\nafter the drain: {"id":"X"}\n`, + stderr: "", + exitCode: 0, + }); + }); + + // --unhandled-rejections=strict and =throw route a rejection nobody listens for into + // uncaughtException. Node keeps the promise's context installed across the whole + // per-mode dispatch (lib/internal/process/promises.js), so the uncaughtException + // handler sees it too. It drains microtasks outside that window, so a continuation + // registered with no context must not pick the store up. + test.each(["strict", "throw"].flatMap(mode => runtimes.map(([name, exe]) => [mode, name, exe])))( + "--unhandled-rejections=%s keeps the context for uncaughtException only (%s)", + async (mode, _name, exe) => { + await using proc = Bun.spawn({ + cmd: [ + exe, + `--unhandled-rejections=${mode}`, + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + + let resumeDrainedMicrotask; + const pending = new Promise(resolve => { resumeDrainedMicrotask = resolve; }); + pending.then(() => { + console.log("drained microtask store:", als.getStore()); + process.exit(0); + }); + + process.on("uncaughtException", () => { + console.log("uncaughtException store:", als.getStore()); + resumeDrainedMicrotask(); + }); + als.run(7, () => { Promise.reject(new Error("strict")); });`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("uncaughtException store: 7\ndrained microtask store: undefined\n"); + expect(exitCode).toBe(0); + }, + ); + + // A throwing unhandledRejection listener halts iteration (subsequent listeners are + // skipped). Node restores the context the dispatch replaced before the throw propagates + // to uncaughtException. At a top-level drain that context is undefined, so the handler + // reads no store while a callback registered under the top-level enterWith("Y") still does. + test.each(runtimes)( + "a throwing unhandledRejection listener halts later listeners and reaches uncaughtException with the dispatch's context restored (%s)", + async (_name, exe) => { + await using proc = Bun.spawn({ + cmd: [ + exe, + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + als.enterWith("Y"); + const log = []; + process.on("unhandledRejection", () => { + log.push("first store=" + JSON.stringify(als.getStore() ?? null)); + throw new Error("from-listener"); + }); + process.on("unhandledRejection", () => { + log.push("second ran"); + }); + process.on("uncaughtException", () => { + log.push("uncaught store=" + JSON.stringify(als.getStore() ?? null)); + }); + als.run(7, () => Promise.reject(new Error("e"))); + setImmediate(() => { + log.push("after store=" + JSON.stringify(als.getStore() ?? null)); + console.log(log.join(" | ")); + process.exit(0); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(`first store=7 | uncaught store=null | after store="Y"\n`); + expect(exitCode).toBe(0); + }, + ); + + // A listener's throw ends the mode's dispatch early, but what the listener and the + // uncaughtException handler queued must still run before the process exits, with the + // context each was queued under. Nothing else keeps the loop alive here. + test.each( + ["default", "warn", "none", "throw", "strict", "warn-with-error-code"].flatMap(mode => + runtimes.map(([name, exe]) => [mode, name, exe]), + ), + )("--unhandled-rejections=%s still drains what a throwing listener queued (%s)", async (mode, _name, exe) => { + await using proc = Bun.spawn({ + cmd: [ + exe, + ...(mode === "default" ? [] : [`--unhandled-rejections=${mode}`]), + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const log = []; + const show = label => log.push(label + " store=" + JSON.stringify(als.getStore() ?? null)); + process.on("unhandledRejection", () => { + show("listener"); + Promise.resolve().then(() => show("listener microtask")); + process.nextTick(() => show("listener nextTick")); + throw new Error("from-listener"); + }); + process.on("unhandledRejection", () => show("second listener")); + process.on("uncaughtException", err => { + show("uncaught(" + err.message + ")"); + Promise.resolve().then(() => show("uncaught(" + err.message + ") microtask")); + }); + process.on("exit", () => console.log(log.join(" | "))); + als.run(7, () => Promise.reject(new Error("e")));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const listenerThrow = + "listener store=7 | uncaught(from-listener) store=null | listener nextTick store=7 | " + + (mode === "strict" ? "uncaught(e) microtask store=7 | " : "") + + "listener microtask store=7 | uncaught(from-listener) microtask store=null"; + expect(stdout).toBe(`${mode === "strict" ? "uncaught(e) store=7 | " : ""}${listenerThrow}\n`); + expect(exitCode).toBe(0); + }); + + // An enterWith() inside the callback that rejects must not outlive the dispatch: the + // listener and what it queues see the rejection-time store, and a callback registered + // with no context (the exit handler) reads none afterwards, as on node. Every + // non-default mode also drains microtasks inside the dispatch. + test.each( + ["warn", "none", "throw", "warn-with-error-code"].flatMap(mode => runtimes.map(([name, exe]) => [mode, name, exe])), + )( + "--unhandled-rejections=%s does not resurrect a stale enterWith() store after the dispatch (%s)", + async (mode, _name, exe) => { + await using proc = Bun.spawn({ + cmd: [ + exe, + `--unhandled-rejections=${mode}`, + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const log = []; + const show = label => log.push(label + " store=" + JSON.stringify(als.getStore() ?? null)); + + // Registered before any context exists, so they read the ambient slot as-is. + process.on("exit", () => { + show("exit"); + console.log(log.join(" | ")); + }); + process.on("warning", warning => show("warning(" + warning.name + ")")); + + process.on("unhandledRejection", () => { + show("listener"); + Promise.resolve().then(() => show("listener microtask")); + }); + + setTimeout(() => { + // "Y" stays in the ambient slot until the next microtask runs, and the rejection is + // dispatched before one does. + als.enterWith("Y"); + als.run("S", () => { + Promise.reject(new Error("e")); + }); + }, 1);`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const warnings = mode === "warn" ? `warning(UnhandledPromiseRejectionWarning) store="S" | `.repeat(2) : ""; + expect(stdout).toBe(`listener store="S" | ${warnings}listener microtask store="S" | exit store=null\n`); + expect(exitCode).toBe(0); + }, + ); + + // With no listener, the printer runs with the promise's context still installed, so an + // error whose stack it computes lazily sees the store. In throw mode the fall-through + // print comes after the dispatch's microtask drain, so it also checks that the drain + // gave the context back. + test.each(["default", "throw"])("the printer runs with the promise's context installed (%s)", async mode => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + ...(mode === "default" ? [] : [`--unhandled-rejections=${mode}`]), + "-e", + `const { AsyncLocalStorage } = require("node:async_hooks"); + const fs = require("node:fs"); + const als = new AsyncLocalStorage(); + als.run(7, () => { + const err = new Error("printed"); + Object.defineProperty(err, "stack", { + get() { + fs.writeSync(1, "stack getter store=" + JSON.stringify(als.getStore() ?? null) + "\\n"); + return "Error: printed\\n at "; + }, + }); + Promise.reject(err); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("stack getter store=7"); + expect(stdout).not.toContain("store=null"); + expect(stderr).toContain("printed"); + expect(exitCode).toBe(1); + }); + + // A worker has its own global, process object and rejection list; the dispatch runs + // there with the worker's context. The mode comes from the parent's command line. + test.each(runtimes)("the dispatch runs with the rejection-time context inside a worker (%s)", async (_name, exe) => { + await using proc = Bun.spawn({ + cmd: [ + exe, + "--unhandled-rejections=warn", + "-e", + `const { Worker } = require("node:worker_threads"); + const worker = new Worker( + \`const { AsyncLocalStorage } = require("node:async_hooks"); + const { parentPort } = require("node:worker_threads"); + const als = new AsyncLocalStorage(); + const log = []; + const show = label => log.push(label + " store=" + JSON.stringify(als.getStore() ?? null)); + process.on("exit", () => { + show("exit"); + parentPort.postMessage(log.join(" | ")); + }); + process.on("unhandledRejection", () => { + show("listener"); + Promise.resolve().then(() => show("listener microtask")); + }); + als.run("S", () => { + Promise.reject(new Error("e")); + });\`, + { eval: true }, + ); + worker.on("message", message => console.log(message)); + worker.on("exit", code => console.log("worker exit", code));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(`listener store="S" | listener microtask store="S" | exit store=null\nworker exit 0\n`); + expect(exitCode).toBe(0); + }); + + // A listener that stops its worker (process.exit()) hands the dispatch a termination, + // not an error: nothing is reported, and the worker exits with the requested code. + test.each(runtimes)("a worker's unhandledRejection listener can call process.exit() (%s)", async (_name, exe) => { + await using proc = Bun.spawn({ + cmd: [ + exe, + "-e", + `const { Worker } = require("node:worker_threads"); + const worker = new Worker( + \`const { parentPort } = require("node:worker_threads"); + process.on("unhandledRejection", () => { + parentPort.postMessage("listener"); + process.exit(3); + }); + Promise.reject(new Error("e"));\`, + { eval: true }, + ); + worker.on("message", message => console.log(message)); + worker.on("exit", code => console.log("worker exit", code));`, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "listener\nworker exit 3\n", stderr: "", exitCode: 0 }); + }); + + // `bun test` (isBunTest) doesn't dispatch the process event at all; the test + // runner's handler receives the rejection instead, and that path must not let + // the promise's context reach a later unwrapped test callback. + test("under `bun test`, an in-context rejection doesn't leak the store into the next test callback", async () => { + using dir = tempDir("als-unhandled-rejection-buntest", { + "probe.test.ts": ` + import { test } from "bun:test"; + import { AsyncLocalStorage } from "node:async_hooks"; + const als = new AsyncLocalStorage(); + + test("rejects inside a store", () => { + als.run({ id: "leaky" }, () => { + Promise.reject(new Error("in-store")); + }); + }); + + test("next callback observes no leaked store", () => { + console.log("PROBE store:", JSON.stringify(als.getStore() ?? null)); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "probe.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("PROBE store: null"); + expect(stderr).toContain("(pass) next callback observes no leaked store"); + expect(stderr).toContain("(fail) rejects inside a store"); + expect(exitCode).not.toBe(0); + }); + + // A timer callback reports its throw before it restores the async context, so the + // test runner's uncaught-exception path receives it with the store still installed + // and advances into the next hooks and tests from there. + test("under `bun test`, a throw from a timer inside a store doesn't leak the store into later hooks and tests", async () => { + using dir = tempDir("als-uncaught-timer-buntest", { + "probe.test.ts": ` + import { beforeEach, test } from "bun:test"; + import { AsyncLocalStorage } from "node:async_hooks"; + const als = new AsyncLocalStorage(); + + beforeEach(() => { + console.log("HOOK store:", JSON.stringify(als.getStore() ?? null)); + }); + + test("throws from a timer inside a store", async () => { + await new Promise(resolve => { + als.run({ id: "leaky-timer" }, () => { + setTimeout(() => { + resolve(); + throw new Error("in-timer"); + }, 1); + }); + }); + }); + + test("next callback observes no leaked store", () => { + console.log("PROBE store:", JSON.stringify(als.getStore() ?? null)); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "probe.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).not.toContain("leaky-timer"); + expect(stdout).toContain("HOOK store: null\nPROBE store: null"); + expect(stderr).toContain("(pass) next callback observes no leaked store"); + expect(stderr).toContain("in-timer"); + expect(exitCode).not.toBe(0); + }); + + // A throwing listener compiled in a node:vm context reaches the test runner's error + // path with that context's global, which is not the main one. The async-context guard + // there must still work on the main global's slot (the vm global shares it). + test("under `bun test`, a throwing listener from a vm context doesn't leak the store", async () => { + using dir = tempDir("als-vm-listener-buntest", { + "probe.test.ts": ` + import { test } from "bun:test"; + import { AsyncLocalStorage } from "node:async_hooks"; + import vm from "node:vm"; + const als = new AsyncLocalStorage(); + process.on("warning", vm.runInContext('() => { throw new Error("from-vm-listener"); }', vm.createContext({}))); + + test("emits a warning inside a store", async () => { + await als.run({ id: "leaky-vm" }, async () => { + process.emitWarning("w"); + await new Promise(resolve => setImmediate(resolve)); + }); + }); + + test("next callback observes no leaked store", () => { + console.log("PROBE store:", JSON.stringify(als.getStore() ?? null)); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "probe.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("PROBE store: null"); + expect(stderr).toContain("from-vm-listener"); + expect(stderr).toContain("(pass) next callback observes no leaked store"); + expect(exitCode).not.toBe(0); + }); +}); diff --git a/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-async-fn.js b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-async-fn.js new file mode 100644 index 000000000000..0526f39b5d26 --- /dev/null +++ b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-async-fn.js @@ -0,0 +1,98 @@ +process.exitCode = 1; +// Runs under node as well as bun (see AsyncLocalStorage-tracking.test.ts), so +// this stays a plain CommonJS script. +const { AsyncLocalStorage } = require("async_hooks"); +const { readFile } = require("fs/promises"); +const { tmpdir } = require("os"); +const { join } = require("path"); + +const asyncLocalStorage = new AsyncLocalStorage(); + +// An async function (or generator, or .finally() callback) that fails after it +// has suspended settles its promise from a later microtask, after the frame that +// entered the store is gone. The rejection still belongs to that store, so Node +// agrees regardless of version. +const expected = { + "await-throw": "await-throw", + "await-native-reject": "await-native-reject", + "escaped-async-fn": "escaped-async-fn", + "asyncgen-await-throw": "asyncgen-await-throw", + "asyncgen-for-await": "asyncgen-for-await", + "finally-throw": "finally-throw", +}; +const observed = {}; +let remaining = Object.keys(expected).length; + +const keyFor = reason => (reason && reason.code === "ENOENT" ? "await-native-reject" : reason.message); + +process.on("unhandledRejection", reason => { + const key = keyFor(reason); + if (!(key in expected) || key in observed) { + console.error(`FAIL: unexpected or duplicate unhandledRejection for ${JSON.stringify(key)}`); + process.exit(1); + } + observed[key] = asyncLocalStorage.getStore()?.test ?? null; + remaining--; +}); + +// Resumes from a macrotask, so the rejection happens in a later tick than the +// store was entered in, not merely a later microtask. +asyncLocalStorage.run({ test: "await-throw" }, async () => { + await new Promise(resolve => setImmediate(resolve)); + throw new Error("await-throw"); +}); + +// The rejection originates in a native promise the function awaits. +asyncLocalStorage.run({ test: "await-native-reject" }, async () => { + await readFile(join(tmpdir(), `async-context-missing-file-${process.pid}`)); +}); + +// The async function is defined outside any store and only called inside one. +const failsAfterAwait = async () => { + await 0; + throw new Error("escaped-async-fn"); +}; +asyncLocalStorage.run({ test: "escaped-async-fn" }, () => failsAfterAwait()); + +asyncLocalStorage.run({ test: "asyncgen-await-throw" }, () => { + (async function* () { + await 0; + throw new Error("asyncgen-await-throw"); + })().next(); +}); + +asyncLocalStorage.run({ test: "asyncgen-for-await" }, async () => { + for await (const _ of (async function* () { + await 0; + throw new Error("asyncgen-for-await"); + })()); +}); + +// Throwing from the callback rejects during .finally()'s first phase. +asyncLocalStorage.run({ test: "finally-throw" }, () => { + Promise.resolve().finally(() => { + throw new Error("finally-throw"); + }); +}); + +const deadline = performance.now() + 30_000; +(function probe() { + if (performance.now() > deadline) { + console.error(`FAIL: timed out with ${remaining} rejection(s) never delivered`); + process.exit(1); + } + if (remaining !== 0) { + setImmediate(probe); + return; + } + + for (const key of Object.keys(expected)) { + if (observed[key] !== expected[key]) { + console.error( + `FAIL: unhandledRejection for "${key}" observed store ${JSON.stringify(observed[key])}, expected ${JSON.stringify(expected[key])}`, + ); + process.exit(1); + } + } + process.exitCode = 0; +})(); diff --git a/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-finally-thenable.js b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-finally-thenable.js new file mode 100644 index 000000000000..520b4c2360a3 --- /dev/null +++ b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-finally-thenable.js @@ -0,0 +1,40 @@ +process.exitCode = 1; +// Runs under node as well as bun (see AsyncLocalStorage-tracking.test.ts), so +// this stays a plain CommonJS script. +const { AsyncLocalStorage } = require("async_hooks"); + +const asyncLocalStorage = new AsyncLocalStorage(); + +// A .finally() callback that returns a rejected thenable settles the outer +// promise from .finally()'s second phase, a separate microtask from the one that +// ran the callback. Kept apart from the other async shapes because it needs a +// JSC change (oven-sh/WebKit#268) that the rest of them do not. +let delivered = 0; +process.on("unhandledRejection", reason => { + if (++delivered > 1 || reason.message !== "finally-returns-rejected") { + console.error(`FAIL: unexpected or duplicate unhandledRejection: ${reason && reason.message}`); + process.exit(1); + } + const store = asyncLocalStorage.getStore()?.test ?? null; + if (store !== "finally-returns-rejected") { + console.error(`FAIL: observed store ${JSON.stringify(store)}, expected "finally-returns-rejected"`); + process.exit(1); + } +}); + +asyncLocalStorage.run({ test: "finally-returns-rejected" }, () => { + Promise.resolve().finally(() => Promise.reject(new Error("finally-returns-rejected"))); +}); + +const deadline = performance.now() + 30_000; +(function probe() { + if (performance.now() > deadline) { + console.error("FAIL: the rejection was never delivered"); + process.exit(1); + } + if (delivered === 0) { + setImmediate(probe); + return; + } + process.exitCode = 0; +})(); diff --git a/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-native.js b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-native.js new file mode 100644 index 000000000000..b0d58f4c8af8 --- /dev/null +++ b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-native.js @@ -0,0 +1,52 @@ +process.exitCode = 1; +// Runs under node as well as bun (see AsyncLocalStorage-tracking.test.ts), so +// this stays a plain CommonJS script. +const { AsyncLocalStorage } = require("async_hooks"); +const fs = require("fs"); +const path = require("path"); + +const asyncLocalStorage = new AsyncLocalStorage(); + +// Promises that the runtime's native layer rejects from an event-loop task, not +// from JS: a fetch() to a port nothing listens on, and fs.promises.readFile of +// a missing path. Node rejects them with the resource's creation context +// installed (AsyncWrap::context_frame_), so the handler reads the store. +const expected = new Set(["fetch", "fsp.readFile"]); +const delivered = new Set(); +process.on("unhandledRejection", (reason, promise) => { + const test = promise[Symbol.for("test")]; + if (!expected.has(test) || delivered.has(test)) { + console.error(`FAIL: unexpected or duplicate unhandledRejection: ${reason && reason.message}`); + process.exit(1); + } + delivered.add(test); + const store = asyncLocalStorage.getStore()?.test ?? null; + if (store !== test) { + console.error(`FAIL: observed store ${JSON.stringify(store)}, expected ${JSON.stringify(test)}`); + process.exit(1); + } +}); + +function tag(test, promise) { + promise[Symbol.for("test")] = test; +} + +asyncLocalStorage.run({ test: "fetch" }, () => { + tag("fetch", fetch("http://127.0.0.1:1/")); +}); +asyncLocalStorage.run({ test: "fsp.readFile" }, () => { + tag("fsp.readFile", fs.promises.readFile(path.join(__dirname, "does-not-exist"))); +}); + +const deadline = performance.now() + 30_000; +(function probe() { + if (performance.now() > deadline) { + console.error("FAIL: a rejection was never delivered"); + process.exit(1); + } + if (delivered.size < expected.size) { + setImmediate(probe); + return; + } + process.exitCode = 0; +})(); diff --git a/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-then-passthrough.js b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-then-passthrough.js new file mode 100644 index 000000000000..aaaa6ac9db9d --- /dev/null +++ b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection-then-passthrough.js @@ -0,0 +1,88 @@ +process.exitCode = 1; +// Runs under node as well as bun (see AsyncLocalStorage-tracking.test.ts), so +// this stays a plain CommonJS script. +const { AsyncLocalStorage } = require("async_hooks"); + +const asyncLocalStorage = new AsyncLocalStorage(); + +// In every case below the promise that ends up unhandled never runs a handler +// of its own: it adopts the settlement of another promise. That settlement is +// delivered by a separate microtask (PromiseResolveWithoutHandlerJob in JSC), +// which has to carry the context the adoption was set up in. Node agrees +// regardless of version, because the reported promise is also created in that +// context. Kept apart from the other shapes because this needs a JSC change +// (oven-sh/WebKit#268) that the rest of them do not. +const expected = { + // p.then(f) registered inside the store; p rejected later, outside of any store. + "then-pending": "then-pending", + // Promise.reject(e).then(f): the source is already rejected when then() runs. + "then-settled": "then-settled", + // resolve(alreadyRejected) inside the store adopts a promise rejected outside of it. + "adopt-executor": "adopt-executor", + // Same adoption through an async function's return value. + "adopt-async-return": "adopt-async-return", +}; +const observed = {}; +let remaining = Object.keys(expected).length; + +process.on("unhandledRejection", reason => { + const key = reason.message; + if (!(key in expected) || key in observed) { + console.error(`FAIL: unexpected or duplicate unhandledRejection for ${JSON.stringify(key)}`); + process.exit(1); + } + observed[key] = asyncLocalStorage.getStore()?.test ?? null; + remaining--; +}); + +// Rejected with no store active. The catch handler keeps these two from being +// reported themselves; only the promises that adopt them below are unhandled. +const rejectedOutside = {}; +for (const key of ["adopt-executor", "adopt-async-return"]) { + rejectedOutside[key] = Promise.reject(new Error(key)); + rejectedOutside[key].catch(() => {}); +} + +let rejectPending; +asyncLocalStorage.run({ test: "then-pending" }, () => { + new Promise((_, reject) => { + rejectPending = reject; + }).then(() => {}); +}); +// No store is active here, so the context the listener observes can only come +// from the then() registration above. +rejectPending(new Error("then-pending")); + +asyncLocalStorage.run({ test: "then-settled" }, () => { + Promise.reject(new Error("then-settled")).then(() => {}); +}); + +asyncLocalStorage.run({ test: "adopt-executor" }, () => { + new Promise(resolve => resolve(rejectedOutside["adopt-executor"])); +}); + +asyncLocalStorage.run({ test: "adopt-async-return" }, () => { + (async () => rejectedOutside["adopt-async-return"])(); +}); + +const deadline = performance.now() + 30_000; +(function probe() { + if (performance.now() > deadline) { + console.error(`FAIL: timed out with ${remaining} rejection(s) never delivered`); + process.exit(1); + } + if (remaining !== 0) { + setImmediate(probe); + return; + } + + for (const key of Object.keys(expected)) { + if (observed[key] !== expected[key]) { + console.error( + `FAIL: unhandledRejection for "${key}" observed store ${JSON.stringify(observed[key])}, expected ${JSON.stringify(expected[key])}`, + ); + process.exit(1); + } + } + process.exitCode = 0; +})(); diff --git a/test/js/node/async_hooks/async-context/async-context-unhandled-rejection.js b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection.js new file mode 100644 index 000000000000..b7c7e328c26e --- /dev/null +++ b/test/js/node/async_hooks/async-context/async-context-unhandled-rejection.js @@ -0,0 +1,86 @@ +process.exitCode = 1; +// Runs under node as well as bun (see AsyncLocalStorage-tracking.test.ts), so +// this stays a plain CommonJS script. +const { AsyncLocalStorage } = require("async_hooks"); + +const asyncLocalStorage = new AsyncLocalStorage(); + +// An unhandledRejection listener observes the AsyncLocalStorage context that +// was active when the promise was rejected. Every case below rejects in the +// same context the promise was created in, so Node agrees regardless of +// version; AsyncLocalStorage.test.ts pins the cases where they differ. +const expected = { + "sync-a": "a", + "sync-b": "b", + "no-context": null, + timer: "timer", + // Rejected last, from inside a context, so that the drain leaves a context + // installed unless it restores the previous one afterwards. + final: "final", +}; +const observed = {}; +let remaining = Object.keys(expected).length; + +process.on("unhandledRejection", reason => { + const key = reason.message; + if (!(key in expected) || key in observed) { + console.error(`FAIL: unexpected or duplicate unhandledRejection for ${JSON.stringify(key)}`); + process.exit(1); + } + observed[key] = asyncLocalStorage.getStore()?.test ?? null; + remaining--; +}); + +asyncLocalStorage.run({ test: "a" }, () => { + Promise.reject(new Error("sync-a")); +}); + +asyncLocalStorage.run({ test: "b" }, () => { + Promise.reject(new Error("sync-b")); +}); + +Promise.reject(new Error("no-context")); + +asyncLocalStorage.run({ test: "timer" }, () => { + new Promise((_, reject) => { + setTimeout(() => reject(new Error("timer")), 10); + }); +}); + +// Polls outside of any context, so each run observes what the rejection drain +// left in the slot: the drain must restore the previous context after every +// dispatch, not merely overwrite it before the next one. +const deadline = performance.now() + 30_000; +let finalQueued = false; +(function probe() { + const leaked = asyncLocalStorage.getStore(); + if (leaked !== undefined) { + console.error(`FAIL: rejection drain leaked an async context: ${JSON.stringify(leaked)}`); + process.exit(1); + } + + if (performance.now() > deadline) { + console.error(`FAIL: timed out with ${remaining} rejection(s) never delivered`); + process.exit(1); + } + if (remaining === 1 && !finalQueued) { + finalQueued = true; + asyncLocalStorage.run({ test: "final" }, () => { + Promise.reject(new Error("final")); + }); + } + if (remaining !== 0) { + setImmediate(probe); + return; + } + + for (const key of Object.keys(expected)) { + if (observed[key] !== expected[key]) { + console.error( + `FAIL: unhandledRejection for "${key}" observed store ${JSON.stringify(observed[key])}, expected ${JSON.stringify(expected[key])}`, + ); + process.exit(1); + } + } + process.exitCode = 0; +})(); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index ceacfaa82f87..a7390d388729 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1731,15 +1731,24 @@ describe.concurrent(() => { expect(await proc.exited).toBe(42); }); - it("delivers many unhandledRejections in order, including ones queued from the handler", async () => { - // Pins the observable behaviour: order is preserved, late .catch() - // suppresses delivery, and a rejection raised from inside the handler is - // also delivered. - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` + // With an AsyncLocalStorage active, every rejection is queued wrapped in an + // AsyncContextFrame, which both places that match against the pending list + // have to unwrap. + it.each([false, true])( + "delivers many unhandledRejections in order, including ones queued from the handler (AsyncLocalStorage: %p)", + async withAsyncLocalStorage => { + // Pins the observable behaviour: order is preserved, late .catch() + // suppresses delivery, and a rejection raised from inside the handler is + // also delivered. + const prelude = withAsyncLocalStorage + ? `const { AsyncLocalStorage } = require("async_hooks"); new AsyncLocalStorage().enterWith({ id: 1 });` + : ""; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + prelude + + ` const N = 1000; const seen = []; let nestedSeen = false; @@ -1778,14 +1787,47 @@ describe.concurrent(() => { throw new Error("spurious rejectionHandled fired " + spuriousRejectionHandled + "x"); console.log("ok"); `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "ok", stderr: "", exitCode: 0 }); - }); + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "ok", stderr: "", exitCode: 0 }); + }, + ); + + // A throwing unhandledRejection listener ends the dispatch (later listeners do not run) + // and is reported as an uncaught exception. With no handler for it the process is done: + // nothing the listener queued runs, and the exit code is 1, as on Node. Strict mode is + // left out: Bun still runs the listener after the fatal report, Node exits first. + it.each(["default", "none", "warn", "warn-with-error-code", "throw"])( + "a throwing unhandledRejection listener with no uncaughtException handler exits 1 (--unhandled-rejections=%s)", + async mode => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + ...(mode === "default" ? [] : [`--unhandled-rejections=${mode}`]), + "-e", + `process.on("warning", warning => console.log("warning:", warning.name)); + process.on("unhandledRejection", () => { + console.log("first ran"); + Promise.resolve().then(() => console.log("listener microtask ran")); + throw new Error("from-listener"); + }); + process.on("unhandledRejection", () => console.log("second ran")); + process.on("exit", code => console.log("exit code=", code)); + Promise.reject(new Error("e"));`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("from-listener"); + expect({ stdout, exitCode }).toEqual({ stdout: "first ran\nexit code= 1\n", exitCode: 1 }); + }, + ); it("aborts when the uncaughtException handler throws", async () => { const proc = Bun.spawn([bunExe(), join(import.meta.dir, "process-onUncaughtExceptionAbort.js")], { diff --git a/test/js/node/test/parallel/test-async-local-storage-errors.js b/test/js/node/test/parallel/test-async-local-storage-errors.js new file mode 100644 index 000000000000..f4a0d50b0a71 --- /dev/null +++ b/test/js/node/test/parallel/test-async-local-storage-errors.js @@ -0,0 +1,121 @@ +// Flags: --unhandled-rejections=throw +// Bun: upstream runs under node's default mode (throw). Bun's default never routes a rejection with no listener to uncaughtException, so err4 needs the flag. +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { AsyncLocalStorage } = require('async_hooks'); +const vm = require('vm'); + +// err1 is emitted sync as a control - no events +// err2 is emitted after a timeout - uncaughtExceptionMonitor +// + uncaughtException +// err3 is emitted after some awaits - unhandledRejection +// err4 is emitted during handling err3 - uncaughtExceptionMonitor +// err5 is emitted after err4 from a VM lacking hooks - unhandledRejection +// + uncaughtException + +const asyncLocalStorage = new AsyncLocalStorage(); +const callbackToken = { callbackToken: true }; +const awaitToken = { awaitToken: true }; + +let i = 0; + +// Redefining the uncaughtExceptionHandler is a bit odd, so we just do this +// so we can track total invocations +let underlyingExceptionHandler; +const exceptionHandler = common.mustCall(function(...args) { + return underlyingExceptionHandler.call(this, ...args); +}, 2); +process.setUncaughtExceptionCaptureCallback(exceptionHandler); + +const exceptionMonitor = common.mustCall((err, origin) => { + if (err.message === 'err2') { + assert.strictEqual(origin, 'uncaughtException'); + assert.strictEqual(asyncLocalStorage.getStore(), callbackToken); + } else if (err.message === 'err4') { + assert.strictEqual(origin, 'unhandledRejection'); + assert.strictEqual(asyncLocalStorage.getStore(), awaitToken); + } else { + assert.fail('unknown error ' + err); + } +}, 2); +process.on('uncaughtExceptionMonitor', exceptionMonitor); + +function fireErr1() { + underlyingExceptionHandler = common.mustCall(function(err) { + ++i; + assert.strictEqual(err.message, 'err2'); + assert.strictEqual(asyncLocalStorage.getStore(), callbackToken); + }, 1); + try { + asyncLocalStorage.run(callbackToken, () => { + setTimeout(fireErr2, 0); + throw new Error('err1'); + }); + } catch (e) { + assert.strictEqual(e.message, 'err1'); + assert.strictEqual(asyncLocalStorage.getStore(), undefined); + } +} + +function fireErr2() { + process.nextTick(() => { + assert.strictEqual(i, 1); + fireErr3(); + }); + throw new Error('err2'); +} + +function fireErr3() { + assert.strictEqual(asyncLocalStorage.getStore(), callbackToken); + const rejectionHandler3 = common.mustCall((err) => { + assert.strictEqual(err.message, 'err3'); + assert.strictEqual(asyncLocalStorage.getStore(), awaitToken); + process.off('unhandledRejection', rejectionHandler3); + + fireErr4(); + }, 1); + process.on('unhandledRejection', rejectionHandler3); + async function awaitTest() { + await null; + throw new Error('err3'); + } + asyncLocalStorage.run(awaitToken, awaitTest); +} + +const uncaughtExceptionHandler4 = common.mustCall( + function(err) { + assert.strictEqual(err.message, 'err4'); + assert.strictEqual(asyncLocalStorage.getStore(), awaitToken); + fireErr5(); + }, 1); +function fireErr4() { + assert.strictEqual(asyncLocalStorage.getStore(), awaitToken); + underlyingExceptionHandler = uncaughtExceptionHandler4; + // re-entrant check + Promise.reject(new Error('err4')); +} + +function fireErr5() { + assert.strictEqual(asyncLocalStorage.getStore(), awaitToken); + underlyingExceptionHandler = () => {}; + const rejectionHandler5 = common.mustCall((err) => { + assert.strictEqual(err.message, 'err5'); + assert.strictEqual(asyncLocalStorage.getStore(), awaitToken); + process.off('unhandledRejection', rejectionHandler5); + }, 1); + process.on('unhandledRejection', rejectionHandler5); + const makeOrphan = vm.compileFunction(`(${String(() => { + async function main() { + await null; + // eslint-disable-next-line node-core/must-call-assert + Promise.resolve().then(() => { + throw new Error('err5'); + }); + } + main(); + })})()`); + makeOrphan(); +} + +fireErr1();