Carry the AsyncLocalStorage frame in promise reactions and microtask arguments instead of an InternalFieldTuple

Merged
JA
2mo ago
+607-133

With an AsyncLocalStorage frame active, every .then(), every await and every .finally() allocated an InternalFieldTuple [userContext, asyncContext] just to carry the frame to the reaction job. This threads the frame through slots that already exist instead, and makes a job that captured no frame run with no frame (rather than inheriting whatever an earlier job left in the slot via enterWith()).

What changes

  • performPromiseThen: JSFullPromiseReaction stores the frame in m_context with the per-cell bit set; PromiseReactionJob's payload carries promiseReactionJobAsyncContextFlag saying arguments[3] is the frame rather than an embedder handler context. performPromiseThenWithContext only allocates a tuple when a frame is active or its handler context is itself an InternalFieldTuple (previously: always, when a handler context was passed — i.e. every Bun stream read).
  • await / async generators / for-await drivers / finally / module TLA: the frame rides in the fourth microtask argument, in the inline reaction's cell slot (new flag bit 14, inlineReactionAsyncContextFlag), in JSSlimPromiseReaction's promise slot (per-cell bit; also used for the resolving-functions context record), and in a fourth slot of VM::SynchronousModuleTask. performPromiseThenWithInternalMicrotask, resolve/reject/fulfillWithInternalMicrotask and createResolvingFunctionsWithInternalMicrotask take it as a separate (defaulted) argument. No internal-microtask path builds a tuple any more, so dispatch no longer type-sniffs contextArg.
  • AsyncContextSwapScope: the tracking-enabled flag moves from JSGlobalObject to VM (it was never cleared, and Bun shares the slot across realms); the scope, current() and every capture site test it first, so a VM that never constructed an AsyncLocalStorage pays one flag test. Once enabled, the scope installs the captured value whenever it differs from the current one and always puts the previous value back on exit — a reaction that captured nothing runs with nothing, and an enterWith() inside a job ends with the job.
  • The 4-argument JSGlobalObject::queueMicrotask overload moves into MicrotaskQueueInlines.h next to the 3-argument one so it inlines the same way.
  • $vm.asyncContext() / $vm.setAsyncContext() + JSTests/stress/bun-async-context-propagation.js (then/catch/finally on pending and settled promises, await of pending/settled/non-promise/thenable, spilled inline reactions, async generators + for-await, combinators, throwing handlers, residue after drain). Passes under default, --useJIT=0, --useDFGJIT=0, --forceEagerCompilation=1.

Measurements

jsc shell, perf stat -e instructions:u, instructions per operation (5M ops, --useConcurrentJIT=0 --useConcurrentGC=0), same commit with and without this patch. "flag" = tracking enabled but slot empty (an ALS exists somewhere, no store active); "ctx" = a frame is active.

base, no ALSpatched, no ALSpatched, flagbase, ctxpatched, ctx
await <value>440429452554456 (−18%)
await Promise.resolve()494497515612522 (−15%)
p = p.then(f) chain87989491015351021 (−33%)
N × pending.then(f) + Promise.all17921807183021251907 (−10%)
for await over async generator11041085112113301128 (−15%)

Heap objects per operation with a frame active (Bun heapStats().objectTypeCounts, 100k ops):

beforeafter
await pendingInternalFieldTuple + SlimPromiseReaction (203 B)SlimPromiseReaction (171 B, same as no ALS)
pending.then(f)InternalFieldTuple + FullPromiseReaction (155 B)FullPromiseReaction (123 B)
pending.then(f, g)InternalFieldTuple + FullPromiseReaction (187 B)FullPromiseReaction (155 B, same as no ALS)

The JSTests promise/async/generator/module stress subset shows no failures that differ from the unpatched build.