AsyncLocalStorage: drop enterWith() frames at the event-loop boundary, stop allocating per await/then

Merged
JA
2mo ago
+493-221

What

Two problems with AsyncLocalStorage in 1.4.1:

1. enterWith() leaked past the callback that called it (Bun-only). A frame installed inside a timer / setImmediate / I/O callback / server handler stayed in the global slot until some later microtask tick happened to clear it, so unrelated callbacks that ran in between inherited it — and retained whatever it referenced. Node drops it when the callback's CallbackScope closes.

const als = new AsyncLocalStorage();
setImmediate(() => als.enterWith("a"));
setImmediate(() => console.log(als.getStore())); // node: undefined   bun 1.4.1: "a"

Now the microtask checkpoint resets the slot whenever no script is on the stack (the boundary between event-loop callbacks), and — in JSC — every promise reaction / async continuation runs under exactly the frame it captured, including "none", and puts the previous value back when it ends. That replaces cleanupLater / asyncHooksNeedsCleanup / the onEachMicrotaskTick juggling and the reset hack in JSNextTickQueue::drain.

2. A store being active cost an allocation per await / .then() / .finally(). JSC wrapped [userContext, asyncContext] into a 32-byte InternalFieldTuple to carry the frame to the reaction job. oven-sh/WebKit#552 threads the frame through slots that already exist (the 4th microtask argument, the inline reaction's cell slot, the slim reaction's promise slot, JSFullPromiseReaction::m_context + a payload flag), gates all of it on a VM-wide "an AsyncLocalStorage exists" flag, and stops performPromiseThenWithContext (every native stream read) from allocating a tuple when no frame is active. On the JS side, frames are now a persistent linked chain ({storage, value, prev}) instead of a [storage, value, ...] array that run() copied with slice()+push and enterWith() with concat(): run() pushes one three-field object and pops it, captures share their tail, getStore() walks the (short) chain, enterWith() replaces its own binding so the chain stays bounded, and disable() exits its binding in place.

Depends on oven-sh/WebKit#552 (WEBKIT_VERSION points at its preview build; to be replaced by the merge SHA).

Measurements

Same-commit A/B: main + unpatched WebKit vs this branch + oven-sh/WebKit#552, both --profile=release-local on the same machine.

Heap per pending operation with a store active (heapStats().objectTypeCounts, 100k ops — deterministic):

beforeafter
await pending203 B — InternalFieldTuple + SlimPromiseReaction171 B — SlimPromiseReaction (same as no ALS)
pending.then(f)155 B — InternalFieldTuple + FullPromiseReaction123 B — FullPromiseReaction
pending.then(f, g)187 B — InternalFieldTuple + FullPromiseReaction155 B (same as no ALS)
setTimeout163 B163 B (unchanged; AsyncContextFrame wrapper)

Instructions per operation (perf stat -e instructions:u, 6×1M ops per process; the box was loaded so wall-clock is noisier than this). "no store" = an AsyncLocalStorage exists but the loop is not inside run():

store: beforestore: afterno store: beforeno store: after
await <value>559471 (−16%)447454
await Promise.resolve()612533 (−13%)503518
for await (async generator)13211144 (−13%)11091111
N × pending.then(f) + Promise.all25071989 (−21%)20302060
p = p.then(f) chain18081753 (−3%)838857
queueMicrotask741744710714
setTimeout3607358232903310

Wall clock, min of 3 interleaved runs × min of 5 iterations, taskset to 8 cores, store active (node v26.3.0 on the same box for reference):

ns/opnode 26bun mainthis PR
als.run(v, fn)41028.815.9
3× nested run()283333857.8
await <value>36.844.134.5
await Promise.resolve()55.948.635.5
for await (async generator)16211088.3
N × pending.then(f) + Promise.all435345281
queueMicrotask26566.965.1
setTimeout(0)503437445

p = p.then(f) chains under a store are unchanged (~450 ns; one 48-byte FullPromiseReaction per link remains vs the inline reaction without ALS). No-store rows are within run-to-run noise. The +1–3% instructions in the "no store" column is the residue protection: with an ALS in the process every reaction job now reads and restores the slot.

jsc-shell numbers for the engine change alone are in oven-sh/WebKit#552.

Behavior notes

  • enterWith() at the top level of the entry module is still seen by everything scheduled from there (timers, process.nextTick, promise continuations, await, servers started afterwards capture it), as in Node; what changes is that a frame set inside one event-loop callback is no longer visible to the next unrelated one, and process.on("exit") handlers run with no frame (as in Node).
  • run() pops back to the frame object from before the call rather than a copy. Node's finally { enterWith(prior) } installs a fresh frame, so in Node a later disable() (which deletes from the current frame object in place) does not reach continuations captured before that run(), while in Bun it now does. disable() itself still exits the binding in place, so consecutive disable() calls reach captured continuations as in Node (test added).

Tests

  • test/js/node/async_hooks/AsyncLocalStorage.test.ts: residue across timers / immediates / I/O callbacks / microtasks / nextTicks / promise reactions / server requests (with a FinalizationRegistry check that per-request stores are collected), no per-await/.then helper allocations under a store, consecutive disable(). The residue and allocation tests fail on 1.4.1.
  • Existing: AsyncLocalStorage-tracking (74 fixtures), async_hooks.node, thenable, EventEmitterAsyncResource, node parallel test-async-local-storage-*, process-nexttick, timers, streams, fetch body streams, bun:test, serve, node:http, spawn, workers, vm — same results as the baseline build.
  • JSC: JSTests/stress/bun-async-context-propagation.js in the WebKit PR; promise/async/generator/module stress subset shows no failures that differ from the unpatched build.