Skip to content

[Prototype] Node 24: run await continuations from fibers in hidden fibers (port of #6 + #8) - #9

Draft
jbaczuk-qualia wants to merge 5 commits into
node24-pthreadfrom
jbaczuk/fiber-await-v24
Draft

jbaczuk-qualia wants to merge 5 commits into
node24-pthreadfrom
jbaczuk/fiber-await-v24

Conversation

@jbaczuk-qualia

Copy link
Copy Markdown

Summary

Port of the node 18 fiber-await prototype (#6 + #8) to node 24, stacked on #5 (node24-pthread; merge #5 first). Needs qualialabs/node#10. Code after an await (or a .then callback) registered inside a fiber runs on a hidden fiber, reused and batched across consecutive jobs; Fiber.current still reads undefined there, so Fiber.current-based mode switches are unchanged, while Future.wait (and Promise.await with qualialabs/shared-packages#265) can block.

Commit What
f8fdefd #6 cherry-picked (one trivial conflict with #5's "return the fiber's result" change in fibers_async.js)
b632621 #8 cherry-picked as is
dcf9992 node 24 / V8 13: CPED moved from the context to the isolate (helpers pick the API per V8 version); tag with true instead of a heap object (the CPED slot is a GC root visited once per archived thread, i.e. per fiber, and a young object visited twice in a scavenge corrupts the heap); don't install the dispatcher when AsyncContextFrame is on (it keeps AsyncLocalStorage state in the same slot; detected with Fiber.__cpedInUse()); Fiber.microtasksBatched kept on the shared native module
1eacecd Only warn about AsyncContextFrame when FIBERS_AWAIT_DISPATCH=1 asks for dispatch (helper processes load fibers without the flag); Fiber.awaitDispatchEnabled reports the state
d0ed894 Disable the AsyncContextFrame probe after use: an AsyncLocalStorage otherwise turns on promise hooks for the whole process and made fiberless awaits ~5x slower

Measurement tooling for node 24 (counters, fiber-await/{test,bench}.js, app_stats.mjs, README) is on jbaczuk/fiber-await-v24-stats, which is not meant to merge.

Testing

node 24.21.0 from node#10, arm64 Docker, --no-async-context-frame:

bench stock #6 this, reuse off this
await chain in a fiber 0.04–0.05 us 28.4 us 0.10 us
I/O-style await in a fiber 0.58 us 30.8 us 27–30 us
await outside any fiber 0.03 us 0.03–0.04 us
drain per macrotask 0.54–0.56 us 0.55–0.57 us
  • GDC on qli5 locally (qualia jbaczuk/node-24-gdc-fiber-await, on top of qualia#56473, vendoring this branch with a prebuilt node 24 binary): boots; a fibered startup hook that awaits and then calls Meteor.users.find().count() works on a hidden fiber; fiberless code still throws. Two hand-patches needed there: meteor-lite#41's tarball guard, and shared-packages#265's one-liner in both meteor-promise copies (GDC loads the one nested under @meteor/promise/node_modules).

🤖 Generated with Claude Code

jbaczuk-qualia and others added 5 commits October 2, 2026 11:20
Prototype, needs the patched node (v8-microtask-dispatch.h).

While a fiber runs, its context's CPED is a marker, so every promise
reaction registered inside it (the code after `await`, `.then`
callbacks) is handed to a dispatcher that runs it in a new fiber.

Those fibers are hidden: `Fiber.current` reports undefined there, as
it does after an `await` today, so Fiber.current-based mode switches
(e.g. cursor.observeChanges returning a Promise) are unchanged.
Future.wait uses the new `Fiber.currentIncludingHidden`, so blocking
calls work. The dispatch API is resolved with dlsym, so this binary
still loads (with the feature off) on stock node.
FIBERS_AWAIT_DISPATCH=0 disables it; Fiber.microtasksDispatched counts
dispatched jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Instead of a new hidden fiber per dispatched job, keep one long-lived
hidden dispatch fiber. After each job it runs the dispatchable jobs
queued right behind it (Fiber.__runNextDispatchable, needs the matching
node patch), so a chain of awaits in fibered code costs one switch onto
the fiber and one back instead of two per await. A job that blocks keeps
the fiber and the next dispatch starts a new one; a fiber that was
parked and later resumed never pulls in other jobs.

FIBERS_AWAIT_REUSE=0 restores one new fiber per job.
Fiber.microtasksBatched counts jobs run without a switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- V8 13 moved the continuation-preserved embedder data (CPED) slot from
  the context to the isolate; use Isolate::Get/SetContinuationPreserved
  EmbedderData there and keep the Context API on older V8.
- Tag with `true` instead of a heap object. On V8 13 the CPED slot is a
  GC root visited once per archived thread state (one per fiber), and a
  young object visited twice in one scavenge corrupts the heap. `true`
  lives in read-only space and never moves.
- Don't install the dispatcher when node 24's AsyncContextFrame is on: it
  keeps AsyncLocalStorage state in the same slot. Detected with
  Fiber.__cpedInUse() inside an AsyncLocalStorage.run(); fibers needs
  --no-async-context-frame on node 24 anyway.
- Keep Fiber.microtasksBatched on the shared native module, so it reads
  correctly when an app loads more than one copy of fibers_async.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tly requested

Helper processes (build tools, child processes) load fibers on node 24
without --no-async-context-frame; a warning on every load is noise.
Fiber.awaitDispatchEnabled reports whether dispatch is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without AsyncContextFrame an AsyncLocalStorage enables promise hooks for
the whole process, which made every await ~5x slower in processes that
use no AsyncLocalStorage of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant