AsyncLocalStorage: drop enterWith() frames at the event-loop boundary, stop allocating per await/then
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):
| before | after | |
|---|---|---|
await pending | 203 B — InternalFieldTuple + SlimPromiseReaction | 171 B — SlimPromiseReaction (same as no ALS) |
pending.then(f) | 155 B — InternalFieldTuple + FullPromiseReaction | 123 B — FullPromiseReaction |
pending.then(f, g) | 187 B — InternalFieldTuple + FullPromiseReaction | 155 B (same as no ALS) |
setTimeout | 163 B | 163 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: before | store: after | no store: before | no store: after | |
|---|---|---|---|---|
await <value> | 559 | 471 (−16%) | 447 | 454 |
await Promise.resolve() | 612 | 533 (−13%) | 503 | 518 |
for await (async generator) | 1321 | 1144 (−13%) | 1109 | 1111 |
N × pending.then(f) + Promise.all | 2507 | 1989 (−21%) | 2030 | 2060 |
p = p.then(f) chain | 1808 | 1753 (−3%) | 838 | 857 |
queueMicrotask | 741 | 744 | 710 | 714 |
setTimeout | 3607 | 3582 | 3290 | 3310 |
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/op | node 26 | bun main | this PR |
|---|---|---|---|
als.run(v, fn) | 410 | 28.8 | 15.9 |
3× nested run() | 2833 | 338 | 57.8 |
await <value> | 36.8 | 44.1 | 34.5 |
await Promise.resolve() | 55.9 | 48.6 | 35.5 |
for await (async generator) | 162 | 110 | 88.3 |
N × pending.then(f) + Promise.all | 435 | 345 | 281 |
queueMicrotask | 265 | 66.9 | 65.1 |
setTimeout(0) | 503 | 437 | 445 |
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, andprocess.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'sfinally { enterWith(prior) }installs a fresh frame, so in Node a laterdisable()(which deletes from the current frame object in place) does not reach continuations captured before thatrun(), while in Bun it now does.disable()itself still exits the binding in place, so consecutivedisable()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 aFinalizationRegistrycheck that per-request stores are collected), no per-await/.thenhelper allocations under a store, consecutivedisable(). The residue and allocation tests fail on 1.4.1.- Existing:
AsyncLocalStorage-tracking(74 fixtures),async_hooks.node, thenable,EventEmitterAsyncResource, node paralleltest-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.jsin the WebKit PR; promise/async/generator/module stress subset shows no failures that differ from the unpatched build.