Skip to content

JSC: propagate async context through PromiseFinallyAwaitJob - #268

Open
robobun wants to merge 1 commit into
mainfrom
robobun/async-function-resume-context-ordering
Open

JSC: propagate async context through PromiseFinallyAwaitJob#268
robobun wants to merge 1 commit into
mainfrom
robobun/async-function-resume-context-ordering

Conversation

@robobun

@robobun robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

When a .finally() callback returns a thenable, the follow-up that propagates the original fulfilment (or rejects with the thenable's reason) runs as InternalMicrotask::PromiseFinallyAwaitJob. Unlike PromiseFinallyReactionJob a few lines above it, this case did not install the async context, and the phase-1 handler did not capture it on the reaction it schedules. So in Bun:

const als = new AsyncLocalStorage();
process.on("unhandledRejection", () => console.log(als.getStore()));
als.run("ctx", () => Promise.resolve().finally(() => Promise.reject(new Error("e"))));
// bun:  undefined   node: "ctx"

Fix

Capture the active async context alongside the reaction at the two schedule points in promiseFinallyReactionJob (performPromiseThenWithInternalMicrotask and createResolvingFunctionsWithInternalMicrotask), and install/restore it in the PromiseFinallyAwaitJob case, mirroring PromiseFinallyReactionJob. The synchronous promiseFinallyAwaitJob calls remain inside the phase-1 install window and are unaffected.

Context

This was originally the second commit of this PR, alongside the AsyncFunctionResume settle-ordering fix. That half landed independently as #295, so this PR is rebased to carry only the remaining PromiseFinallyAwaitJob fix.

Audit

Of the internal-microtask cases that can settle a promise with a Bun async context active:

Case State
PromiseReactionJob already correct (settle, then restore)
AsyncFunctionResume fixed by #295
AsyncGeneratorYieldAwaited, AsyncGeneratorBodyCallNormal/Return, AsyncGeneratorAwaitReturnContinuation already correct
PromiseFinallyReactionJob (phase 1) already correct
PromiseFinallyAwaitJob (phase 2) this PR

Verification

Consumed by oven-sh/bun#31721, whose async-context/async-context-unhandled-rejection-async-fn.js fixture runs the example above (and its throw-in-finally, async-generator, and await-throw siblings) against both Bun and Node.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any issues — the reordering cleanly mirrors the existing PromiseReactionJob pattern — but since this changes observable async-context semantics on the async-function settle path, it's worth a quick human sanity check.

Extended reasoning...

Overview

This PR reorders two statements in each of the two terminal arms of InternalMicrotask::AsyncFunctionResume in JSMicrotask.cpp: it now calls promise->reject() / promise->resolve() before restoring the Bun async-context slot, instead of after. The net diff is moving scope.release(); promise->reject/resolve(...) above the asyncContextData->putInternalField(vm, 0, restoreAsyncContext) restore in both branches, plus a comment explaining the ordering.

Correctness

The new ordering is a direct copy of the already-established pattern in PromiseReactionJob a few dozen lines above (lines ~1855–1866), which explicitly documents the same invariant. asyncContextData is rooted via globalObject->m_asyncContextData and restoreAsyncContext is a stack JSValue, so there's no GC hazard from calling into reject()/resolve() before the restore. putInternalField doesn't need the throw scope, so scope.release() preceding it is fine — again identical to the reference pattern. The audit table in the PR description matches what I see in the file.

Security risks

None. This is a Bun-only (USE(BUN_JSC_ADDITIONS)) reordering of when an async-context slot is restored; no new inputs, no parsing, no auth/crypto/permissions surface.

Level of scrutiny

Medium-high. The diff is mechanically tiny and pattern-matched against existing code, and the bug-hunting pass found nothing. However, it lives on the hot path of async function completion in JavaScriptCore and intentionally changes what the embedder's promiseRejectionTracker (and thenable-resolution jobs) observe. That's a deliberate, user-visible semantic change to AsyncLocalStorage behavior across await, so I'd rather a human confirm the intended semantics than auto-approve.

Other factors

Verification is via the downstream oven-sh/bun#31721 fixture suite rather than an in-tree JSC test, and there are no prior reviewer comments on the PR. Given it's engine-runtime behavior rather than a config/typo change, deferring.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Promise finally continuation scheduling now captures Bun’s active async context, and the await microtask unwraps that context before accessing the promise reaction.

Promise finally async context

Layer / File(s) Summary
Capture context for finally scheduling
Source/JavaScriptCore/runtime/JSMicrotask.cpp
promiseFinallyReactionJob captures the current async context and passes it through both continuation scheduling paths.
Unwrap context for await execution
Source/JavaScriptCore/runtime/JSMicrotask.cpp
PromiseFinallyAwaitJob unwraps the encoded context before extracting the JSSlimPromiseReaction.

Possibly related PRs

  • oven-sh/WebKit#278: Propagates active async context across Promise continuation microtasks.
  • oven-sh/WebKit#301: Introduces related AsyncContextSwapScope handling for Promise finally microtasks.

Suggested reviewers: constellation, sosukesuzuki

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately names the main change: propagating async context through PromiseFinallyAwaitJob.
Description check ✅ Passed The description covers problem, fix, context, audit, and verification, with only minor template metadata missing.
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.

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — small reorder that mirrors the established PromiseReactionJob pattern in the same file.

Extended reasoning...

Overview

This PR reorders two statements in each of the two terminal arms of InternalMicrotask::AsyncFunctionResume in Source/JavaScriptCore/runtime/JSMicrotask.cpp. Previously the async context was restored before calling promise->reject() / promise->resolve(); now the settle happens first and the context is restored afterwards. A comment is added referencing the identical ordering already used by PromiseReactionJob at line ~1855 in the same file.

Security risks

None. The change is a pure statement reorder inside #if USE(BUN_JSC_ADDITIONS) guards. putInternalField is a simple internal-field store with a write barrier and does not depend on the exception scope, so moving it after scope.release() and the settle call is safe — this is exactly what PromiseReactionJob already does a few dozen lines above (verified at JSMicrotask.cpp:1858-1866). No new user-controlled data flow, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. While this is core JSC async/await runtime code, the change is mechanical: it aligns two branches with an existing, documented, in-tree pattern rather than introducing new logic. The PR description includes a thorough audit of every putInternalField(vm, 0, restoreAsyncContext) site in the file confirming the other branches already follow the settle-then-restore ordering. The behavioural delta is narrow (what promiseRejectionTracker and thenable-resolution jobs observe as the current async context), and .then()/.catch() handler semantics are unaffected since performPromiseThen captures context at registration time.

Other factors

The change is Bun-specific (entirely inside USE(BUN_JSC_ADDITIONS)), is consumed and tested by oven-sh/bun#31721 with fixtures that fail before and pass after, and the multi-agent bug hunter found no issues. No prior human or bot review comments to address. Net diff is ~10 lines of reordering plus a 4-line explanatory comment.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
5f70edce autobuild-preview-pr-268-5f70edce 2026-07-18 02:47:42 UTC
86efe056 autobuild-preview-pr-268-86efe056 2026-07-16 01:46:56 UTC
0aef04ea autobuild-preview-pr-268-0aef04ea 2026-07-14 23:34:47 UTC
48232e38 autobuild-preview-pr-268-48232e38 2026-07-14 07:09:58 UTC
ee98a203 autobuild-preview-pr-268-ee98a203 2026-07-02 23:44:09 UTC

robobun added a commit to oven-sh/bun that referenced this pull request Jul 3, 2026
oven-sh/WebKit#268 settles the async function's promise before restoring
the async context, so a function that fails after an await reports its
rejection with its own context still installed. Un-skips the async-fn
fixture, which now passes on bun and node alike.

Pinned to the PR's preview build; re-pin to the autobuild tag of its
merge commit once it lands on WebKit main.
@robobun
robobun force-pushed the robobun/async-function-resume-context-ordering branch from ee98a20 to 48232e3 Compare July 14, 2026 06:26

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any issues, but this reorders exception-scope release and promise settlement in JSC's async function resume path, so it's worth a human sign-off.

What was reviewed:

  • Confirmed the new ordering matches PromiseReactionJob at JSMicrotask.cpp:1858-1867 (settle → restore).
  • putInternalField is a plain write-barrier store (InternalFieldTuple.h:75) — safe after scope.release() even with a pending exception from reject()/resolve().
  • Termination-exception early-return and the await-continuation arm still restore context correctly.
Extended reasoning...

Overview

Two-branch reorder in runInternalMicrotask's AsyncFunctionResume case (Source/JavaScriptCore/runtime/JSMicrotask.cpp). In both terminal arms — the error reject arm and the State::Executing resolve arm — the Bun async-context restore (asyncContextData->putInternalField(vm, 0, restoreAsyncContext)) is moved from before scope.release(); promise->reject/resolve(...) to after it. A comment is added referencing the identical ordering already used in PromiseReactionJob.

Security risks

None. The change is a reorder of two existing statements inside #if USE(BUN_JSC_ADDITIONS) blocks; no new inputs, no auth/crypto/permission surface.

Level of scrutiny

Moderate-to-high. The diff is tiny and mechanically mirrors the established PromiseReactionJob pattern in the same function (lines 1858-1867 and 1875-1880), which already does scope.release(); settle; restore;. I verified putInternalField is a bare WriteBarrier::set with no exception checks, so calling it after scope.release() with a possibly-pending exception is safe — same as the reference case. That said, this is the JSC microtask/async-resume hot path, where ordering between ThrowScope::release(), promise settlement, the rejection tracker callback, and async-context slot mutation is subtle and observable to embedders. Per the approval guidelines for critical runtime code paths, I'm deferring rather than auto-approving.

Other factors

  • The PR description includes a full audit table of every restoreAsyncContext site in the file; I spot-checked it and it's accurate.
  • Downstream tests exist in oven-sh/bun#31721 covering the unhandledRejection + AsyncLocalStorage scenario.
  • No prior human or bot review on the thread (CodeRabbit was rate-limited).
  • The bug hunting system found no issues.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
oven-sh/WebKit#268 settles the async function's promise before restoring
the async context, so a function that fails after an await reports its
rejection with its own context still installed. Un-skips the async-fn
fixture, which now passes on bun and node alike.

Pinned to the PR's preview build (on top of WebKit 4895f45d, so it keeps
the shared-allocator change from #34009). Re-pin to the autobuild tag of
its merge commit once it lands on WebKit main.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
…fixture cases

The claim that these arms were already correct was wrong: a .finally()
callback that returns a rejected thenable settles from
PromiseFinallyAwaitJob, which did not carry the async context across, so
the unhandledRejection handler observed undefined. Fixed alongside the
AsyncFunctionResume ordering in oven-sh/WebKit#268.

Add fixture cases for both .finally() shapes and two async-generator
shapes. The file is back in the tracking test's todos until the WebKit
pin picks up the PromiseFinallyAwaitJob fix.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this touches core JSC promise/async-context machinery and changes observable AsyncLocalStorage semantics, so it warrants a human look. Note that the diff also includes a second commit (PromiseFinallyAwaitJob context propagation) that isn't covered by the PR description — the audit table there marks PromiseFinallyReactionJob as "already correct", but this PR now modifies it.

What was reviewed:

  • AsyncFunctionResume reorder matches the PromiseReactionJob reference pattern exactly; putInternalField after scope.release() is a plain write-barrier store, safe post-release.
  • New InternalFieldTuple wrap/unwrap for PromiseFinallyAwaitJob follows the same shape as AsyncGeneratorYieldAwaited / PromiseReactionJob; the synchronous promiseFinallyAwaitJob(...) call sites keep passing raw context (no wrap needed since context is still installed).
  • Checked that restoreAsyncContext is only read when asyncContextData is non-null in all new arms.
Extended reasoning...

Overview

Single-file change to Source/JavaScriptCore/runtime/JSMicrotask.cpp with two logically separate pieces:

  1. AsyncFunctionResume settle ordering (matches title/description): reorders the error-reject and Executing-resolve arms to call promise->reject() / promise->resolve() before restoring the Bun async-context slot, so promiseRejectionTracker() and thenable-resolution jobs observe the async function's context. This is a two-line swap in each arm plus a comment, and mirrors the existing PromiseReactionJob ordering line-for-line.

  2. PromiseFinallyAwaitJob context propagation (second commit 0aef04ea, not in the PR description): promiseFinallyReactionJob now captures the current async context into an InternalFieldTuple alongside the reaction context when scheduling phase 2, and the PromiseFinallyAwaitJob case unwraps it and installs/restores around promiseFinallyAwaitJob(...). This is net-new behavior, though it follows the same InternalFieldTuple-wrap pattern already used by PromiseReactionJob, AsyncGeneratorYieldAwaited, and AsyncFromSyncIteratorContinue.

Security risks

None apparent. No parsing, no untrusted input handling, no auth/crypto. The change adjusts when a per-VM InternalFieldTuple slot is written and adds a heap allocation (InternalFieldTuple::create) on the .finally() scheduling path. The dynamicDowncast<InternalFieldTuple> discriminator is type-safe; the fallback uncheckedDowncast<JSSlimPromiseReaction> matches pre-existing behavior for the non-wrapped case.

Level of scrutiny

High. This is core JavaScriptCore promise/microtask machinery on the hot path of every await and .finally(). It intentionally changes what Bun's unhandledRejection handler and thenable-resolution jobs observe from AsyncLocalStorage, i.e., user-visible semantics. The change is small and pattern-following, but the surface it touches is exactly the kind where subtle ordering bugs (double-restore, leaked context, missed restore on early return) are easy to introduce and hard to detect. A maintainer familiar with the Bun async-context design should confirm the intended semantics — particularly for the second commit, which the PR description doesn't cover.

Other factors

  • The PR description's audit table is stale: it lists PromiseFinallyReactionJob as "already correct / unchanged", but the diff modifies it. The description should be updated or the second commit split out.
  • putInternalField is a simple WriteBarrier::set (checked InternalFieldTuple.h) — safe to call after scope.release(), consistent with all sibling arms.
  • The synchronous promiseFinallyAwaitJob(globalObject, vm, result, context, ...) call sites in promiseFinallyReactionJob correctly keep passing raw context rather than scheduledContext, since the async context is still installed at that point (phase-1 wrapper restores it).
  • Downstream test coverage exists in oven-sh/bun#31721 per the description, but no in-tree JSC tests are added here.
  • No prior human or bot reviews with substantive feedback on the timeline.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
oven-sh/WebKit#268's second commit carries the async context through
PromiseFinallyAwaitJob, so a .finally() callback that returns a
rejected thenable now reports its unhandled rejection with the
callback's store. Un-skips the async-fn fixture (now 7 cases, all
passing on bun and node).

