Carry the AsyncLocalStorage frame in promise reactions and microtask arguments instead of an InternalFieldTuple
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:JSFullPromiseReactionstores the frame inm_contextwith the per-cell bit set;PromiseReactionJob's payload carriespromiseReactionJobAsyncContextFlagsayingarguments[3]is the frame rather than an embedder handler context.performPromiseThenWithContextonly allocates a tuple when a frame is active or its handler context is itself anInternalFieldTuple(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), inJSSlimPromiseReaction's promise slot (per-cell bit; also used for the resolving-functions context record), and in a fourth slot ofVM::SynchronousModuleTask.performPromiseThenWithInternalMicrotask,resolve/reject/fulfillWithInternalMicrotaskandcreateResolvingFunctionsWithInternalMicrotasktake it as a separate (defaulted) argument. No internal-microtask path builds a tuple any more, so dispatch no longer type-sniffscontextArg.AsyncContextSwapScope: the tracking-enabled flag moves fromJSGlobalObjecttoVM(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 anAsyncLocalStoragepays 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 anenterWith()inside a job ends with the job.- The 4-argument
JSGlobalObject::queueMicrotaskoverload moves intoMicrotaskQueueInlines.hnext 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 ALS | patched, no ALS | patched, flag | base, ctx | patched, ctx | |
|---|---|---|---|---|---|
await <value> | 440 | 429 | 452 | 554 | 456 (−18%) |
await Promise.resolve() | 494 | 497 | 515 | 612 | 522 (−15%) |
p = p.then(f) chain | 879 | 894 | 910 | 1535 | 1021 (−33%) |
N × pending.then(f) + Promise.all | 1792 | 1807 | 1830 | 2125 | 1907 (−10%) |
for await over async generator | 1104 | 1085 | 1121 | 1330 | 1128 (−15%) |
Heap objects per operation with a frame active (Bun heapStats().objectTypeCounts, 100k ops):
| before | after | |
|---|---|---|
await pending | InternalFieldTuple + 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.