Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughUnhandled promise rejection handling now preserves rejection-time async context, clears context during reporting and cleanup, propagates listener exceptions, and adds AsyncLocalStorage regression coverage. The WebKit dependency pin also changes to a preview autobuild. ChangesAsync context preservation for unhandled promise rejections
WebKit preview pin
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 2:20 PM PT - Sep 16th, 2026
❌ @robobun, your commit 81d0d90 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31721That installs a local version of the PR into your bun-31721 --bun |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/async_hooks/AsyncLocalStorage.test.ts`:
- Around line 590-596: The test currently sets proc with stderr: "pipe" but
never consumes it; update the Promise.all consumer to drain stderr as well by
awaiting proc.stderr.text() (e.g., const [stdout, stderr, exitCode] = await
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])); then add an
assertion or include stderr in the failure diagnostics (for example
expect(stderr).toBe("") or include it in the test error messages) so the pipe
cannot fill and test failures surface stderr content; locate and change the code
around the proc usage and the existing stdout/exitCode awaits.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 674e4315-278d-4d16-ad2e-4908415d8e51
📒 Files selected for processing (4)
src/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.htest/js/node/async_hooks/AsyncLocalStorage.test.tstest/js/node/async_hooks/async-context/async-context-unhandled-rejection.js
There was a problem hiding this comment.
Thanks — the contextless-drain edge case and the stderr nit are both addressed; no further issues found. Leaving final sign-off to a maintainer since this touches the core promise-rejection tracker and GC-visible state in ZigGlobalObject.
Extended reasoning...
Overview
This PR makes unhandledRejection handlers observe the AsyncLocalStorage context that was active when the promise was rejected, matching Node.js. It changes m_aboutToBeNotifiedRejectedPromises from WriteBarrierList<JSPromise> to WriteBarrierList<JSCell> so entries can be either a raw JSPromise or an AsyncContextFrame wrapping the promise plus the captured context. promiseRejectionTracker snapshots the context on Reject and unwraps on Handle; handleRejectedPromises() installs the captured context (or jsUndefined() for contextless entries when tracking is enabled) around Bun__handleRejectedPromise, then restores. Two new tests cover the behavior, including a dual-runtime fixture run against both bun and node.
Security risks
None identified. This is a behavioral correctness fix for async-context propagation; no auth, crypto, untrusted input parsing, or privilege boundaries are involved.
Level of scrutiny
Moderate-to-high. ZigGlobalObject.cpp is core runtime: the promise-rejection tracker fires on every unhandled rejection, the pending list is GC-visited, and the drain now does uncheckedDowncast<JSPromise> based on the invariant that only JSPromise or AsyncContextFrame cells are ever appended. The implementation closely mirrors the existing pattern in NodeTimerObject.cpp (same m_asyncContextData save/install/restore dance) and m_asyncContextData.get() is already used unguarded throughout the file, so the new code is consistent with established idioms. The WriteBarrierList<JSCell> is still visited at the existing m_aboutToBeNotifiedRejectedPromises.visit(...) site, and AsyncContextFrame visits its own callback/context barriers, so GC reachability looks correct. Still, changing the cell type of a GC-visited container plus adding an allocation inside promiseRejectionTracker is the kind of thing a Bun/JSC maintainer should eyeball.
Other factors
- My previous inline nit (contextless rejections leaking the ambient drain-time context when
handleRejectedPromises()is re-entered from insideals.run()) was fixed in 8faa3ca by installingjsUndefined()for raw entries when tracking is enabled — verified in the current diff. - CodeRabbit's stderr-drain suggestion was applied in 19f2869. All review threads are resolved.
- The bug-hunting pass on the current revision found nothing.
- The two CI failures reported by robobun (FreeBSD linker warning, musl build-bun) are in
scripts/build/ci.tsinfrastructure and appear unrelated to this change. - Test coverage is solid: a direct subprocess test plus a dual-runtime fixture covering sync rejections in two stores, a no-context rejection, and a timer-deferred rejection.
|
Status: rebased onto main Repro for the bug: the script in #40223 prints Re-run on the rebased build:
Review state: every thread is resolved except @alii's original blocker thread from 07-02. It has the update and is his to close. His changes-requested review is what blocks the merge. CI on The WebKit half (oven-sh/WebKit#268) is reworked on top of oven-sh/WebKit#552 and pushed as Remaining action (maintainer): review and merge this PR. Review oven-sh/WebKit#268 when convenient. |
2475297 to
035b030
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
alii
left a comment
There was a problem hiding this comment.
Thanks — the core design here (snapshot the rejection-time context onto the pending entry, install it around the whole per-mode dispatch) is the right shape, and I verified it matches what Node >= 24 does in lib/internal/process/promises.js (store AsyncContextFrame.current() at rejection time, exchange() around the emit). Requesting changes for one blocker and five smaller items; details with file:line traces in the inline comments.
The blocker: the headline scenario — als.run(id, async () => { await x; throw e }), i.e. an async function failing after an await, which is the most common unhandled rejection there is — still observes getStore() === undefined with this PR, and no test in the PR can notice. The root cause is an ordering inconsistency in our WebKit fork: JSMicrotask.cpp's AsyncFunctionResume error branch restores the async context before promise->reject(), while PromiseReactionJob a page above settles first and restores after (and has a comment saying exactly why). Your snapshot hook fires inside that reject, so it reads the already-popped context. Every rejection in the test matrix is a synchronous Promise.reject or a setTimeout callback — the two paths that already keep the context installed — so the suite passes and the node-parity fixture certifies a case it never distinguishes. Fixing this properly is a one-line ordering change in the WebKit fork (plus the same audit on the sibling async-generator/finally branches) and a pin bump in this PR — details on the inline comment.
Summary of asks:
- (blocker) Fix the
AsyncFunctionResumerestore-vs-reject ordering in the WebKit fork + bump the pin here; addawait-throw / awaited-native-rejection / escaped-async-fn legs to the fixture and confirm they fail first. - Replace the hand-rolled Reject block with the existing
AsyncContextFrame::withAsyncContextIfNeededhelper (drops a redundant flag gate and a dead null check). - Make each of the two restore mechanisms in
handleRejectedPromises()individually load-bearing under test — today either one can be deleted and every test still passes. - Add the one case that pins the semantic this PR chooses (rejection-time vs creation-context — they differ, and Node itself flipped between 22 and >= 24), and note the Node-version dependency: the parity harness runs an unpinned system
node. - Cover the
Handle-path unwrap sites with a frame-wrapped entry (they currently have zero coverage, and reverting either one silently regresses #32554). - Don't run the microtask drain + GC inside the installed-context window (
--unhandled-rejections=warn|strict|throw|noneall do today); add astrict-mode test.
Happy to re-review quickly. The design is right — the blocker is that it doesn't yet handle the case it was built for, and the test matrix was (accidentally) constructed so it can't tell.
| if (auto* asyncContextData = globalObj->m_asyncContextData.get()) { | ||
| JSC::JSValue context = asyncContextData->getInternalField(0); | ||
| if (!context.isUndefined()) | ||
| entry = AsyncContextFrame::create(obj->vm(), globalObj->AsyncContextFrameStructure(), promise, context); |
There was a problem hiding this comment.
Blocker. This snapshot is taken too late for the most important rejection shape: an async function that throws (or awaits a rejection) after its first await.
Trace, at this PR's pinned WebKit (scripts/build/deps/webkit.ts → the vendor/WebKit checkout):
JSMicrotask.cpp,case InternalMicrotask::AsyncFunctionResume, error branch (~1964–1973): it doesasyncContextData->putInternalField(vm, 0, restoreAsyncContext)and thenpromise->reject(vm, error).promise->reject()→rejectPromise()→promiseRejectionTracker(..., Reject)fires synchronously.- So by the time this line reads
m_asyncContextData->getInternalField(0), the async function's[als, ctx]has already been popped back to the outer (usually undefined) value → the entry is stored raw → theunhandledRejectionlistener seesundefined.
Contrast PromiseReactionJob (~1832 in the same file), which settles first and restores after, with the comment "Note: Keep async context active during resolvePromise/rejectPromise …". That's why the PR's two sync fixtures and the timer fixture work — they never go through AsyncFunctionResume. The one path with the inverted order is the one path with no coverage, and it's the canonical one:
als.run(7, async () => { await Bun.sleep(5); throw new Error("late"); });
// with this PR: unhandledRejection sees undefined. Node prints 7.Two asks:
- Add these legs to
async-context-unhandled-rejection.js(they run against Node too, so they double as the parity proof) and confirm they fail on the current PR build before changing anything:als.run({test:"await-throw"}, async () => { await sleep(5); throw new Error("await-throw"); })als.run({test:"await-native-reject"}, async () => { await fetch("http://127.0.0.1:1/"); })const p = als.run(ctx, () => asyncFn())with nobody catchingp
- Fix the root cause in the WebKit fork rather than working around it here: in
AsyncFunctionResume, restore the async context afterpromise->reject()/promise->resolve(), matchingPromiseReactionJob's documented ordering, and bump the pin in this PR. While there, please audit the sibling terminal branches (theExecutingresolve arm,AsyncGeneratorBodyCall*,PromiseFinallyReactionJob) for the same restore-before-settle inversion — it's the same bug class.
Without the WebKit half, this feature returns undefined for its own motivating case while shipping a green node-parity fixture.
There was a problem hiding this comment.
Confirmed and fixed upstream: oven-sh/WebKit#268.
You're right on every point. AsyncFunctionResume restores the slot at JSMicrotask.cpp:1990-1991 and then calls promise->reject() at 1994, while PromiseReactionJob a few cases above settles first and restores after (with the comment explaining why). Reproduced with the full bun-side fix in place and the current pin:
FAIL: unhandledRejection for "await-throw" observed store null, expected "await-throw"
(the sync, no-context and timer legs all pass at that point, which is exactly why the old matrix couldn't see it).
The WebKit PR settles before restoring in both terminal arms. I audited every putInternalField(vm, 0, restoreAsyncContext) site in the file: only those two were inverted. AsyncGeneratorYieldAwaited, AsyncGeneratorBodyCallNormal/Return, AsyncGeneratorAwaitReturnContinuation, PromiseFinallyReactionJob and the await-continuation arm all already restore after their call. It doesn't change what .then()/.catch() handlers observe, since performPromiseThen() captures at registration time; what changes is the tracker and any thenable job the settle schedules.
The three legs are in async-context/async-context-unhandled-rejection-async-fn.js (await-throw, awaited native rejection via fs.promises.readFile of a missing path, escaped async fn). They pass on Node and fail on the current pin, so the file is in the tracking test's todos with a comment pointing at the WebKit PR — I'll drop the todo and bump WEBKIT_VERSION in this PR as soon as #268 merges and an autobuild tag exists. Worth flagging: the preview-build workflow on oven-sh/WebKit currently fails before building (actions/github-script@v7 isn't SHA-pinned and the org requires that), so I can't pin a preview tag in the meantime.
There was a problem hiding this comment.
Pin bumped in 0cde717 — the blocker is closed end-to-end.
oven-sh/WebKit#268's preview build is green across all 43 artifacts, so WEBKIT_VERSION now points at autobuild-preview-pr-268-ee98a203 and the async-fn fixture is out of todos. (The preview workflow had been failing for an unrelated reason — actions/github-script@v7 wasn't SHA-pinned; WebKit main fixed that in #269, so rebasing my branch onto it was enough.) Re-pin to the autobuild tag of the merge commit before this lands — happy to do that the moment #268 merges.
A/B on the same tree, flipping only the pin:
| old pin | preview pin | |
|---|---|---|
await-throw, awaited native rejection, escaped async fn |
FAIL: ... observed store null |
pass (bun and node) |
AsyncLocalStorage-tracking |
75 pass / 2 todo | 76 pass / 1 todo |
I also baselined the only other thing that moved locally: three setTimeout doesn't leak ... RSS-threshold tests fail under debug+ASAN on both pins, so they're pre-existing and unrelated. That matches the code — without an AsyncLocalStorage active, asyncContextData is null and the reorder is inert, so nothing outside the rejection paths can move.
Also smoke-tested the engine change beyond this feature: all 36 test-promise*/test-async-*/test-microtask* parallel tests, event-emitter, timers.promises and the async_hooks suites are green.
There was a problem hiding this comment.
Update: the AsyncFunctionResume half landed independently as oven-sh/WebKit#295, so #268 was rebased onto it and now carries only the PromiseFinallyAwaitJob fix. bun's pin is bumped to the new preview (86efe056, on top of #295). All 7 async-fn fixture cases pass on bun and node. Still open: re-pin to #268's merge-commit tag once it lands.
There was a problem hiding this comment.
Update: oven-sh/WebKit#268 now also carries the PromiseResolveWithoutHandlerJob half (found through #39847, details in #31721 (comment)) and sits on current WebKit main. The pin is its preview, autobuild-preview-pr-268-33161987. Still open for the same reason: re-pin to the merge commit autobuild once #268 lands.
There was a problem hiding this comment.
Update: the AsyncFunctionResume half of this blocker is in main's pin (oven-sh/WebKit#295) and the await-throw, awaited-native-rejection and escaped-async-fn legs pass on it, so the case this thread was opened for is covered on main's own WebKit. The preview pin is gone as of 6d1f6fc (see #31721 (comment)); the two remaining shapes that need oven-sh/WebKit#268 (finally-thenable, then-passthrough) are todo in the tracking test until #268 lands. Leaving this to you to resolve.
There was a problem hiding this comment.
Update (e3f715e): the two shapes that still need oven-sh/WebKit#268 are no longer bare todos. The tracking test runs them on both runtimes and asserts that node passes and bun fails, so the WebKit bump that carries #268 turns them red and the gate moves them to the plain list. Nothing else in this thread changed.
|
@robobun please address the review feedback above (#31721 (review)). Suggested order: start with the blocker's WebKit half — fix the restore-vs-settle ordering in |
b92fd1b to
2c91bb5
Compare
|
Thanks — this was a genuinely good catch, and the test matrix was constructed so it couldn't see the blocker. All six items are addressed; details in the inline replies. The blockerConfirmed exactly as you described, and fixed upstream in oven-sh/WebKit#268. I audited every Fail-before, with the complete bun-side fix and the current pin: The The rest
Every clause was mutation-tested: reverting either unwrap, deleting the restore-after-dispatch, dropping the replay-undefined branch, or removing the drain guard each breaks at least one test. The one thing I couldn't cover is the termination-path restore — it only runs while the VM is being torn down, so there's no point at which JS could observe the slot; that's stated in the description rather than tested. One correction to your reproduction note for the replay-undefined branch: under No test timeouts were raised. |
|
Pin bumped in 0cde717 — the blocker is now fixed end-to-end in CI, not just argued for. oven-sh/WebKit#268's preview build went green across all 43 artifacts, so A/B on the same tree, flipping only the pin:
I baselined the only other thing that moved locally, too: three One thing left, and it needs a human: the pin points at a preview tag, so oven-sh/WebKit#268 has to merge and then this wants a re-pin to its merge commit's autobuild tag. I've left that review thread open as the reminder and I'm happy to push the re-pin the moment it lands. |
0cde717 to
3c4e8a0
Compare
pushback resultsRan 1. A throwing
|
6553c8c to
0973a36
Compare
2481dc8 to
d28503d
Compare
d28503d to
70602eb
Compare
4ad980b to
175d78b
Compare
|
Rebased onto main ( Re-run on the new preview: the two ALS suites, |
175d78b to
6d1f6fc
Compare
|
@alii I changed course on the pin, because the preview approach could not stay mergeable: the preview I built for main's pin
The history is one commit again (the pin commits netted to zero). Once #268 lands and main's pin picks it up, a follow-up drops the two todos. If you would rather have the preview pin back, say so and I re-pin, but it needs #268 merged to stop moving. |
ffea832 to
cdcd0e8
Compare
cdcd0e8 to
740aacb
Compare
740aacb to
e3f715e
Compare
e3f715e to
51528db
Compare
51528db to
7dda549
Compare
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.
7dda549 to
81d0d90
Compare
A second route to this bug, and the conflict with #42590
import { AsyncLocalStorage } from "node:async_hooks";
const mode = process.argv[2] ?? "none";
if (mode === "graph") new Bun.ModuleGraph({}).dispose();
if (mode === "port") {
const { port1, port2 } = new MessageChannel();
port2.onmessage = () => { port1.close(); port2.close(); };
port1.postMessage(1);
}
const als = new AsyncLocalStorage();
als.enterWith("top");
process.on("unhandledRejection", () => console.log(mode, als.getStore()));
Promise.reject(new Error("x"));
The conflictBoth PRs changed the queue of rejected promises. #42590 replaced it with The two fit togetherI checked this with a small patch on
The patch is only the check. It has none of this PR's handling of throwing listeners, of the Patch used for the checkdiff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp
index e99812a337..a780273db2 100644
--- a/src/jsc/bindings/ZigGlobalObject.cpp
+++ b/src/jsc/bindings/ZigGlobalObject.cpp
@@ -142,6 +142,7 @@
#include "sqlite/NodeSqlite.h"
#include "JSStringDecoder.h"
#include "ModuleGraph.h"
+#include <JavaScriptCore/AsyncContextSwapScope.h>
#include "JSTextEncoder.h"
#include "streams/JSTextEncoderStream.h"
#include "streams/JSTextDecoderStream.h"
@@ -1170,7 +1171,7 @@ void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise*
case JSPromiseRejectionOperation::Reject:
// Whose rejection this is (a Bun.ModuleGraph's or the global object's) is
// decided now, in the context it is rejected in, and travels with it.
- globalObj->m_aboutToBeNotifiedRejectedPromises.append(obj->vm(), globalObj, promise, Bun::moduleGraphRejecting(globalObj));
+ globalObj->m_aboutToBeNotifiedRejectedPromises.append(obj->vm(), globalObj, promise, Bun::moduleGraphRejecting(globalObj), globalObj->m_asyncContextData.get()->getInternalField(0));
break;
case JSPromiseRejectionOperation::Handle:
bool removed = globalObj->m_aboutToBeNotifiedRejectedPromises.remove(globalObj, promise);
@@ -3340,12 +3341,13 @@ RefPtr<Performance> GlobalObject::performance()
extern "C" void Bun__handleRejectedPromise(Zig::GlobalObject* JSGlobalObject, JSC::JSPromise* promise, JSC::EncodedJSValue rejectionOwner);
-void GlobalObject::RejectedPromiseQueue::append(JSC::VM& vm, JSC::JSCell* owner, JSC::JSPromise* promise, JSC::JSObject* rejectionOwner)
+void GlobalObject::RejectedPromiseQueue::append(JSC::VM& vm, JSC::JSCell* owner, JSC::JSPromise* promise, JSC::JSObject* rejectionOwner, JSC::JSValue asyncContext)
{
WTF::Locker locker { owner->cellLock() };
m_entries.append({});
m_entries.last().promise.set(vm, owner, promise);
m_entries.last().rejectionOwner.set(vm, owner, rejectionOwner ? JSValue(rejectionOwner) : jsNull());
+ m_entries.last().asyncContext.set(vm, owner, asyncContext);
}
bool GlobalObject::RejectedPromiseQueue::remove(JSC::JSCell* owner, JSC::JSPromise* promise)
@@ -3354,15 +3356,17 @@ bool GlobalObject::RejectedPromiseQueue::remove(JSC::JSCell* owner, JSC::JSPromi
return m_entries.removeFirstMatching([&](Entry& entry) { return entry.promise.get() == promise; });
}
-void GlobalObject::RejectedPromiseQueue::drainTo(JSC::JSCell* owner, JSC::MarkedArgumentBuffer& promises, JSC::MarkedArgumentBuffer& rejectionOwners)
+void GlobalObject::RejectedPromiseQueue::drainTo(JSC::JSCell* owner, JSC::MarkedArgumentBuffer& promises, JSC::MarkedArgumentBuffer& rejectionOwners, JSC::MarkedArgumentBuffer& asyncContexts)
{
WTF::Locker locker { owner->cellLock() };
promises.ensureCapacity(promises.size() + m_entries.size());
rejectionOwners.ensureCapacity(rejectionOwners.size() + m_entries.size());
+ asyncContexts.ensureCapacity(asyncContexts.size() + m_entries.size());
for (Entry& entry : m_entries) {
if (entry.promise.get().isCell()) {
promises.append(entry.promise.get());
rejectionOwners.append(entry.rejectionOwner.get());
+ asyncContexts.append(entry.asyncContext.get());
}
}
m_entries.clear();
@@ -3375,6 +3379,7 @@ void GlobalObject::RejectedPromiseQueue::visit(JSC::JSCell* owner, Visitor& visi
for (auto& entry : m_entries) {
visitor.append(entry.promise);
visitor.append(entry.rejectionOwner);
+ visitor.append(entry.asyncContext);
}
}
@@ -3391,8 +3396,9 @@ void GlobalObject::handleRejectedPromises()
// RejectedPromiseTracker use.
JSC::MarkedArgumentBuffer promises;
JSC::MarkedArgumentBuffer rejectionOwners;
- m_aboutToBeNotifiedRejectedPromises.drainTo(this, promises, rejectionOwners);
- RELEASE_ASSERT(!promises.hasOverflowed() && !rejectionOwners.hasOverflowed());
+ JSC::MarkedArgumentBuffer asyncContexts;
+ m_aboutToBeNotifiedRejectedPromises.drainTo(this, promises, rejectionOwners, asyncContexts);
+ RELEASE_ASSERT(!promises.hasOverflowed() && !rejectionOwners.hasOverflowed() && !asyncContexts.hasOverflowed());
// Expose the not-yet-processed tail so promiseRejectionTracker(Handle)
// can tell "still pending" apart from "already notified". Linked as a
// stack so a re-entrant handleRejectedPromises() (a handler that ticks
@@ -3405,6 +3411,7 @@ void GlobalObject::handleRejectedPromises()
continue;
inflight.index = i + 1;
+ JSC::AsyncContextSwapScope rejectionContext(virtual_machine, this, asyncContexts.at(i));
Bun__handleRejectedPromise(this, promise, JSValue::encode(rejectionOwners.at(i)));
if (auto ex = scope.exception()) {
if (virtual_machine.isTerminationException(ex)) [[unlikely]]
diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h
index 4c6c360f0d..1249a9e8f4 100644
--- a/src/jsc/bindings/ZigGlobalObject.h
+++ b/src/jsc/bindings/ZigGlobalObject.h
@@ -836,10 +836,10 @@ public:
// Guarded by cellLock() (visited on the GC thread).
class RejectedPromiseQueue {
public:
- void append(JSC::VM&, JSC::JSCell* owner, JSC::JSPromise*, JSC::JSObject* rejectionOwner);
+ void append(JSC::VM&, JSC::JSCell* owner, JSC::JSPromise*, JSC::JSObject* rejectionOwner, JSC::JSValue asyncContext);
bool remove(JSC::JSCell* owner, JSC::JSPromise*);
// Move every entry out (index-aligned; jsNull() owner for the global object's) and clear.
- void drainTo(JSC::JSCell* owner, JSC::MarkedArgumentBuffer& promises, JSC::MarkedArgumentBuffer& rejectionOwners);
+ void drainTo(JSC::JSCell* owner, JSC::MarkedArgumentBuffer& promises, JSC::MarkedArgumentBuffer& rejectionOwners, JSC::MarkedArgumentBuffer& asyncContexts);
template<typename Visitor> void visit(JSC::JSCell* owner, Visitor&);
bool isEmpty() const { return m_entries.isEmpty(); }
@@ -847,6 +847,7 @@ public:
struct Entry {
JSC::WriteBarrier<JSC::Unknown> promise; // JSPromise
JSC::WriteBarrier<JSC::Unknown> rejectionOwner; // JSModuleGraph or null
+ JSC::WriteBarrier<JSC::Unknown> asyncContext;
};
WTF::Vector<Entry> m_entries;
}; |
Fixes #40223. Related: #39847 (see Notes).
Problem
unhandledRejectionlistener observesAsyncLocalStorage.getStore() === undefined. Node gives it the store the promise was rejected in.Zig::GlobalObject::promiseRejectionTracker(src/jsc/bindings/ZigGlobalObject.cpp) recorded only the promise. The event fires at the end of the tick, after the rejection-time context is gone.Fix
AsyncContextFrame).VirtualMachine::unhandled_rejection_in_contextinstalls it for the dispatch and puts the previous value back after. A promise rejected with no context installs none. Drains inside the dispatch run with the slot cleared.Bun__handleUnhandledRejectionand halts later listeners. The dispatch reports it after the restore, as Node does, then drains what the listener queued. A listener that stops its worker is re-armed as a termination.AsyncLocalStoragestarts with tracking off, so nothing restored a top-levelenterWith()it made, and the next dispatch took that frame as the previous context.async_hookssuites, node'stest-async-local-storage-errors.js,process.test.js, the nodetest-promise*tests, thebun:testsuites. Self-reviewed twice: 17 concerns, 16 addressed, 1 deferred (Notes).Background
m_asyncContextData, field 0).AsyncLocalStorage.runsets and restores it. A callback or reaction registered while it is set runs with that value.enterWith()frame ends with the callback that made it, as in Node.--unhandled-rejectionsmode but the default drains microtasks inside the dispatch. Node'sprocessPromiseRejectionsexchanges the frame around the dispatch and restores it infinally.Notes
Which semantic this pins: the context the promise was rejected in, not the one it was created in. That matches Node >= 24 and Node 22 with
--experimental-async-context-frame, which storeAsyncContextFrame.current()at rejection time andexchange()around the emit (lib/internal/process/promises.js). Node 22's defaultasync_hooks-basedAsyncLocalStoragereplays the creation context instead, so the dual-runtime fixtures only cover cases where the two agree;AsyncLocalStorage.test.tspins the distinguishing case against Bun alone.Scope. The context is preserved for rejections raised by JS, and by JSC microtasks once
WEBKIT_VERSIONincludes oven-sh/WebKit#268. A promise that Bun's native layer creates and rejects from an event-loop task (fetch,Bun.file().text(),fs.promises,Bun.dns,Bun.connect, the SQL and S3 clients) still reportsundefined: unlike Node'sInternalCallbackScope, Bun installs no context around native settlement.async-context-unhandled-rejection-native.jspins that gap in the tracking test as "node passes, bun fails", so it flips when native settlement starts installing the resource's context.Rebased onto #41190 / #41359. Those replaced
enterWith()'s one-shot cleanup (cleanupLater/cleanupAsyncHooksData) with the checkpoint reset and oven-sh/WebKit#552's per-reaction frames, so the earlier revision's clear counter (asyncContextClearCount, which kept a restore from resurrecting a store that cleanup had cleared inside the bracket) is gone: nothing clears the slot inside a dispatch any more, and the restore is a plain swap. Thedoes not resurrect a stale enterWith() storecases stay as coverage of the enterWith-then-reject shape in four modes. One addition in that area:GlobalObject::drainMicrotasks()clears the slot after the drain too (same!vm.entryScopeguard as the reset before it). The job that constructs the firstAsyncLocalStoragestarts with tracking off, so itsAsyncContextSwapScoperestores nothing and a top-levelenterWith()in the entry module outlived the checkpoint; the rejection dispatch that follows the checkpoint then saw it as the previous context (uncaught store="Y"where node reads none). Conflicts in the rebase were that removed cleanup code and main's new test blocks appended at the same point ofAsyncLocalStorage.test.ts(both kept).The throwing-listener rule. Only
unhandledRejectionswitches to halt-and-propagate (Bun__handleUnhandledRejectionrethrows the listener's exception, later listeners do not run); that is what Node'semitdoes, and the dispatch needs the exception back so it can report it after the async context is restored. The other native process emits (warning,exit,beforeExit,uncaughtException, ...) and JSprocess.emit()keep the pre-existing report-and-continue path; theemit(..., NakedPtr<Exception>&)overload is additive and is the primitive a later uniform change can use. After a handled throw the dispatch drains microtasks (the mode's own drain was skipped by the early return), so what the listener and theuncaughtExceptionhandler queued still runs; an unhandled throw ends the turn and the process exits 1, as on Node. A listener that stops its worker (process.exit(),terminate()) hands back a termination, which is re-armed rather than rethrown (rethrowing it trippedVM::setException's assertion in debug builds).Second self-review round (on the first rework). Found and fixed: the skipped drain above, the termination rethrow, and
AsyncContextFrame__exchangeAsyncContexttakingZig::GlobalObject*while the test runner's error path can hand it anode:vmcontext's global (a sibling class that shares the slot); both bindings now resolve the lexical global to the thread'sZig::GlobalObjectlikecleanupAsyncHooksData. Added coverage: the throwing listener in all six modes with the post-throw drain, the no-handler exit path (process.test.js), a worker dispatch and a workerprocess.exit()from the listener, thevmcontext listener underbun test, the equal-count branch of the restore (the re-entrant drain gives the caller its context back; the throw-mode printer runs after the drain), and#[must_use]onAsyncContextScope.The remaining JSC half: oven-sh/WebKit#268. Three JSC microtask paths settled a promise without the async context of the call that set them up, so there was nothing for the tracker to snapshot:
AsyncFunctionResumerestored the context beforepromise->reject(), which runs the tracker synchronously. Fixed in Keep async context active while settling the async function promise WebKit#295, which main's pin has.PromiseFinallyAwaitJob(phase 2 of.finally()) neither captured the context when scheduled nor installed it when run. Use Node.js v18.x from NodeSource to use string.replaceAll method #268, first commit.PromiseResolveWithoutHandlerJob, the job that settles a derived promise when the settled side has no handler (p.then(f)withprejecting,resolve(otherPromise),async () => otherPromise). Every site that queued it passedundefinedfor the context. Use Node.js v18.x from NodeSource to use string.replaceAll method #268, second commit. This is the shape behind Next.js 16cacheComponents+partialPrefetching: every request leaks unhandledRejection (NEXT_PRERENDER_INTERRUPTED,Date.now()bailout) underbun server.js— Node leaks 0 #39847: the rejections Next.js 16 filters by reading its prerender store from theunhandledRejectionlistener are these derived promises.This PR does not change
WEBKIT_VERSION. The two fixtures that need #268 (finally-thenable,then-passthrough) run in the tracking test as "node passes, bun fails" and go red on the WebKit bump that carries #268. Measured with a preview of #268 (autobuild-preview-pr-268-b1fbd2f1): both pass, and #39847's Next.js 16.3.1 app logs 0 rejections over 3 requests (6 without it; node logs 0).Tests. Dual-runtime fixtures under
async-context/:unhandled-rejection.js(sync rejections in two stores, a contextless one, a timer-deferred one, a context-free poll after the drain),unhandled-rejection-async-fn.js(await-then-throw, awaited native rejection, escaped async function, async generators, throwing.finally()), plus the three gated ones above.AsyncLocalStorage.test.ts: rejection-time vs creation-time, same-tick.catch(), contextless rejection drained re-entrantly inside a context,--unhandled-rejections=strict|throwforuncaughtException, the throwing listener, theenterWith()resurrection case in four modes (also asserting thewarninglistener's store in warn mode), the printer in default and throw mode (a lazystackgetter reads the store), the throwing listener's post-throw drain in six modes, a worker dispatch and a workerprocess.exit()from the listener, and threebun testleak checks (an in-context rejection, a timer throw, avmcontext listener).test/js/node/test/parallel/test-async-local-storage-errors.jsis node's regression test for this feature, with// Flags: --unhandled-rejections=throwbecause Bun's default mode never routes a rejection with no listener touncaughtException.process.test.js's #32554 test is parametrized withAsyncLocalStorageso every rejection is frame-wrapped.Earlier revisions pinned a preview of #268 (
WEBKIT_VERSION); main bumped its pin eight times in five days, so the pin moved back to main's. Rebased onto main repeatedly during review; the first rebase squashed the review history into one commit.no test proof · iteration 47 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process.test.js, test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts