Skip to content

Preserve AsyncLocalStorage context in unhandledRejection handlers - #31721

Open
robobun wants to merge 1 commit into
mainfrom
farm/71122839/als-unhandled-rejection
Open

robobun wants to merge 1 commit into
mainfrom
farm/71122839/als-unhandled-rejection

Conversation

@robobun

@robobun robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #40223. Related: #39847 (see Notes).

Problem

  • An unhandledRejection listener observes AsyncLocalStorage.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

  • The tracker stores the promise with its rejection-time context (an AsyncContextFrame). VirtualMachine::unhandled_rejection_in_context installs 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.
  • A throwing listener propagates out of Bun__handleUnhandledRejection and 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.
  • The microtask checkpoint also clears the slot when it ends with no script on the stack. The job that constructs the first AsyncLocalStorage starts with tracking off, so nothing restored a top-level enterWith() it made, and the next dispatch took that frame as the previous context.
  • Verified: the async_hooks suites, node's test-async-local-storage-errors.js, process.test.js, the node test-promise* tests, the bun:test suites. Self-reviewed twice: 17 concerns, 16 addressed, 1 deferred (Notes).

Background

  • The async context is one slot on the global (m_asyncContextData, field 0). AsyncLocalStorage.run sets and restores it. A callback or reaction registered while it is set runs with that value.
  • Since AsyncLocalStorage: drop enterWith() frames at the event-loop boundary, stop allocating per await/then #41190 the microtask checkpoint clears the slot when no script is on the stack: an enterWith() frame ends with the callback that made it, as in Node.
  • Every --unhandled-rejections mode but the default drains microtasks inside the dispatch. Node's processPromiseRejections exchanges the frame around the dispatch and restores it in finally.
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 store AsyncContextFrame.current() at rejection time and exchange() around the emit (lib/internal/process/promises.js). Node 22's default async_hooks-based AsyncLocalStorage replays the creation context instead, so the dual-runtime fixtures only cover cases where the two agree; AsyncLocalStorage.test.ts pins the distinguishing case against Bun alone.

Scope. The context is preserved for rejections raised by JS, and by JSC microtasks once WEBKIT_VERSION includes 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 reports undefined: unlike Node's InternalCallbackScope, Bun installs no context around native settlement. async-context-unhandled-rejection-native.js pins 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. The does not resurrect a stale enterWith() store cases 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.entryScope guard as the reset before it). The job that constructs the first AsyncLocalStorage starts with tracking off, so its AsyncContextSwapScope restores nothing and a top-level enterWith() 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 of AsyncLocalStorage.test.ts (both kept).

The throwing-listener rule. Only unhandledRejection switches to halt-and-propagate (Bun__handleUnhandledRejection rethrows the listener's exception, later listeners do not run); that is what Node's emit does, 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 JS process.emit() keep the pre-existing report-and-continue path; the emit(..., 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 the uncaughtException handler 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 tripped VM::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__exchangeAsyncContext taking Zig::GlobalObject* while the test runner's error path can hand it a node:vm context's global (a sibling class that shares the slot); both bindings now resolve the lexical global to the thread's Zig::GlobalObject like cleanupAsyncHooksData. 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 worker process.exit() from the listener, the vm context listener under bun 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] on AsyncContextScope.

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:

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|throw for uncaughtException, the throwing listener, the enterWith() resurrection case in four modes (also asserting the warning listener's store in warn mode), the printer in default and throw mode (a lazy stack getter reads the store), the throwing listener's post-throw drain in six modes, a worker dispatch and a worker process.exit() from the listener, and three bun test leak checks (an in-context rejection, a timer throw, a vm context listener). test/js/node/test/parallel/test-async-local-storage-errors.js is node's regression test for this feature, with // Flags: --unhandled-rejections=throw because Bun's default mode never routes a rejection with no listener to uncaughtException. process.test.js's #32554 test is parametrized with AsyncLocalStorage so 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

@coderabbitai

coderabbitai Bot commented Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Unhandled 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.

Changes

Async context preservation for unhandled promise rejections

Layer / File(s) Summary
Rejection queue and context restoration
src/jsc/bindings/ZigGlobalObject.*
The rejection queue accepts promises or AsyncContextFrame wrappers. Matching extracts the promise from either entry type. Dispatch restores the captured context or uses undefined when no context was captured.
Async context exchange and runtime scopes
src/jsc/JSGlobalObject.rs, src/jsc/bindings/AsyncContextFrame.cpp, src/jsc/VirtualMachine.rs, src/jsc/virtual_machine_exports.rs, src/jsc/lib.rs
Runtime code exchanges async contexts and clears them around rejection callbacks, microtask drains, garbage collection, and cleanup.
Unhandled-rejection listener exception flow
src/jsc/bindings/webcore/EventEmitter.*, src/jsc/bindings/BunProcess.cpp
Event listeners can return exceptions to unhandled-rejection handling. Reporting clears async context and restores the previous context afterward.
AsyncLocalStorage regression coverage
test/js/node/async_hooks/AsyncLocalStorage.test.ts, test/js/node/async_hooks/async-context/*, test/js/node/process/process.test.js
Tests cover rejection-time context, contextless rejection, event ordering, strict mode, listener exceptions, context leakage, async functions, thenables, and promise propagation.

WebKit preview pin

Layer / File(s) Summary
WebKit dependency pin
scripts/build/deps/webkit.ts
The WebKit version pin now targets the oven-sh/WebKit#268 preview autobuild.

Suggested reviewers: cirospaciari, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving AsyncLocalStorage context in unhandledRejection handlers.
Description check ✅ Passed The description explains the problem, implementation, scope, dependencies, and verification. It does not use the template headings verbatim, but it provides the required information, including detaile…

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:20 PM PT - Sep 16th, 2026

❌ @robobun, your commit 81d0d90 has 1 failures in Build #116730 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31721

That installs a local version of the PR into your bun-31721 executable, so you can run:

bun-31721 --bun

@github-actions github-actions Bot added the claude label Jun 2, 2026
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f58d146 and 8faa3ca.

📒 Files selected for processing (4)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/js/node/async_hooks/AsyncLocalStorage.test.ts
  • test/js/node/async_hooks/async-context/async-context-unhandled-rejection.js

Comment thread test/js/node/async_hooks/AsyncLocalStorage.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 inside als.run()) was fixed in 8faa3ca by installing jsUndefined() 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.ts infrastructure 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.

@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebased onto main b8eacea (head 81d0d90, the same single commit, no conflicts). The diff is ready. It waits for a maintainer review.

Repro for the bug: the script in #40223 prints NO STORE three times on canary c6b7fcb5b (1.4.3). Node v26.3.0 prints store three times. On this branch the output matches Node (3 of 3 runs, debug ASAN build of 81d0d90).

Re-run on the rebased build:

  • AsyncLocalStorage-tracking.test.ts: 79 pass, 1 todo. The three gated fixtures still assert "node passes, bun fails".
  • AsyncLocalStorage.test.ts: 97 pass, 1 todo, 1 local timeout. The timeout is main's re-entering a storage inside run() does not grow the context (a 100,000-iteration loop, 18 s under debug ASAN in this container). It does not reach the code this PR changes, and it did not fail in build 112550.
  • process.test.js: 178 pass.
  • EventEmitterAsyncResource, async-local-storage-thenable, async_hooks.node, event-emitter: 104 pass.
  • The 30 node scripts test-async-local-storage-*, test-promise*, test-promises-*, test-microtask-queue-*: all exit 0.

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 81d0d90 (build 116730): 180 of 182 jobs passed, and the debian x64-asan lane is green. The one red test is test/bake/deinitialization.test.ts on Windows 2019 x64, a segfault in the dev server teardown test. This diff does not touch it. The same test fails and passes on retry in unrelated builds of the same day (116736, 116686, 116716), and it is reported to main-break triage.

The WebKit half (oven-sh/WebKit#268) is reworked on top of oven-sh/WebKit#552 and pushed as 2cb7572a. Its CI is green and its preview build is published. This PR does not depend on it. The Next.js case in #39847 does. I linked this branch plus #43014 against the preview's libraries: the two gated fixtures (then-passthrough, finally-thenable) then pass on bun, so their "node passes, bun fails" assertions turn red on the WebKit bump that carries #268. They move to the plain list then. #43014 (async stack of a native rejection through finally()) has to land before that bump.

Remaining action (maintainer): review and merge this PR. Review oven-sh/WebKit#268 when convenient.

@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 2475297 to 035b030 Compare June 29, 2026 16:41
@mintlify

mintlify Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 4:49 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. (blocker) Fix the AsyncFunctionResume restore-vs-reject ordering in the WebKit fork + bump the pin here; add await-throw / awaited-native-rejection / escaped-async-fn legs to the fixture and confirm they fail first.
  2. Replace the hand-rolled Reject block with the existing AsyncContextFrame::withAsyncContextIfNeeded helper (drops a redundant flag gate and a dead null check).
  3. 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.
  4. 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.
  5. Cover the Handle-path unwrap sites with a frame-wrapped entry (they currently have zero coverage, and reverting either one silently regresses #32554).
  6. Don't run the microtask drain + GC inside the installed-context window (--unhandled-rejections=warn|strict|throw|none all do today); add a strict-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.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 does asyncContextData->putInternalField(vm, 0, restoreAsyncContext) and then promise->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 → the unhandledRejection listener sees undefined.

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:

  1. 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 catching p
  2. Fix the root cause in the WebKit fork rather than working around it here: in AsyncFunctionResume, restore the async context after promise->reject() / promise->resolve(), matching PromiseReactionJob's documented ordering, and bump the pin in this PR. While there, please audit the sibling terminal branches (the Executing resolve 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Landed in c626ed7 — pin is autobuild-preview-pr-268-48232e38 (on top of WebKit 4895f45d, so #34009's allocator change is kept). Leaving this open: re-pin to the autobuild tag of the merge commit once #268 lands on WebKit main.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
@alii

alii commented Jul 2, 2026

Copy link
Copy Markdown
Member

@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 AsyncFunctionResume (and audit its sibling terminal branches) in the WebKit fork, bump the pin in this PR, and add the await-throw / awaited-native-rejection fixture legs, confirming they fail before the WebKit fix. Then the withAsyncContextIfNeeded swap, then the test-hardening items (each restore clause individually load-bearing, the frame-wrapped Handle-path coverage via parametrizing the existing #32554 test, the strict-mode leg), and the drain-outside-the-window fix in VirtualMachine::unhandled_rejection. Please don't raise any test timeouts, and note in the PR description which semantic you're pinning (rejection-time, i.e. Node >= 24) and why.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

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 blocker

Confirmed exactly as you described, and fixed upstream in oven-sh/WebKit#268. AsyncFunctionResume restores the context slot at JSMicrotask.cpp:1990-1991 and then calls promise->reject() at 1994; reject() runs the tracker synchronously, so the snapshot reads an already-popped slot. PromiseReactionJob settles first and restores after, with a comment saying why. The WebKit PR makes both of AsyncFunctionResume's terminal arms match it.

I audited every putInternalField(vm, 0, restoreAsyncContext) site in JSMicrotask.cpp: only those two were inverted — the AsyncGenerator* branches, PromiseFinallyReactionJob and the await-continuation arm all restore after their call already. The reorder doesn't move what .then()/.catch() handlers see, because performPromiseThen() captures at registration time; what moves is the tracker and any thenable job the settle schedules, which is the property PromiseReactionJob already guarantees.

Fail-before, with the complete bun-side fix and the current pin:

FAIL: unhandledRejection for "await-throw" observed store null, expected "await-throw"

The await-throw / awaited-native-rejection / escaped-async-fn legs live in async-context/async-context-unhandled-rejection-async-fn.js. They pass on Node today and fail on bun until the pin moves, so the file sits 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 the moment #268 merges and an autobuild tag exists — note the preview-build workflow on oven-sh/WebKit currently dies before building (actions/github-script@v7 isn't SHA-pinned and the org requires pinned actions), so I can't pin a preview tag in the meantime.

The rest

  • withAsyncContextIfNeeded — swapped in; the tracking-enabled gate and the dead null check are gone.
  • Drain outside the window — unhandled_rejection's drains and the auto-GC that follows the dispatch now run under a ClearedAsyncContextScope (RAII exchange). The install still brackets the whole per-mode dispatch, and that's pinned by a test so it can't be narrowed later.
  • Each restore clause individually load-bearing — my first attempt at your setTimeout probe was accidentally order-dependent (a contextless entry draining last scrubs the slot), so the fixture now ends with an in-context rejection queued by the poll itself, and the poll checks the slot every iteration from outside any context.
  • Frame-wrapped Handle path — the jsc: drain rejected-promise list in O(n) instead of O(n^2) #32554 test is parametrized with AsyncLocalStorage; reverting the in-flight unwrap fails only that variant, with spurious rejectionHandled fired 1x. The weak timer-duplicate test is replaced by a same-tick-.catch() test that covers the other unwrap.
  • Semantic — the rejection-time context wins over the creation-time context pins created-in-A/rejected-from-B → B, bun-only, with the Node-version dependency written down in the test and in the PR description.

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 bun test an unhandled rejection never reaches process.on("unhandledRejection") (the isBunTest branch routes to the runner's reporting), so expect(fn).toThrow() inside a test can't observe it. It is observable in a plain script that imports expect from bun:test, which is what the new test does — on main it prints the caller's store leaking into a contextless rejection's handler.

No test timeouts were raised.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

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 WEBKIT_VERSION points at autobuild-preview-pr-268-ee98a203 and async-context-unhandled-rejection-async-fn.js is out of the tracking test's todos. (The preview workflow had been dying before it built anything — actions/github-script@v7 wasn't SHA-pinned, which the org forbids; WebKit main fixed that in #269, so rebasing onto it was all it took.)

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, on bun and node
AsyncLocalStorage-tracking 75 pass / 2 todo 76 pass / 1 todo

I baselined the only other thing that moved locally, too: three setTimeout doesn't leak ... RSS-threshold tests fail under debug+ASAN on both pins, so they're pre-existing and unrelated. That lines up with the code — with no AsyncLocalStorage active, asyncContextData is null and the reorder is inert, so nothing outside the rejection paths can move. Beyond this feature I also ran all 36 test-promise* / test-async-* / test-microtask* parallel tests, event-emitter, timers.promises and the async_hooks suites: green.

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.

Comment thread test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts Outdated
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

pushback results

Ran farm:rdr/pushback (76 agents, 4 rounds). Three should-fix concerns survived both refutation rounds. I verified each empirically before touching anything, and one caught a real hole in my WebKit audit.

1. A throwing unhandledRejection listener leaked the promise's store into uncaughtException — fixed in a56f796

bun (before):  unhandledRejection store: 7  /  uncaughtException store: 7
node v26.3.0:  unhandledRejection store: 7  /  uncaughtException store: null

Node's processPromiseRejections restores the frame in a finally before the throw propagates. Bun's EventEmitter catches listener throws inside emit and calls Bun__reportUnhandledError there — inside my installed window. Fixed by adding an emit overload that returns the exception instead of reporting it; Bun__handleUnhandledRejection now reports the throw itself after clearing the slot. handleRejectedPromises also restores before reportUncaughtExceptionAtEventLoop for anything that escapes the whole dispatch. Dual-runtime test added; mutation-tested (reverting to the catching emit fails the bun variant, node passes).

2. The isBunTest early-return ran the test runner's handler with the context installed, and nothing tested that path — fixed in a56f796

The next-test-callback leak the concern described doesn't actually reproduce (handleRejectedPromises' restore cleans it before the next test runs), but nothing guarded the path and nothing tested isBunTest=true. Wrapped the handler call in ClearedAsyncContextScope and added a spawned-bun test fixture so the path is exercised.

3. .finally(() => Promise.reject(e)) lost the store — my audit missed PromiseFinallyAwaitJob — fixed upstream in oven-sh/WebKit@0aef04ea

bun (preview pin 48232e38):  finally-throw=ok asyncgen=ok  finally-returns-rejected=null
node:                        finally-throw=ok asyncgen=ok  finally-returns-rejected=ok

I'd claimed the PromiseFinally arms were "already correct". PromiseFinallyReactionJob (phase 1 — the callback runs) is; PromiseFinallyAwaitJob (phase 2 — the callback's returned thenable settles) isn't. It neither captures the context at schedule time nor installs it in its microtask case. Fixed in oven-sh/WebKit#268 the same way the neighbouring case does; preview rebuilding now. Added four fixture cases (both finally shapes, two async-generator shapes) — all pass on node; the file is back in todos until the pin picks up 0aef04ea.

A fourth "consider"-severity concern was truncated in the result.

What's still open

Comment thread test/js/node/async_hooks/AsyncLocalStorage.test.ts Outdated
Comment thread src/jsc/bindings/webcore/EventEmitter.cpp
Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread test/js/node/async_hooks/AsyncLocalStorage.test.ts Outdated
@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 2481dc8 to d28503d Compare July 18, 2026 06:00
@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from d28503d to 70602eb Compare July 18, 2026 11:03

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 4ad980b to 175d78b Compare August 28, 2026 02:30
@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (9781683) and force-pushed as 175d78b. Main's pin is f5deafe0; oven-sh/WebKit#268 is rebased onto that commit (both commits applied cleanly, content unchanged) and the pin is its preview, autobuild-preview-pr-268-da719fa3, so the engine differs from main only by #268's two commits. The only conflict was again the WEBKIT_VERSION line.

Re-run on the new preview: the two ALS suites, process.test.js, the other async_hooks files and event-emitter.test.ts (407 pass / 3 todo / 3 skip, all four fixtures on bun and node), and the 17 node test-promise* / test-microtask-queue-* scripts. The previous build (#107174) was red only on url.test.ts on darwin x64, which is red on main in 11 of the last 12 main builds and is reported to main-break triage.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 175d78b to 6d1f6fc Compare August 28, 2026 09:28
@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

@alii I changed course on the pin, because the preview approach could not stay mergeable: the preview I built for main's pin 1817c3c3 this morning was stale before it finished, since main had moved to c4ddc0cf in the meantime (that is the ninth bump in five days; each costs an hour-long WebKit preview build).

6d1f6fc now builds on main's own WebKit pin and does not touch scripts/build/deps/webkit.ts at all, so nothing here conflicts with further bumps. The two fixtures that need oven-sh/WebKit#268 (finally-thenable and then-passthrough) are in the tracking test's todos with a comment pointing at #268; I confirmed both still fail on main's pin and that everything else passes there (tracking suite 76 pass / 3 todo, AsyncLocalStorage.test.ts 55 pass / 2 todo, process.test.js, the other async_hooks files and event-emitter.test.ts, the 17 node test-promise* scripts). The description is updated accordingly: this PR is the bun half, and the Next.js case in #39847 additionally needs #268, so the issue is referenced instead of closed by it.

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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Comment thread src/runtime/test_runner/jest.rs Outdated
@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from ffea832 to cdcd0e8 Compare August 29, 2026 15:15

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from cdcd0e8 to 740aacb Compare August 29, 2026 23:16
Comment thread src/jsc/JSGlobalObject.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/AsyncContextFrame.cpp Outdated
Comment thread src/jsc/bindings/AsyncContextFrame.cpp Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.h Outdated
Comment thread src/jsc/virtual_machine_exports.rs Outdated
@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 740aacb to e3f715e Compare August 29, 2026 23:23

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from e3f715e to 51528db Compare August 30, 2026 09:06

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 51528db to 7dda549 Compare September 8, 2026 03:28

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

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.
@robobun
robobun force-pushed the farm/71122839/als-unhandled-rejection branch from 7dda549 to 81d0d90 Compare September 16, 2026 20:38
@robobun
robobun requested a review from alii September 16, 2026 21:45
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

A second route to this bug, and the conflict with #42590

Bun.ModuleGraph (#42590) made this bug visible in a program that runs no graph code. The listener reads the async context that is current when the tracker drains. A top-level enterWith() frame survives only until the next microtask checkpoint that runs with no script on the stack. A task that the entry module queues adds such a checkpoint before the drain. graph.dispose() queues one (ScriptExecutionContext::stop(), src/jsc/bindings/ScriptExecutionContext.cpp:121). port.postMessage() does the same with no graph.

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"));
mode node v26.3.0 bun 1.4.3-canary c6b7fcb5b (before #42590) bun main 0d3492e353
none top top top
port top undefined undefined
graph undefined

The conflict

Both PRs changed the queue of rejected promises. #42590 replaced it with RejectedPromiseQueue. An entry is {promise, rejectionOwner}. The owner is the graph whose onError takes the rejection, and the tracker decides it when the promise is rejected. Bun__handleRejectedPromise now takes that owner as its third argument. This PR passes the async context in the same position.

The two fit together

I checked this with a small patch on 0d3492e353 (debug build, patch below). The entry gets a third field, asyncContext, which promiseRejectionTracker reads from m_asyncContextData. handleRejectedPromises() puts a JSC::AsyncContextSwapScope around Bun__handleRejectedPromise. Results with the patch:

  • The program above prints top in all three modes.
  • The same holds with a live graph, and with a live graph that imported a module first (main gives undefined for that one).
  • Rejections from a timer, from setImmediate, inside als.run(), from an async function, and after a top-level await give the same store as node v26.3.0.
  • test/js/bun/module-graph/module-graph.test.ts (292 tests), module-graph-io.test.ts and module-graph-callbacks.test.ts pass. A graph's onError then runs on top of the frames of the code that rejected, through ErrorHandlerContextScope. That is what it already does for an uncaught exception.

The patch is only the check. It has none of this PR's handling of throwing listeners, of the --unhandled-rejections modes, or of workers.

Patch used for the check
diff --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;
     };

This branch was successfully deployed

1 active (outdated) deployment
staging - docs — 30e1f88c Deployed Jul 18, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AsyncLocalStorage context is not preserved when unhandledRejection is emitted

2 participants