Also makes the "unhandledRejection async context" block concurrent —
8 hermetic subprocess spawns, so there's no reason to run them
sequentially.
@robobun
robobun force-pushed the robobun/async-function-resume-context-ordering branch from 0aef04e to 86efe05 Compare July 16, 2026 01:12
@robobun robobun changed the title JSC: keep async context installed across AsyncFunctionResume settle JSC: propagate async context through PromiseFinallyAwaitJob Jul 16, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any issues — the capture/install/restore logic is a close mirror of the existing PromiseFinallyReactionJob case and the other InternalFieldTuple-wrapped microtask handlers in this file. Deferring to a human because this sits on the JSC promise microtask dispatch path.

What was reviewed:

  • Confirmed the wrap/unwrap pattern matches phase 1 (PromiseFinallyReactionJob) and the ~8 other dynamicDowncast<InternalFieldTuple> sites in runInternalMicrotask.
  • Verified the two schedule points now carry scheduledContext while the synchronous promiseFinallyAwaitJob calls still receive the raw context (they run inside the phase-1 install window).
  • Checked that scope.release() ordering and the post-call putInternalField restore match the established pattern; non-Bun builds are unchanged behind #if USE(BUN_JSC_ADDITIONS).
Extended reasoning...

Overview

This PR modifies Source/JavaScriptCore/runtime/JSMicrotask.cpp to propagate Bun's async context (AsyncLocalStorage) through the PromiseFinallyAwaitJob internal microtask — the phase-2 job that runs when a .finally() callback returns a thenable. It has two pieces:

  1. In promiseFinallyReactionJob (phase 1), before scheduling phase 2 via performPromiseThenWithInternalMicrotask or createResolvingFunctionsWithInternalMicrotask, wrap the JSSlimPromiseReaction context in an InternalFieldTuple alongside the current async context (only when one is active).
  2. In the InternalMicrotask::PromiseFinallyAwaitJob case of runInternalMicrotask, unwrap that tuple, install the captured async context into globalObject->m_asyncContextData, run promiseFinallyAwaitJob, then restore the previous value.

Both pieces are guarded by #if USE(BUN_JSC_ADDITIONS); the non-Bun path is byte-for-byte unchanged.

Security risks

None identified. This is internal promise-reaction bookkeeping — no user-controlled input parsing, no auth/crypto/permissions surface. The uncheckedDowncast<JSSlimPromiseReaction> on the unwrapped field is safe because the only writer of that slot is the phase-1 code added in this same PR, which always stores a JSSlimPromiseReaction*. The InternalFieldTuple is GC-managed, so the captured context and async-context values are kept alive across the microtask boundary the same way they are in every other case in this file.

Level of scrutiny

High. runInternalMicrotask is on the hot path for every promise reaction in Bun, and mistakes in async-context install/restore can leak state across unrelated tasks. That said, the change is not novel logic — it is a near-verbatim copy of the PromiseFinallyReactionJob case immediately above it (JSMicrotask.cpp:2176-2214) and structurally identical to the async-generator and module cases at lines ~1940/2059/2088/2117/2146/2361. I compared each step (tuple detection, asyncContextData null-guard, restoreAsyncContext capture, scope.release() before the job call, restore after) and they line up exactly.

I also verified the four synchronous promiseFinallyAwaitJob(...) calls inside promiseFinallyReactionJob (non-object result, non-thenable, .then getter throw, non-callable .then) still pass the raw context, not scheduledContext — correct, since those run while phase 1's async context is still installed by the caller.

Other factors

  • The PR description includes an audit table of all internal-microtask cases that settle promises under an async context, with this being the last gap after #295.
  • Companion test coverage lives in oven-sh/bun#31721 (async-context-unhandled-rejection-async-fn.js), validated against Node.
  • No prior human review comments to address; only bot summaries in the timeline.

Given the critical code path, I'm deferring rather than auto-approving, but I have no concrete concerns to raise.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main: the AsyncFunctionResume commit was superseded by #295 (merged), so this PR now carries only the PromiseFinallyAwaitJob fix. Title and description updated to match.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 settles the async function's promise before restoring
the async context, so a function that fails after an await reports its
rejection with its own context still installed. Un-skips the async-fn
fixture, which now passes on bun and node alike.

Pinned to the PR's preview build (on top of WebKit 4895f45d, so it keeps
the shared-allocator change from #34009). Re-pin to the autobuild tag of
its merge commit once it lands on WebKit main.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
…fixture cases

The claim that these arms were already correct was wrong: a .finally()
callback that returns a rejected thenable settles from
PromiseFinallyAwaitJob, which did not carry the async context across, so
the unhandledRejection handler observed undefined. Fixed alongside the
AsyncFunctionResume ordering in oven-sh/WebKit#268.

Add fixture cases for both .finally() shapes and two async-generator
shapes. The file is back in the tracking test's todos until the WebKit
pin picks up the PromiseFinallyAwaitJob fix.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268's second commit carries the async context through
PromiseFinallyAwaitJob, so a .finally() callback that returns a
rejected thenable now reports its unhandled rejection with the
callback's store. Un-skips the async-fn fixture (now 7 cases, all
passing on bun and node).

Also makes the "unhandledRejection async context" block concurrent —
8 hermetic subprocess spawns, so there's no reason to run them
sequentially.
When a .finally() callback returns a thenable, the follow-up that
propagates the original fulfilment (or rejects with the thenable's
reason) runs as InternalMicrotask::PromiseFinallyAwaitJob. Unlike
PromiseFinallyReactionJob a few lines above it, this case did not
install the async context, and the phase-1 handler did not capture it
on the reaction it schedules, so an unhandled rejection originating
here observed an undefined AsyncLocalStorage store.

Capture the active async context alongside the reaction at the
schedule points in promiseFinallyReactionJob, and install/restore it
in the PromiseFinallyAwaitJob case, mirroring PromiseFinallyReactionJob.
The synchronous promiseFinallyAwaitJob calls remain inside the
phase-1 install window and are unaffected.
@robobun
robobun force-pushed the robobun/async-function-resume-context-ordering branch from 86efe05 to 5f70edc Compare July 18, 2026 02:09
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again: #301 introduced AsyncContextSwapScope, so this PR now uses it — one wrapWithCurrent call at the schedule point and one unwrapContextTuple + RAII scope in the case, matching PromiseFinallyReactionJob. 19 lines instead of 51.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Source/JavaScriptCore/runtime/JSMicrotask.cpp (1)

803-860: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Defer async-context wrapping until deferred work is needed.

wrapWithCurrent allocates an InternalFieldTuple whenever a context is active, but synchronous, primitive, non-thenable, and non-callable results execute inline and never use scheduledContext. Capture it immediately before the two deferred scheduling calls to avoid an allocation on the common synchronous path.

🤖 Prompt for 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.

In `@Source/JavaScriptCore/runtime/JSMicrotask.cpp` around lines 803 - 860, Move
the AsyncContextSwapScope::wrapWithCurrent call out of the initial setup and
defer it until just before the deferred PromiseFinallyAwaitJob scheduling paths:
the fast JSPromise performPromiseThenWithInternalMicrotask call and
createResolvingFunctionsWithInternalMicrotask. Keep synchronous, primitive,
non-thenable, and non-callable branches using the original context directly,
while passing the wrapped context only to deferred work.
🤖 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.

Outside diff comments:
In `@Source/JavaScriptCore/runtime/JSMicrotask.cpp`:
- Around line 803-860: Move the AsyncContextSwapScope::wrapWithCurrent call out
of the initial setup and defer it until just before the deferred
PromiseFinallyAwaitJob scheduling paths: the fast JSPromise
performPromiseThenWithInternalMicrotask call and
createResolvingFunctionsWithInternalMicrotask. Keep synchronous, primitive,
non-thenable, and non-callable branches using the original context directly,
while passing the wrapped context only to deferred work.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: afd1840e-84f7-4eb5-a62b-91526d29ce59

📥 Commits

