Monorepo for the nibling.run platform

Async context stage 2: make AsyncLocalStorage follow await #1

Open natemoo.re
Open opened by natemoo.re

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, queueMicrotask and syscall-completion callbacks (the frame when scheduled);
  • events from fs.watch, child processes, workers, MessagePorts, http servers (the frame of listen()) and readline (the frame when created);
  • .then, catch, finally and the Promise combinators (the frame of the then() call). The then() Proxy installs only once a worker first leaves the root frame, so a worker that never uses AsyncLocalStorage keeps the engine's own then.

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 E becomes $nib.r($f, await E), which restores the frame inside the continuation;
  • yield E in an async generator becomes $nib.y($f, yield E), because Node resumes a generator in the frame of the next() call that resumed it;
  • each catch and finally block whose nearest function is async starts with $nib.r($f);;
  • a for await body 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 web processes run the transform?
  • Should unhandledRejection see the frame of the reject() call? Matching Node means wrapping the Promise constructor's resolving functions.
  • What does enterWith mean in the frame model?
  • Stage 1 chose the creation frame for every event on raw sockets and readline. Node runs socket data/end and readline close in the root frame. Keep the kernel's choice, or match Node?
Sign up or Sign in to add to the discussion.