Skip to content

DFG/FTL: bail out of the PerformPromiseThenOneHandler fast path when an async context is active - #278

Merged
Jarred-Sumner merged 2 commits into
mainfrom
bun/async-context-promise-then-fast-path
Jul 11, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
bun/async-context-promise-then-fast-path

Conversation

@robobun

@robobun robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Repro (oven-sh/bun#33806)

import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();

function chain() {
  let p = Promise.resolve();
  for (let i = 0; i < 3; i++) p = p.then(() => {});
  return p.then(() => als.getStore());
}

let bad = 0, firstBad = -1;
for (let i = 0; i < 8000; i++) {
  const r = await als.run({ v: i }, chain);
  if (!r || r.v !== i) { bad++; if (firstBad < 0) firstBad = i; }
}
console.log(bad ? `FAIL ${bad}/8000, first bad iteration ${firstBad}` : 'PASS');

On Bun 1.4.0 this prints FAIL 7538/8000, first bad iteration 462: every chain loses its store once chain tiers up to the DFG. BUN_JSC_useDFGJIT=0 makes it pass; BUN_JSC_useFTLJIT=0 does not, so the break is in the DFG tier (FTL inherits it). Regression against Bun 1.3.14, introduced with the C++ promise rewrite that added the PromisePrototypeThenIntrinsic / PerformPromiseThenOneHandler folding.

Cause

Promise.prototype.then with one callable handler is folded to a PerformPromiseThenOneHandler node, and its DFG/FTL fast path stores the handler directly on the promise as an inline reaction. Inline reactions cannot carry Bun's async context: the C++ performPromiseThen explicitly skips the inline-reaction path when globalObject->m_asyncContextData holds an active context (JSPromise.cpp), but the JIT fast path has no such check, so the reaction is enqueued without the context and the continuation runs with getStore() === undefined.

Fix

Mirror the C++ check in both JIT tiers: load m_asyncContextData's internal field 0 and take the existing slow path (operationPerformPromiseThenOneHandler -> performPromiseThen, which captures the context into a full reaction) when it is not undefined. The fast path stays a two-load compare when no AsyncLocalStorage scope is active, and the check is compiled only under USE(BUN_JSC_ADDITIONS).

Verification

With a bun run build:local debug build of oven-sh/bun at b05b4fab0 plus this change:

Companion Bun PR with the regression test to follow.

…an async context is active

The PerformPromiseThenOneHandler fast path stores the handler directly on
the promise as an inline reaction. Inline reactions cannot carry Bun's
async context (performPromiseThen already falls back to a full reaction in
that case), so once the calling code tiered up to the DFG,
AsyncLocalStorage.getStore() returned undefined inside .then()
continuations. Check globalObject->m_asyncContextData and take the slow
path, which captures the context, when one is active.

Fixes oven-sh/bun#33806.

@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 and the JIT check faithfully mirrors the C++ path in JSPromise.cpp, but hand-written DFG/FTL codegen for the promise-then fast path is worth a human pass.

Extended reasoning...

Overview

This PR adds a runtime bailout to the PerformPromiseThenOneHandler fast path in both the DFG (DFGSpeculativeJIT.cpp) and FTL (FTLLowerDFGToB3.cpp) tiers: when JSGlobalObject::m_asyncContextData holds a non-undefined internal field 0, the JIT takes the existing operationPerformPromiseThenOneHandler slow path so performPromiseThen can capture Bun's async context into a full reaction. A trivial offsetOfAsyncContextData() accessor is added to JSGlobalObject.h. All new code is guarded by USE(BUN_JSC_ADDITIONS).

Security risks

None apparent. The check only adds a load/compare on VM-internal state (a WriteBarrier<InternalFieldTuple>) and diverts to an already-existing slow path. No user-controlled data influences the new offsets or loads, and the null-pointer case is guarded before dereferencing internal field 0. The JSInternalFieldObjectImpl<>::offsetOfInternalField(0) and m_heaps.JSInternalFieldObjectImpl_internalFields[0] accessors match established patterns elsewhere in the DFG/FTL.

Level of scrutiny

High. This is hand-written codegen in both optimizing JIT tiers on the Promise.prototype.then hot path. The DFG side introduces a scoped GPRTemporary and converts a single Jump into a JumpList; the FTL side inserts two new basic blocks between the packed-mask check and the fast path and moves lastNext. Mistakes here manifest as miscompilation rather than compile errors, so a reviewer familiar with SpeculativeJIT register lifetimes and B3 block linking should confirm the control flow (in particular that packed still dominates fastPath in the FTL lowering, which it does since it's loaded in the entry block before any branch).

Other factors

The logic exactly mirrors the C++ guard in JSPromise::performPromiseThen (JSPromise.cpp:350-357), the PR description documents end-to-end verification against the repro across DFG-only and FTL configurations plus the async_hooks and promise test suites, and the bug hunter found nothing. I'm deferring rather than approving purely because JIT lowering changes fall outside the "simple/mechanical" bar for auto-approval.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This change adds async context detection to Promise.then compilation across DFG and FTL JIT tiers, gated by USE(BUN_JSC_ADDITIONS). A new offset accessor on JSGlobalObject exposes async context data location, and active contexts route execution through the slow path.

Changes

Async context aware promise-then compilation

Layer / File(s) Summary
JSGlobalObject async context offset accessor
Source/JavaScriptCore/runtime/JSGlobalObject.h
Adds offsetOfAsyncContextData() as a guarded static constexpr helper returning the byte offset of m_asyncContextData.
DFG slow-path selection with async context check
Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
Replaces the single slow-path jump with a JumpList slowCases, adds an async-context slow case, and passes the list to slowPathCall.
FTL fast/slow path branching with async context check
Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
Adds guarded basic blocks that inspect async-context data and route to the slow path when the context is active and non-undefined, while retaining the original branching otherwise.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template and omits the bug link, reviewed-by line, and structured changed-file section. Rewrite the PR description in the repository template format, including the Bugzilla link, reviewed-by line, explanation, and changed-file bullets.
✅ Passed checks (3 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 accurately summarizes the main DFG/FTL fast-path bailout change for active async contexts.

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

@github-actions

github-actions Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
d95b1370 autobuild-preview-pr-278-d95b1370 2026-07-10 12:25:39 UTC
45bbb77c autobuild-preview-pr-278-45bbb77c 2026-07-09 04:26:20 UTC

robobun added a commit to oven-sh/bun that referenced this pull request Jul 9, 2026
Points at autobuild-preview-pr-278-45bbb77c so the new regression test
runs against the fixed JSC. Re-pin to the merge commit's autobuild when
the WebKit PR lands.
Comment thread Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp Outdated
Allocating a GPRTemporary can emit a spill store; doing so after the
first slowCases branch means that store only runs on the fall-through
path and can desync the register state assumed by addSlowPathGenerator.
Hoist the allocation next to the packed temporary and drop the now
unnecessary block scope.

@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 touches DFG/FTL codegen with register-allocation ordering constraints (which already needed one fixup), so it's worth a final human sign-off.

Extended reasoning...

Overview

This PR adds an async-context bailout to the PerformPromiseThenOneHandler fast path in both the DFG (DFGSpeculativeJIT.cpp) and FTL (FTLLowerDFGToB3.cpp) tiers, plus a new offsetOfAsyncContextData() accessor on JSGlobalObject. When m_asyncContextData's internal field 0 is not undefined, the JIT now routes to the existing slow path so performPromiseThen can capture the context into a full reaction — mirroring the check already present in the C++ runtime path.

Security risks

None apparent. The change only adds an extra runtime guard that diverts to an existing, already-exercised slow path; it introduces no new attacker-controlled inputs, no memory layout changes beyond a compile-time offset accessor, and is gated behind USE(BUN_JSC_ADDITIONS).

Level of scrutiny

High. This is hand-written JIT code generation in two optimizing tiers. Correctness here depends on subtle invariants — register/temporary allocation ordering relative to branches, JumpList linkage into addSlowPathGenerator, and B3 basic-block threading (appendTo / lastNext). The reviewer already caught one such ordering bug (GPRTemporary allocated after a slow-path branch), which was fixed in d95b137. That's exactly the class of issue that argues for human eyes on the final revision rather than bot approval.

Other factors

  • A domain expert has already reviewed and requested a change; that change was applied and the thread resolved, but no explicit approval has been posted yet.
  • Verification was done against a local Bun build (repro + async_hooks/promise test suites), which is reassuring but not something I can independently confirm.
  • The diff is small and focused, and the non-BUN_JSC_ADDITIONS build path is unchanged, but the affected code is on a very hot path (Promise.prototype.then).

Given the JIT-correctness sensitivity and the in-flight human review, deferring is the right call.

@Jarred-Sumner
Jarred-Sumner merged commit 234d8b3 into main Jul 11, 2026
49 checks passed
robobun added a commit to oven-sh/bun that referenced this pull request Jul 13, 2026
oven-sh/WebKit#280 was merged with fork main to pick up oven-sh/WebKit#278
(the DFG/FTL PerformPromiseThenOneHandler async-context bailout for #33806);
the preview release is published with 43 artifacts.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
oven-sh/WebKit#280 was merged with fork main to pick up oven-sh/WebKit#278
(the DFG/FTL PerformPromiseThenOneHandler async-context bailout for #33806);
the preview release is published with 43 artifacts.
robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
oven-sh/WebKit#280 was merged with fork main to pick up oven-sh/WebKit#278
(the DFG/FTL PerformPromiseThenOneHandler async-context bailout for #33806);
the preview release is published with 43 artifacts.
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.

3 participants