Track stage 2 of the kernel's async context design: making AsyncLocalStorage follow await.
Where it stands #
The design is the "Async context" section of packages/kernel/DESIGN.md. Its contract is the WinterTC portable subset of AsyncLocalStorage. Node 24.16.0 is the oracle: it runs AsyncLocalStorage on AsyncContextFrame by default, which uses the subset's model, so goldens recorded under the pinned Node test the subset.
Stage 1 landed in 11f6944. A store set with run() now follows:
- timers, immediates,
nextTick,queueMicrotaskand syscall-completion callbacks (the frame when scheduled); - events from
fs.watch, child processes, workers, MessagePorts,httpservers (the frame oflisten()) and readline (the frame when created); .then,catch,finallyand the Promise combinators (the frame of thethen()call). Thethen()Proxy installs only once a worker first leaves the root frame, so a worker that never usesAsyncLocalStoragekeeps the engine's ownthen.
After an await, the store is undefined. tests/async-context.spec.ts pins that until stage 2.
Stage 2 #
The engine has no hook for native await, so stage 2 is a source transform on the kernel side, for ESM and CJS. In each async function, async arrow, async method, async generator and module top level:
- the body starts with
const $f = $nib.c();, a box holding the frame at entry; await Ebecomes$nib.r($f, await E), which restores the frame inside the continuation;yield Ein an async generator becomes$nib.y($f, yield E), because Node resumes a generator in the frame of thenext()call that resumed it;- each
catchandfinallyblock whose nearest function is async starts with$nib.r($f);; - a
for awaitbody and the statement after the loop start with$nib.r($f);.
The restore runs in the resumed code, so the frame is right no matter who settled the promise, including fetch() and a pool settling a waiter from another request. The transform adds no await, promise or tick. Stage 2 also adds a root-frame reset: one microtask at the end of each drain in which a restore ran.
Results are cached by content hash, and a regex pre-scan skips files with no async or await. The parser loads lazily, and a per-process option turns the transform off.
Measured 2026-11-04 #
These were measured under Node on an Apple M1, not yet inside the kernel.
| Bytes, min / brotli | Vite demo (13.4 MB to parse) | Express + Hono (0.8 MB) | |
|---|---|---|---|
| meriyah 6 | 119 KB / 29 KB | 270 ms | 45 ms |
| oxc parser (wasm) | 737 KB / 195 KB | 341 ms | 56 ms |
| acorn 8 | 122 KB / 29 KB | 554 ms | 80 ms |
For scale, kernel.js is 82 KB brotli and runtime.js is 126 KB.
To do #
Open questions #
- Should
webprocesses run the transform? - Should
unhandledRejectionsee the frame of thereject()call? Matching Node means wrapping the Promise constructor's resolving functions. - What does
enterWithmean in the frame model? - Stage 1 chose the creation frame for every event on raw sockets and readline. Node runs socket
data/endand readlineclosein the root frame. Keep the kernel's choice, or match Node?