From 55927aa2f26884686fe90a8de38f1c0fda90b800 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:43:49 +0000 Subject: [PATCH 1/2] JSC: propagate async context through PromiseFinallyAwaitJob When a .finally() callback returns a thenable, the follow-up that propagates the original fulfilment (or rejects with the thenable's reason) runs as InternalMicrotask::PromiseFinallyAwaitJob. Unlike PromiseFinallyReactionJob, this case did not install the async context, and the phase-1 handler did not capture it on the reaction it schedules, so an unhandled rejection originating here observed an undefined AsyncLocalStorage store. Capture the active async context alongside the reaction at the two schedule points in promiseFinallyReactionJob (wrapWithCurrent), and install/restore it in the PromiseFinallyAwaitJob case (unwrapContextTuple + AsyncContextSwapScope), mirroring PromiseFinallyReactionJob. The synchronous promiseFinallyAwaitJob calls remain inside the phase-1 install window and are unaffected. --- Source/JavaScriptCore/runtime/JSMicrotask.cpp | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Source/JavaScriptCore/runtime/JSMicrotask.cpp b/Source/JavaScriptCore/runtime/JSMicrotask.cpp index 7bf56f7caa3a..ec258ae96389 100644 --- a/Source/JavaScriptCore/runtime/JSMicrotask.cpp +++ b/Source/JavaScriptCore/runtime/JSMicrotask.cpp @@ -899,11 +899,22 @@ static void promiseFinallyReactionJob(JSGlobalObject* globalObject, VM& vm, JSPr context->setHandlerOrContext(vm, valueOrReason); context->setPerCellBit(status == JSPromise::Status::Fulfilled); +#if USE(BUN_JSC_ADDITIONS) + // PromiseFinallyAwaitJob may run as a later microtask (scheduled below via + // performPromiseThenWithInternalMicrotask or + // createResolvingFunctionsWithInternalMicrotask), after this call's async + // context has been unwound. Capture it with the reaction so that phase 2 + // can restore it, like PromiseFinallyReactionJob does for phase 1. + JSValue scheduledContext = AsyncContextSwapScope::wrapWithCurrent(vm, globalObject, context); +#else + JSValue scheduledContext = context; +#endif + if (result.inherits()) { auto* promise = uncheckedDowncast(result); if (promise->realm() == globalObject && promise->isThenFastAndNonObservable()) { scope.release(); - promise->performPromiseThenWithInternalMicrotask(vm, InternalMicrotask::PromiseFinallyAwaitJob, resultPromise, context); + promise->performPromiseThenWithInternalMicrotask(vm, InternalMicrotask::PromiseFinallyAwaitJob, resultPromise, scheduledContext); return; } } @@ -945,7 +956,7 @@ static void promiseFinallyReactionJob(JSGlobalObject* globalObject, VM& vm, JSPr return; } - auto [resolve, reject] = JSPromise::createResolvingFunctionsWithInternalMicrotask(vm, globalObject, InternalMicrotask::PromiseFinallyAwaitJob, context); + auto [resolve, reject] = JSPromise::createResolvingFunctionsWithInternalMicrotask(vm, globalObject, InternalMicrotask::PromiseFinallyAwaitJob, scheduledContext); scope.release(); promiseResolveThenableJob(globalObject, resolutionObject, then, resolve, reject, microtaskCallCache); } @@ -2110,8 +2121,13 @@ void runInternalMicrotask(JSGlobalObject* globalObject, VM& vm, InternalMicrotas // arguments[0] = unused (we get resultPromise from context) // arguments[1] = settled value from onFinally's result // arguments[2] = context (JSSlimPromiseReaction: promise=resultPromise, handlerOrContext=originalValue, perCellBit=wasFulfilled) + // OR InternalFieldTuple: [context, asyncContext] when Bun async context is present // payload = status of onFinally's result - auto* context = uncheckedDowncast(arguments[2]); + JSValue contextArg = arguments[2]; +#if USE(BUN_JSC_ADDITIONS) + AsyncContextSwapScope asyncContextScope(vm, globalObject, AsyncContextSwapScope::unwrapContextTuple(contextArg)); +#endif + auto* context = uncheckedDowncast(contextArg); auto* resultPromise = uncheckedDowncast(context->promise()); scope.release(); promiseFinallyAwaitJob(resultPromise->realm(), vm, From b1fbd2f1b0b010afe7bed7330d79375f91a94463 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:05:08 +0000 Subject: [PATCH 2/2] JSC: propagate async context through PromiseResolveWithoutHandlerJob A reaction registered with then() captures the async context, but when the settled side has no handler the derived promise is settled by PromiseResolveWithoutHandlerJob, and every path that queues that job dropped the context: triggerPromiseReactions for a JSFullPromiseReaction with an undefined side, the already-settled arms of performPromiseThen and performPromiseThenWithContext, and the PromiseResolveThenableJobFast reaction that makes one promise adopt another. The derived promise then rejected with no async context installed, so the rejection tracker (and Bun's unhandledRejection event) saw none, while the same shape with a handler, or through the non-fast thenable job, kept it. Pass the reaction's context as the job's third argument and install it around the settle, the way PromiseReactionJob does. The thenable fast path installs the captured context before the species check too, so the slow path's performPromiseThen captures the same context. --- Source/JavaScriptCore/runtime/JSMicrotask.cpp | 24 ++++++++++++++++--- Source/JavaScriptCore/runtime/JSPromise.cpp | 19 ++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/Source/JavaScriptCore/runtime/JSMicrotask.cpp b/Source/JavaScriptCore/runtime/JSMicrotask.cpp index ec258ae96389..d803ef145745 100644 --- a/Source/JavaScriptCore/runtime/JSMicrotask.cpp +++ b/Source/JavaScriptCore/runtime/JSMicrotask.cpp @@ -1814,15 +1814,24 @@ void runInternalMicrotask(JSGlobalObject* globalObject, VM& vm, InternalMicrotas auto* promise = uncheckedDowncast(arguments[0]); auto* promiseToResolve = uncheckedDowncast(arguments[1]); - if (!promiseSpeciesWatchpointIsValid(vm, promise)) [[unlikely]] - RELEASE_AND_RETURN(scope, promiseResolveThenableJobFastSlow(globalObject, promise, promiseToResolve)); - #if USE(BUN_JSC_ADDITIONS) + // Install the async context captured by resolvePromise() for both paths below: the slow + // path registers reactions through performPromiseThen, which captures the current context. AsyncContextSwapScope asyncContextScope(vm, globalObject, arguments[2]); #endif + if (!promiseSpeciesWatchpointIsValid(vm, promise)) [[unlikely]] + RELEASE_AND_RETURN(scope, promiseResolveThenableJobFastSlow(globalObject, promise, promiseToResolve)); + scope.release(); +#if USE(BUN_JSC_ADDITIONS) + // promiseToResolve adopts promise's settlement in PromiseResolveWithoutHandlerJob. Capture + // the context (now installed) with the reaction so that it settles in the same context the + // slow path and PromiseResolveThenableJob would settle it in. + promise->performPromiseThenWithInternalMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, promiseToResolve, AsyncContextSwapScope::wrapWithCurrent(vm, globalObject, jsUndefined())); +#else promise->performPromiseThenWithInternalMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, promiseToResolve, jsUndefined()); +#endif return; } @@ -1872,6 +1881,15 @@ void runInternalMicrotask(JSGlobalObject* globalObject, VM& vm, InternalMicrotas } case InternalMicrotask::PromiseResolveWithoutHandlerJob: { +#if USE(BUN_JSC_ADDITIONS) + // arguments[2] is the context the reaction was registered with: an InternalFieldTuple + // [userContext, asyncContext] from performPromiseThen, performPromiseThenWithContext or + // PromiseResolveThenableJobFast, or undefined. Keep the async context installed while the + // derived promise settles, so that a rejection reaches the rejection tracker in the context + // then() was called in, the same as PromiseReactionJob does when there is a handler. + JSValue contextArg = arguments[2]; + AsyncContextSwapScope asyncContextScope(vm, globalObject, AsyncContextSwapScope::unwrapContextTuple(contextArg)); +#endif RELEASE_AND_RETURN(scope, promiseResolveWithoutHandlerJob(globalObject, vm, arguments[0], arguments[1], static_cast(payload))); } diff --git a/Source/JavaScriptCore/runtime/JSPromise.cpp b/Source/JavaScriptCore/runtime/JSPromise.cpp index ff34f32f3d6a..28d51280836b 100644 --- a/Source/JavaScriptCore/runtime/JSPromise.cpp +++ b/Source/JavaScriptCore/runtime/JSPromise.cpp @@ -401,7 +401,13 @@ void JSPromise::performPromiseThen(VM& vm, JSGlobalObject* globalObject, JSValue globalObject->queueMicrotask(vm, InternalMicrotask::PromiseReactionJob, static_cast(Status::Rejected), promiseOrCapability, onRejected, settled); #endif else +#if USE(BUN_JSC_ADDITIONS) + // The derived promise settles in PromiseResolveWithoutHandlerJob; carry the async context + // so that it settles (and is reported to the rejection tracker) in the context then() was called in. + globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Rejected), promiseOrCapability, settled, context); +#else globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Rejected), promiseOrCapability, settled, jsUndefined()); +#endif markAsHandled(); break; } @@ -414,7 +420,11 @@ void JSPromise::performPromiseThen(VM& vm, JSGlobalObject* globalObject, JSValue globalObject->queueMicrotask(vm, InternalMicrotask::PromiseReactionJob, static_cast(Status::Fulfilled), promiseOrCapability, onFulfilled, settled); #endif else +#if USE(BUN_JSC_ADDITIONS) + globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Fulfilled), promiseOrCapability, settled, context); +#else globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Fulfilled), promiseOrCapability, settled, jsUndefined()); +#endif break; } } @@ -453,7 +463,7 @@ void JSPromise::performPromiseThenWithContext(VM& vm, JSGlobalObject* globalObje if (rejectedCallable) globalObject->queueMicrotask(vm, InternalMicrotask::PromiseReactionJob, static_cast(Status::Rejected), promiseOrCapability, onRejected, settled, context); else - globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Rejected), promiseOrCapability, settled, jsUndefined()); + globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Rejected), promiseOrCapability, settled, context); markAsHandled(); break; } @@ -462,7 +472,7 @@ void JSPromise::performPromiseThenWithContext(VM& vm, JSGlobalObject* globalObje if (fulfilledCallable) globalObject->queueMicrotask(vm, InternalMicrotask::PromiseReactionJob, static_cast(Status::Fulfilled), promiseOrCapability, onFulfilled, settled, context); else - globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Fulfilled), promiseOrCapability, settled, jsUndefined()); + globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveWithoutHandlerJob, static_cast(Status::Fulfilled), promiseOrCapability, settled, context); break; } } @@ -912,9 +922,12 @@ void JSPromise::triggerPromiseReactions(VM& vm, JSGlobalObject* globalObject, St // performPromiseThen normalizes non-callable sides to jsUndefined() when storing // an async context in a full reaction; cheap tag check instead of isCallable(). if (handler.isUndefined()) { + // No handler for this settlement: the derived promise just adopts it. Pass the + // reaction's context along so PromiseResolveWithoutHandlerJob settles it in the + // async context then() was called in. task = InternalMicrotask::PromiseResolveWithoutHandlerJob; handler = argument; - arg = jsUndefined(); + arg = fullReaction->context(); break; } JSValue context = fullReaction->context();