Skip to content

Run await continuations from fibers on one reused hidden dispatch fiber (needs node#7) - #6

Open
jbaczuk-qualia wants to merge 14 commits into
async-resourcefrom
jbaczuk/fiber-await-v18
Open

jbaczuk-qualia wants to merge 14 commits into
async-resourcefrom
jbaczuk/fiber-await-v18

Conversation

@jbaczuk-qualia

@jbaczuk-qualia jbaczuk-qualia commented Oct 1, 2026 •

Copy link
Copy Markdown

Jira: EE-1048

Summary

R&D prototype, pairs with qualialabs/node#7 (node 18 V8 patch). Makes the code after an await (and .then callbacks) that was registered inside a fiber run on a fiber, so fiber-only calls work there instead of throwing Can't wait without a fiber. Continuations run on one long-lived hidden dispatch fiber, which also runs the dispatchable jobs queued right behind each one, so a chain of awaits in fibered code costs one switch onto the fiber and one back instead of two per await. Two commits on async-resource: hidden-fiber dispatch, then the reused dispatch fiber.

File Change
src/fibers.cc SwapContext() sets the context's continuation-preserved embedder data (CPED) to a marker while a fiber runs, so V8 tags every promise reaction registered inside it. DispatchMicrotask() hands tagged jobs to a JS dispatcher; runDispatched(token) runs one. New Fiber.__setMicrotaskDispatcher, Fiber.__runNextDispatchable() and Fiber.microtasksDispatched. The V8 API is resolved with dlsym, so this binary still loads (feature off) on stock node
fibers_async.js reusingDispatcher: runs jobs on the dispatch fiber and, while it is still the dispatch fiber, keeps calling Fiber.__runNextDispatchable(). A job that blocks keeps the fiber (it's parked inside that job) and the next dispatch starts a new one, the same approach as znewsham's drain fiber in this repo's #7. A parked fiber that is later resumed never pulls in other jobs. A dispatch that arrives while the dispatch fiber itself is running (nested drain) falls back to a one-off fiber. Fiber.current reports hidden fibers as undefined; new Fiber.currentIncludingHidden returns them. Fiber.microtasksBatched counts jobs run without a switch
future.js Future.wait uses Fiber.currentIncludingHidden so it can block in a hidden fiber

Kill switches: FIBERS_AWAIT_DISPATCH=0 disables dispatch (stock behaviour); FIBERS_AWAIT_REUSE=0, or a node without v8_qualia_RunNextDispatchableMicrotask, runs each continuation on its own one-off hidden fiber.

Why hidden fibers

A first version used ordinary fibers and broke qualia's boot: Fiber.current is also used as an API mode switch ("in a fiber, return the result synchronously, otherwise return a Promise"), e.g. @znewsham/meteor-mongo-collection-hooks cursor.observeChanges. Async code after an await suddenly got a handle instead of a Promise (platform-data-subscriptions/server.js:96: handlePromise.catch is not a function). Hiding the fiber keeps every Fiber.current check answering exactly as it does today; only blocking primitives that opt in (Future.wait, Promise.await via qualialabs/shared-packages#265) see the fiber.

Testing

On the node 18 build from qualialabs/node#7, arm64 dev-qualia container:

  • Standalone checks: 11/11 pass with reuse on and off (await then fiber-only call, nested awaits, .then, AsyncLocalStorage across the hop, Promise.all overlap, errors, Fiber.current still undefined after await, a dual-mode API still returns a Promise after await, fiberless code untouched, microtask order; same 45 jobs: 27 dispatched + 18 batched with reuse, 45 dispatched without). FIBERS_AWAIT_DISPATCH=0 fails the expected 7.
  • znewsham's test-microtask-fibers.js (with Fiber.current swapped for the running fiber, poolSize = 1e9): 7/7 behaviour tests pass, including "a parked microtask does not stall other promises" and "resuming a parked fiber does not drain unrelated microtasks mid-callback"; the 100k park/resume cycle test still exceeds its 10 s timeout (~9.7 s for the cycles alone; every park needs its own fiber).
  • meteor-promise suite: 15/15, with native Promise too.
  • On stock node 18 the new binary loads with __setMicrotaskDispatcher absent and behaves as before.
  • test/: identical results on stock vs patched node (pool.js/cleanup.js segfault on both: the pthread coro_destroy bug fixed in d3260f6 on node24-pthread).
  • Local qli5 qualia boots; a startup hook doing await OrdersAsync.countDocuments() then Orders.find().count() logs both counts; fiberless code still throws Can't wait without a fiber.
one fiber per continuation (FIBERS_AWAIT_REUSE=0) reused dispatch fiber
chain of awaits in a fiber ~25–30 us 0.13 us (stock: 0.05–0.07 us)
I/O-style await in a fiber ~33 us ~28 us (stock: ~1.1 us; ~20 us is the pthread run+yield floor)
drains / awaits in code that never had a fiber unchanged unchanged

Known gaps

  • ~25 @meteor guards that throw without a fiber (accounts, sync Meteor.call) still check Fiber.current, so they throw after an await as they do today.
  • Legacy sync-style code that calls a dual-mode API after an await still gets a Promise (unchanged from today).
  • No version bump; nothing published. Counting continuations that block is Count await continuations that block on their dispatch fiber (follow-up to #6) #12, stacked on this.

🤖 Generated with Claude Code

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>
@jbaczuk-qualia jbaczuk-qualia changed the title [Prototype] Run await continuations from fibers in hidden fibers (needs node#7) [Prototype] Run await continuations from fibers on one reused hidden dispatch fiber (needs node#7) Oct 9, 2026
@jbaczuk-qualia
jbaczuk-qualia marked this pull request as ready for review October 15, 2026 15:57
jbaczuk-qualia and others added 2 commits October 15, 2026 11:00
fibers only compiled the await dispatch when v8-microtask-dispatch.h was in
the node headers. node-gyp builds against the stock headers it downloads, and
the patched node's make install doesn't ship that header, so every npm install
(including 5.1.0-rc.1) built a binary with the feature compiled out. Declare
the two types the dispatch uses here instead; the entry points were already
resolved with dlsym, so the binary still loads with the feature off on stock
node. Bump to 5.1.0-rc.2.

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

fix: build the await dispatch without the patched node's header
@jbaczuk-qualia jbaczuk-qualia changed the title [Prototype] Run await continuations from fibers on one reused hidden dispatch fiber (needs node#7) [DO NOT MERGE] Run await continuations from fibers on one reused hidden dispatch fiber (needs node#7) Oct 15, 2026
@jbaczuk-qualia
jbaczuk-qualia marked this pull request as draft October 16, 2026 22:25
@jbaczuk-qualia jbaczuk-qualia changed the title [DO NOT MERGE] Run await continuations from fibers on one reused hidden dispatch fiber (needs node#7) Run await continuations from fibers on one reused hidden dispatch fiber (needs node#7) Oct 22, 2026
@jbaczuk-qualia
jbaczuk-qualia marked this pull request as ready for review October 22, 2026 22:02
Comment thread fibers_async.js Outdated
Comment thread fibers_async.js Outdated
Comment thread fibers_async.js Outdated
jbaczuk-qualia and others added 8 commits October 27, 2026 10:59
…a running flag

- Rename dispatchFiber to reusableFiber.
- Check whether the reusable fiber is running instead of comparing it with Fiber.current, so a
  dispatch from a fiber started on top of it also gets a new fiber rather than calling run() on a
  fiber that is still on the stack.
- Drop the always-true `dispatchFiber === fiber` check, and clear the fiber if run() throws.

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

The reused hidden dispatch fiber runs await continuations from unrelated
requests one after another, so a property one continuation set on
Fiber.currentIncludingHidden was still there for the next. Delete the
fiber's own properties after each job, the same way meteor-promise's
fiber pool does before returning a fiber to the pool.

Like the pool, this uses Object.keys, so symbol-keyed properties
(including the hidden marker) survive, and it can't help code that keys
a WeakMap by the fiber. The new test covers the fix and documents both
limitations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.1.0-5.1.2 were published without the bin/ binaries 5.0.5 shipped, so every
install compiles fibers, and that fails where it can't (npm 6's node-gyp 5
crashes on Node >= 19). A v* tag now runs prebuild.yml: it builds the
linux-x64 binaries for Node 12-22 on bullseye (glibc 2.31), packs them,
checks the tarball with scripts/check-package.js, installs it on each Node
version without compiling, and publishes it to CodeArtifact through the
same OIDC role as shared-packages.

- build.js runs quick-test with --no-deprecation: Node 18 warns about
  process.binding('async_wrap') on stderr, which made it throw away a good
  prebuilt binary and compile.
- .npmignore keeps the release tooling out of the package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
child-process, cleanup and pool fail on async-resource (f3cbba6, the source
of 5.0.5) the same way they fail here, on every Node version: the
fibers_async.js wrapper drops a fiber's return value, and cleanup and pool
crash. Skip them so the release isn't blocked on failures that predate it.

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.

2 participants