Reviewing files that changed from the base of the PR and between 48232e3 and 5f70edc.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any issues — the change mechanically applies the same wrapWithCurrent / unwrapContextTuple + AsyncContextSwapScope pattern already used by PromiseFinallyReactionJob and the other async-generator cases in this file. Deferring to a human because this is core JSC promise-reaction machinery and part of a subtle async-context series (#278/#295/#301).

What was reviewed:

  • wrapWithCurrent returns context unchanged when no async context is active, so the fast/non-ALS path and non-Bun builds (#else branch) are unaffected.
  • unwrapContextTuple mutates contextArg in place before the uncheckedDowncast<JSSlimPromiseReaction>, so the cast is still safe when a tuple was passed.
  • The synchronous promiseFinallyAwaitJob(...) calls still pass the raw context — correct, since they run inside the phase-1 scope's install window.
Extended reasoning...

Overview

The PR touches a single file, Source/JavaScriptCore/runtime/JSMicrotask.cpp, adding ~19 lines across two locations:

  1. In promiseFinallyReactionJob (phase 1), wrap the JSSlimPromiseReaction* context with the current Bun async context via AsyncContextSwapScope::wrapWithCurrent before handing it to the two schedule points that enqueue PromiseFinallyAwaitJob (performPromiseThenWithInternalMicrotask and createResolvingFunctionsWithInternalMicrotask).
  2. In the InternalMicrotask::PromiseFinallyAwaitJob dispatch case, unwrap that tuple with unwrapContextTuple and install the async context via an RAII AsyncContextSwapScope, exactly as the adjacent PromiseFinallyReactionJob case already does.

Both additions are guarded by #if USE(BUN_JSC_ADDITIONS); the #else branch keeps scheduledContext = context, so non-Bun builds see no behavioral or codegen change.

Security risks

None identified. This is internal promise-reaction plumbing with no user-controlled input beyond what already flows through the existing code paths. The added allocation (InternalFieldTuple::create inside wrapWithCurrent) is the same one already performed for the sibling reaction jobs and is GC-safe (all live values are on the stack or already stored in the reaction via setHandlerOrContext).

Level of scrutiny

This is core JSC promise-reaction runtime — every .finally() that returns a thenable flows through here — so it warrants a real look from someone who owns Bun's AsyncLocalStorage semantics. The change itself is mechanical (it copies the pattern from the case block ~20 lines above verbatim), but async-context propagation ordering has been subtle enough to need a multi-PR series (#278, #295, #301, and this one), so I'd rather a human confirm the settle-vs-restore ordering here matches the intended ALS semantics.

Other factors

  • The non-Bun fast path is preserved: wrapWithCurrent returns the raw context when no async context is active, so no extra allocation on the common path.
  • The four synchronous promiseFinallyAwaitJob(globalObject, vm, ..., context, ...) calls in promiseFinallyReactionJob intentionally keep the raw context rather than scheduledContext — they execute inside the phase-1 AsyncContextSwapScope, so wrapping would be redundant. The PR description calls this out.
  • unwrapContextTuple takes JSValue& and rewrites it to field 0 before returning field 1, so the subsequent uncheckedDowncast<JSSlimPromiseReaction>(contextArg) remains type-correct whether or not a tuple was passed.
  • Downstream test coverage exists in oven-sh/bun#31721.
  • Bug hunting system found nothing.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 rebased onto WebKit main a8d15c1c and rewritten to use
the AsyncContextSwapScope helper from #301, so it now matches every other
microtask case (one wrapWithCurrent at the schedule point, one
unwrapContextTuple + RAII scope in the case). Same behaviour, 19 lines
instead of 51.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 settles the async function's promise before restoring
the async context, so a function that fails after an await reports its
rejection with its own context still installed. Un-skips the async-fn
fixture, which now passes on bun and node alike.

Pinned to the PR's preview build (on top of WebKit 4895f45d, so it keeps
the shared-allocator change from #34009). Re-pin to the autobuild tag of
its merge commit once it lands on WebKit main.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
…fixture cases

The claim that these arms were already correct was wrong: a .finally()
callback that returns a rejected thenable settles from
PromiseFinallyAwaitJob, which did not carry the async context across, so
the unhandledRejection handler observed undefined. Fixed alongside the
AsyncFunctionResume ordering in oven-sh/WebKit#268.

Add fixture cases for both .finally() shapes and two async-generator
shapes. The file is back in the tracking test's todos until the WebKit
pin picks up the PromiseFinallyAwaitJob fix.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268's second commit carries the async context through
PromiseFinallyAwaitJob, so a .finally() callback that returns a
rejected thenable now reports its unhandled rejection with the
callback's store. Un-skips the async-fn fixture (now 7 cases, all
passing on bun and node).

Also makes the "unhandledRejection async context" block concurrent —
8 hermetic subprocess spawns, so there's no reason to run them
sequentially.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 rebased onto WebKit main a8d15c1c and rewritten to use
the AsyncContextSwapScope helper from #301, so it now matches every other
microtask case (one wrapWithCurrent at the schedule point, one
unwrapContextTuple + RAII scope in the case). Same behaviour, 19 lines
instead of 51.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 settles the async function's promise before restoring
the async context, so a function that fails after an await reports its
rejection with its own context still installed. Un-skips the async-fn
fixture, which now passes on bun and node alike.

Pinned to the PR's preview build (on top of WebKit 4895f45d, so it keeps
the shared-allocator change from #34009). Re-pin to the autobuild tag of
its merge commit once it lands on WebKit main.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
…fixture cases

The claim that these arms were already correct was wrong: a .finally()
callback that returns a rejected thenable settles from
PromiseFinallyAwaitJob, which did not carry the async context across, so
the unhandledRejection handler observed undefined. Fixed alongside the
AsyncFunctionResume ordering in oven-sh/WebKit#268.

Add fixture cases for both .finally() shapes and two async-generator
shapes. The file is back in the tracking test's todos until the WebKit
pin picks up the PromiseFinallyAwaitJob fix.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268's second commit carries the async context through
PromiseFinallyAwaitJob, so a .finally() callback that returns a
rejected thenable now reports its unhandled rejection with the
callback's store. Un-skips the async-fn fixture (now 7 cases, all
passing on bun and node).

Also makes the "unhandledRejection async context" block concurrent —
8 hermetic subprocess spawns, so there's no reason to run them
sequentially.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 rebased onto WebKit main a8d15c1c and rewritten to use
the AsyncContextSwapScope helper from #301, so it now matches every other
microtask case (one wrapWithCurrent at the schedule point, one
unwrapContextTuple + RAII scope in the case). Same behaviour, 19 lines
instead of 51.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 settles the async function's promise before restoring
the async context, so a function that fails after an await reports its
rejection with its own context still installed. Un-skips the async-fn
fixture, which now passes on bun and node alike.

Pinned to the PR's preview build (on top of WebKit 4895f45d, so it keeps
the shared-allocator change from #34009). Re-pin to the autobuild tag of
its merge commit once it lands on WebKit main.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
…fixture cases

The claim that these arms were already correct was wrong: a .finally()
callback that returns a rejected thenable settles from
PromiseFinallyAwaitJob, which did not carry the async context across, so
the unhandledRejection handler observed undefined. Fixed alongside the
AsyncFunctionResume ordering in oven-sh/WebKit#268.

Add fixture cases for both .finally() shapes and two async-generator
shapes. The file is back in the tracking test's todos until the WebKit
pin picks up the PromiseFinallyAwaitJob fix.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268's second commit carries the async context through
PromiseFinallyAwaitJob, so a .finally() callback that returns a
rejected thenable now reports its unhandled rejection with the
callback's store. Un-skips the async-fn fixture (now 7 cases, all
passing on bun and node).

Also makes the "unhandledRejection async context" block concurrent —
8 hermetic subprocess spawns, so there's no reason to run them
sequentially.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 18, 2026
oven-sh/WebKit#268 rebased onto WebKit main a8d15c1c and rewritten to use
the AsyncContextSwapScope helper from #301, so it now matches every other
microtask case (one wrapWithCurrent at the schedule point, one
unwrapContextTuple + RAII scope in the case). Same behaviour, 19 lines
instead of 51.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant