From f8fdefd121d99ea9d8e6b248af83b50b6d24cf98 Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Thu, 1 Oct 2026 13:23:59 -0600 Subject: [PATCH 1/5] feat: run await continuations from fibers in hidden fibers 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 --- fibers_async.js | 28 +++++++++- future.js | 3 +- src/fibers.cc | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/fibers_async.js b/fibers_async.js index 966ea58..5a76817 100644 --- a/fibers_async.js +++ b/fibers_async.js @@ -10,7 +10,8 @@ const asyncResourceWeakMap = new WeakMap(); function Fiber(fn, ...args) { const ar = new AsyncResource('Fiber'); const actualFn = (...args1) => ar.runInAsyncScope(() => { - Fiber.current._meteor_dynamics = undefined; + // _Fiber.current: Fiber.current hides the fibers that run code after an await. + _Fiber.current._meteor_dynamics = undefined; // return the fiber function's value so run() resolves to it when the fiber finishes (fibers README semantics) return fn(...args1); }); @@ -22,7 +23,20 @@ function Fiber(fn, ...args) { Fiber.__proto__ = _Fiber; Fiber.prototype = _Fiber.prototype; +// Fibers that run promise reactions (see below) are hidden: `Fiber.current` reports them as +// undefined, exactly what code after an `await` sees today, so `Fiber.current`-based mode switches +// ("in a fiber? return sync, else return a Promise") keep behaving as before. Blocking primitives +// (Future.wait, Promise.await) use `Fiber.currentIncludingHidden` so they can still yield. +const kHidden = Symbol.for('fibers.hiddenAwaitContinuation'); + Object.defineProperty(Fiber, 'current', { + get() { + const current = _Fiber.current; + return current && current[kHidden] ? undefined : current; + } +}) + +Object.defineProperty(Fiber, 'currentIncludingHidden', { get() { return _Fiber.current; } @@ -51,4 +65,16 @@ _Fiber[Symbol.hasInstance] = function(obj) { return obj instanceof Fiber || obj.run; }; +// Prototype (Qualia): with a patched node, V8 offers us every promise reaction (e.g. the code after +// an `await`) that was registered inside a fiber. Run it in a new, hidden fiber so it can still +// block. The job's own promise hooks restore its async context inside the fiber. +// FIBERS_AWAIT_DISPATCH=0 turns this off. +if (typeof _Fiber.__setMicrotaskDispatcher === 'function' && process.env.FIBERS_AWAIT_DISPATCH !== '0') { + _Fiber.__setMicrotaskDispatcher(function dispatchMicrotask(runDispatched, token) { + const fiber = Fiber(() => runDispatched(token)); + fiber[kHidden] = true; + fiber.run(); + }); +} + module.exports = _Fiber.Fiber = Fiber; diff --git a/future.js b/future.js index 8d907f9..9dcd5d5 100644 --- a/future.js +++ b/future.js @@ -154,7 +154,8 @@ Future.wait = function wait(/* ... */) { } // Resumes current fiber - var fiber = Fiber.current; + // Includes the hidden fibers that run code after an `await` (see fibers_async.js). + var fiber = Fiber.currentIncludingHidden || Fiber.current; if (!fiber) { throw new Error('Can\'t wait without a fiber. Most likely you called `Promise.await()` after calling `await somePromiseAPI()`. Read https://engdocs.qualia.io/qualia/advanced-topics/fibers'); } diff --git a/src/fibers.cc b/src/fibers.cc index cf86a51..a6ff594 100644 --- a/src/fibers.cc +++ b/src/fibers.cc @@ -7,6 +7,24 @@ #include #include +// Prototype (Qualia): patched node exposes v8::SetMicrotaskDispatchCallback so the code after an +// `await` (or a `.then` callback) registered inside a fiber also runs inside a fiber. +#if defined(__has_include) +#if __has_include() +#include +#include +#define FIBERS_AWAIT_DISPATCH 1 +#endif +#endif + +#ifdef FIBERS_AWAIT_DISPATCH +// Looked up at load time so this binary still works (with the feature off) on unpatched node. +typedef void (*SetMicrotaskDispatchCallbackFn)(v8::Isolate*, v8::MicrotaskDispatchCallback, void*); +typedef void (*RunDispatchedMicrotaskFn)(v8::Isolate*, v8::DispatchedMicrotask*); +static SetMicrotaskDispatchCallbackFn set_microtask_dispatch_callback = NULL; +static RunDispatchedMicrotaskFn run_dispatched_microtask = NULL; +#endif + #define THROW(x, m) return uni::Return(uni::ThrowException(Isolate::GetCurrent(), x(uni::NewLatin1String(Isolate::GetCurrent(), m))), args) using namespace std; @@ -433,6 +451,13 @@ class Fiber { static Fiber* current; static vector orphaned_fibers; static Persistent fatal_stack; +#ifdef FIBERS_AWAIT_DISPATCH + static Persistent cped_marker; + static Persistent microtask_dispatcher; + static Persistent run_dispatched; + static Persistent token_template; + static double microtasks_dispatched; +#endif Isolate* isolate; Persistent handle; @@ -721,6 +746,21 @@ class Fiber { Fiber* last_fiber = current; current = this; +#ifdef FIBERS_AWAIT_DISPATCH + // While this fiber runs, the context's continuation-preserved embedder data (CPED) is the + // marker. V8 copies CPED into every promise reaction registered meanwhile, and offers + // those reactions to DispatchMicrotask() instead of running them on whatever stack is + // draining the microtask queue. + bool tag_reactions = !microtask_dispatcher.IsEmpty(); + Local cped_context; + Local previous_cped; + if (tag_reactions) { + cped_context = Local::New(isolate, v8_context); + previous_cped = cped_context->GetContinuationPreservedEmbedderData(); + cped_context->SetContinuationPreservedEmbedderData(Local::New(isolate, cped_marker)); + } +#endif + // This will jump into either `RunFiber()` or `Yield()`, depending on if the fiber was // already running. { @@ -729,6 +769,12 @@ class Fiber { this_fiber->run(); } +#ifdef FIBERS_AWAIT_DISPATCH + if (tag_reactions) { + cped_context->SetContinuationPreservedEmbedderData(previous_cped); + } +#endif + // At this point the fiber either returned or called `yield()`. current = last_fiber; } @@ -891,6 +937,78 @@ class Fiber { return uni::Return(uni::NewNumber(Isolate::GetCurrent(), Coroutine::coroutines_created()), info); } +#ifdef FIBERS_AWAIT_DISPATCH + /** + * Called by V8 for a promise reaction (e.g. the code after an `await`) that was registered + * while a fiber was running. Hands the job to the JS dispatcher, which runs it in a fiber. + * Returns false (V8 runs the job inline) if the dispatcher didn't start it. + */ + static bool DispatchMicrotask(Isolate* isolate, v8::DispatchedMicrotask* task, void* data) { + if (microtask_dispatcher.IsEmpty()) { + return false; + } + HandleScope scope(isolate); + Local context = isolate->GetCurrentContext(); + Local token; + if (!Local::New(isolate, token_template)->NewInstance(context).ToLocal(&token)) { + return false; + } + token->SetAlignedPointerInInternalField(0, task); + Local argv[2] = { Local::New(isolate, run_dispatched), token }; + { + uni::TryCatch try_catch(isolate); + Local ignored; + if (!Local::New(isolate, microtask_dispatcher)->Call(context, Undefined(isolate), 2, argv).ToLocal(&ignored) && try_catch.HasCaught()) { + String::Utf8Value message(isolate, try_catch.Exception()); + cerr << "fibers: microtask dispatcher threw: " << (*message ? *message : "") << "\n"; + } + } + if (token->GetAlignedPointerFromInternalField(0) != NULL) { + // Never started: make the token inert and let V8 run the job inline. + token->SetAlignedPointerInInternalField(0, NULL); + return false; + } + ++microtasks_dispatched; + return true; + } + + /** + * runDispatched(token): runs the job behind `token` on the current stack. Only the first call + * does anything. + */ + static uni::FunctionType RunDispatched(const uni::Arguments& args) { + Isolate* isolate = args.GetIsolate(); + if (args.Length() != 1 || !args[0]->IsObject() || args[0].As()->InternalFieldCount() != 1) { + THROW(Exception::TypeError, "runDispatched expects a dispatch token"); + } + Local token = args[0].As(); + v8::DispatchedMicrotask* task = static_cast(token->GetAlignedPointerFromInternalField(0)); + if (task != NULL) { + token->SetAlignedPointerInInternalField(0, NULL); + run_dispatched_microtask(isolate, task); + } + return uni::Return(uni::Undefined(isolate), args); + } + + /** + * Fiber.__setMicrotaskDispatcher(fn): fn(runDispatched, token) must call + * runDispatched(token) synchronously, normally inside a new fiber. + */ + static uni::FunctionType SetMicrotaskDispatcher(const uni::Arguments& args) { + Isolate* isolate = args.GetIsolate(); + if (args.Length() != 1 || !args[0]->IsFunction()) { + THROW(Exception::TypeError, "__setMicrotaskDispatcher expects a function"); + } + microtask_dispatcher.Reset(isolate, args[0].As()); + set_microtask_dispatch_callback(isolate, DispatchMicrotask, NULL); + return uni::Return(uni::Undefined(isolate), args); + } + + static uni::FunctionType GetMicrotasksDispatched(Local property, const uni::GetterCallbackInfo& info) { + return uni::Return(uni::NewNumber(Isolate::GetCurrent(), microtasks_dispatched), info); + } +#endif + public: /** * Initialize the Fiber library. @@ -941,6 +1059,20 @@ class Fiber { uni::SetAccessor(isolate, fn, uni::NewLatin1Symbol(isolate, "current"), GetCurrent); uni::SetAccessor(isolate, fn, uni::NewLatin1Symbol(isolate, "poolSize"), GetPoolSize, SetPoolSize); uni::SetAccessor(isolate, fn, uni::NewLatin1Symbol(isolate, "fibersCreated"), GetFibersCreated); +#ifdef FIBERS_AWAIT_DISPATCH + set_microtask_dispatch_callback = (SetMicrotaskDispatchCallbackFn)dlsym(RTLD_DEFAULT, "v8_qualia_SetMicrotaskDispatchCallback"); + run_dispatched_microtask = (RunDispatchedMicrotaskFn)dlsym(RTLD_DEFAULT, "v8_qualia_RunDispatchedMicrotask"); + if (set_microtask_dispatch_callback && run_dispatched_microtask) { + Local token_tmpl = ObjectTemplate::New(isolate); + token_tmpl->SetInternalFieldCount(1); + token_template.Reset(isolate, token_tmpl); + cped_marker.Reset(isolate, Object::New(isolate)); + run_dispatched.Reset(isolate, uni::GetFunction(uni::NewFunctionTemplate(isolate, RunDispatched))); + fn->Set(context, uni::NewLatin1Symbol(isolate, "__setMicrotaskDispatcher"), + uni::GetFunction(uni::NewFunctionTemplate(isolate, SetMicrotaskDispatcher))).FromJust(); + uni::SetAccessor(isolate, fn, uni::NewLatin1Symbol(isolate, "microtasksDispatched"), GetMicrotasksDispatched); + } +#endif // Global Fiber target->Set(context, uni::NewLatin1Symbol(isolate, "Fiber"), fn).FromJust(); @@ -954,6 +1086,13 @@ Locker* Fiber::global_locker; Fiber* Fiber::current = NULL; vector Fiber::orphaned_fibers; Persistent Fiber::fatal_stack; +#ifdef FIBERS_AWAIT_DISPATCH +Persistent Fiber::cped_marker; +Persistent Fiber::microtask_dispatcher; +Persistent Fiber::run_dispatched; +Persistent Fiber::token_template; +double Fiber::microtasks_dispatched = 0; +#endif bool did_init = false; #if !NODE_VERSION_AT_LEAST(0,10,0) From b632621b1ca3d135201124c7c5e634e6b88654eb Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Fri, 2 Oct 2026 09:07:43 -0600 Subject: [PATCH 2/5] feat: reuse one dispatch fiber and batch consecutive await continuations 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 --- fibers_async.js | 71 ++++++++++++++++++++++++++++++++++++++++++++----- src/fibers.cc | 18 +++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/fibers_async.js b/fibers_async.js index 5a76817..78937d4 100644 --- a/fibers_async.js +++ b/fibers_async.js @@ -66,15 +66,72 @@ _Fiber[Symbol.hasInstance] = function(obj) { }; // Prototype (Qualia): with a patched node, V8 offers us every promise reaction (e.g. the code after -// an `await`) that was registered inside a fiber. Run it in a new, hidden fiber so it can still -// block. The job's own promise hooks restore its async context inside the fiber. +// an `await`) that was registered inside a fiber. Run it in a hidden fiber so it can still block. +// The job's own promise hooks restore its async context inside the fiber. // FIBERS_AWAIT_DISPATCH=0 turns this off. if (typeof _Fiber.__setMicrotaskDispatcher === 'function' && process.env.FIBERS_AWAIT_DISPATCH !== '0') { - _Fiber.__setMicrotaskDispatcher(function dispatchMicrotask(runDispatched, token) { - const fiber = Fiber(() => runDispatched(token)); - fiber[kHidden] = true; - fiber.run(); - }); + const runNextDispatchable = process.env.FIBERS_AWAIT_REUSE !== '0' ? _Fiber.__runNextDispatchable : undefined; + _Fiber.__setMicrotaskDispatcher(typeof runNextDispatchable === 'function' + ? reusingDispatcher(runNextDispatchable) + : dispatchOnNewFiber); +} + +function dispatchOnNewFiber(runDispatched, token) { + const fiber = Fiber(() => runDispatched(token)); + fiber[kHidden] = true; + fiber.run(); +} + +// Runs jobs on one long-lived hidden fiber instead of a new fiber per job. After each job it keeps +// running the dispatchable jobs queued right behind it, so a run of `await`s in fibered code costs +// one switch onto the fiber and one back, not two per `await`. A job that blocks keeps the fiber +// (it's parked inside that job), and the next dispatch starts a new one. FIBERS_AWAIT_REUSE=0 turns +// this off. +let microtasksBatched = 0; +function reusingDispatcher(runNextDispatchable) { + const kIdle = {}; + let dispatchFiber = null; + let runDispatched; + + function dispatchLoop(token) { + const self = _Fiber.current; + for (;;) { + runDispatched(token); + // Only while this is still the dispatch fiber, i.e. we're nested inside the microtask loop. + // A fiber that was parked by a job and later resumed must not pull in unrelated jobs. + while (self === dispatchFiber && runNextDispatchable()) { + microtasksBatched++; + } + if (self !== dispatchFiber) { + return; + } + token = _Fiber.yield(kIdle); + } + } + + return function dispatchMicrotask(run, token) { + runDispatched = run; + if (dispatchFiber && dispatchFiber === _Fiber.current) { + // A nested microtask drain on the dispatch fiber itself; we can't switch into a running fiber. + dispatchOnNewFiber(run, token); + return; + } + if (!dispatchFiber) { + dispatchFiber = Fiber(dispatchLoop); + dispatchFiber[kHidden] = true; + } + const fiber = dispatchFiber; + if (fiber.run(token) !== kIdle && dispatchFiber === fiber) { + // A job blocked on this fiber; it belongs to that job now. + dispatchFiber = null; + } + }; } +Object.defineProperty(Fiber, 'microtasksBatched', { + get() { + return microtasksBatched; + } +}) + module.exports = _Fiber.Fiber = Fiber; diff --git a/src/fibers.cc b/src/fibers.cc index a6ff594..997acb1 100644 --- a/src/fibers.cc +++ b/src/fibers.cc @@ -21,8 +21,10 @@ // Looked up at load time so this binary still works (with the feature off) on unpatched node. typedef void (*SetMicrotaskDispatchCallbackFn)(v8::Isolate*, v8::MicrotaskDispatchCallback, void*); typedef void (*RunDispatchedMicrotaskFn)(v8::Isolate*, v8::DispatchedMicrotask*); +typedef bool (*RunNextDispatchableMicrotaskFn)(v8::Isolate*); static SetMicrotaskDispatchCallbackFn set_microtask_dispatch_callback = NULL; static RunDispatchedMicrotaskFn run_dispatched_microtask = NULL; +static RunNextDispatchableMicrotaskFn run_next_dispatchable_microtask = NULL; #endif #define THROW(x, m) return uni::Return(uni::ThrowException(Isolate::GetCurrent(), x(uni::NewLatin1String(Isolate::GetCurrent(), m))), args) @@ -1004,6 +1006,17 @@ class Fiber { return uni::Return(uni::Undefined(isolate), args); } + /** + * Fiber.__runNextDispatchable(): if the next queued microtask is also one that would be + * dispatched, run it on the current stack and return true. Lets the dispatch fiber run a + * run of such jobs without switching stacks for each one. + */ + static uni::FunctionType RunNextDispatchable(const uni::Arguments& args) { + Isolate* isolate = args.GetIsolate(); + bool ran = run_next_dispatchable_microtask(isolate); + return uni::Return(uni::NewBoolean(isolate, ran), args); + } + static uni::FunctionType GetMicrotasksDispatched(Local property, const uni::GetterCallbackInfo& info) { return uni::Return(uni::NewNumber(Isolate::GetCurrent(), microtasks_dispatched), info); } @@ -1071,6 +1084,11 @@ class Fiber { fn->Set(context, uni::NewLatin1Symbol(isolate, "__setMicrotaskDispatcher"), uni::GetFunction(uni::NewFunctionTemplate(isolate, SetMicrotaskDispatcher))).FromJust(); uni::SetAccessor(isolate, fn, uni::NewLatin1Symbol(isolate, "microtasksDispatched"), GetMicrotasksDispatched); + run_next_dispatchable_microtask = (RunNextDispatchableMicrotaskFn)dlsym(RTLD_DEFAULT, "v8_qualia_RunNextDispatchableMicrotask"); + if (run_next_dispatchable_microtask) { + fn->Set(context, uni::NewLatin1Symbol(isolate, "__runNextDispatchable"), + uni::GetFunction(uni::NewFunctionTemplate(isolate, RunNextDispatchable))).FromJust(); + } } #endif From dcf9992ad257008c90e3147337a8c69e549ba1ce Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Fri, 2 Oct 2026 11:24:19 -0600 Subject: [PATCH 3/5] fix: run await continuations on fibers on node 24 (V8 13) - 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 --- fibers_async.js | 24 ++++++++++++++++++++---- src/fibers.cc | 42 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/fibers_async.js b/fibers_async.js index 78937d4..423cf75 100644 --- a/fibers_async.js +++ b/fibers_async.js @@ -69,13 +69,28 @@ _Fiber[Symbol.hasInstance] = function(obj) { // an `await`) that was registered inside a fiber. Run it in a hidden fiber so it can still block. // The job's own promise hooks restore its async context inside the fiber. // FIBERS_AWAIT_DISPATCH=0 turns this off. -if (typeof _Fiber.__setMicrotaskDispatcher === 'function' && process.env.FIBERS_AWAIT_DISPATCH !== '0') { +if (typeof _Fiber.__setMicrotaskDispatcher === 'function' && process.env.FIBERS_AWAIT_DISPATCH !== '0' + && !asyncContextFrameInUse()) { const runNextDispatchable = process.env.FIBERS_AWAIT_REUSE !== '0' ? _Fiber.__runNextDispatchable : undefined; _Fiber.__setMicrotaskDispatcher(typeof runNextDispatchable === 'function' ? reusingDispatcher(runNextDispatchable) : dispatchOnNewFiber); } +// Node 24's AsyncContextFrame (on by default) keeps AsyncLocalStorage state in the V8 slot we tag +// reactions with, so dispatch needs `--no-async-context-frame` (fibers needs it on node 24 anyway). +function asyncContextFrameInUse() { + if (typeof _Fiber.__cpedInUse !== 'function') { + return false; + } + const { AsyncLocalStorage } = require('async_hooks'); + const inUse = new AsyncLocalStorage().run(true, () => _Fiber.__cpedInUse()); + if (inUse) { + console.warn('[fibers] not running await continuations on fibers: AsyncContextFrame is on (start node with --no-async-context-frame)'); + } + return inUse; +} + function dispatchOnNewFiber(runDispatched, token) { const fiber = Fiber(() => runDispatched(token)); fiber[kHidden] = true; @@ -87,7 +102,8 @@ function dispatchOnNewFiber(runDispatched, token) { // one switch onto the fiber and one back, not two per `await`. A job that blocks keeps the fiber // (it's parked inside that job), and the next dispatch starts a new one. FIBERS_AWAIT_REUSE=0 turns // this off. -let microtasksBatched = 0; +// Kept on the native module, which every copy of this file shares (an app can load more than one). +_Fiber.__microtasksBatched = _Fiber.__microtasksBatched || 0; function reusingDispatcher(runNextDispatchable) { const kIdle = {}; let dispatchFiber = null; @@ -100,7 +116,7 @@ function reusingDispatcher(runNextDispatchable) { // Only while this is still the dispatch fiber, i.e. we're nested inside the microtask loop. // A fiber that was parked by a job and later resumed must not pull in unrelated jobs. while (self === dispatchFiber && runNextDispatchable()) { - microtasksBatched++; + _Fiber.__microtasksBatched++; } if (self !== dispatchFiber) { return; @@ -130,7 +146,7 @@ function reusingDispatcher(runNextDispatchable) { Object.defineProperty(Fiber, 'microtasksBatched', { get() { - return microtasksBatched; + return _Fiber.__microtasksBatched || 0; } }) diff --git a/src/fibers.cc b/src/fibers.cc index 997acb1..edb34af 100644 --- a/src/fibers.cc +++ b/src/fibers.cc @@ -25,6 +25,24 @@ typedef bool (*RunNextDispatchableMicrotaskFn)(v8::Isolate*); static SetMicrotaskDispatchCallbackFn set_microtask_dispatch_callback = NULL; static RunDispatchedMicrotaskFn run_dispatched_microtask = NULL; static RunNextDispatchableMicrotaskFn run_next_dispatchable_microtask = NULL; + +// V8 keeps the continuation-preserved embedder data (CPED) slot per context up to V8 12, per +// isolate from V8 13 (node 24). Only node 18 and node 24 are tested. +static v8::Local GetCped(v8::Isolate* isolate, v8::Local context) { +#if V8_AT_LEAST(13, 0) + return isolate->GetContinuationPreservedEmbedderData(); +#else + return context->GetContinuationPreservedEmbedderData(); +#endif +} + +static void SetCped(v8::Isolate* isolate, v8::Local context, v8::Local value) { +#if V8_AT_LEAST(13, 0) + isolate->SetContinuationPreservedEmbedderData(value); +#else + context->SetContinuationPreservedEmbedderData(value); +#endif +} #endif #define THROW(x, m) return uni::Return(uni::ThrowException(Isolate::GetCurrent(), x(uni::NewLatin1String(Isolate::GetCurrent(), m))), args) @@ -454,7 +472,6 @@ class Fiber { static vector orphaned_fibers; static Persistent fatal_stack; #ifdef FIBERS_AWAIT_DISPATCH - static Persistent cped_marker; static Persistent microtask_dispatcher; static Persistent run_dispatched; static Persistent token_template; @@ -758,8 +775,11 @@ class Fiber { Local previous_cped; if (tag_reactions) { cped_context = Local::New(isolate, v8_context); - previous_cped = cped_context->GetContinuationPreservedEmbedderData(); - cped_context->SetContinuationPreservedEmbedderData(Local::New(isolate, cped_marker)); + previous_cped = GetCped(isolate, cped_context); + // The marker is `true`: it lives in V8's read-only space and never moves. On V8 13 the + // slot is a GC root visited once per archived thread (one per fiber), and visiting a + // young object more than once in a scavenge corrupts the heap. + SetCped(isolate, cped_context, v8::True(isolate)); } #endif @@ -773,7 +793,7 @@ class Fiber { #ifdef FIBERS_AWAIT_DISPATCH if (tag_reactions) { - cped_context->SetContinuationPreservedEmbedderData(previous_cped); + SetCped(isolate, cped_context, previous_cped); } #endif @@ -992,6 +1012,16 @@ class Fiber { return uni::Return(uni::Undefined(isolate), args); } + /** + * Fiber.__cpedInUse(): whether the CPED slot currently holds anything. Used to detect node 24's + * AsyncContextFrame, which keeps AsyncLocalStorage state in the same slot. + */ + static uni::FunctionType CpedInUse(const uni::Arguments& args) { + Isolate* isolate = args.GetIsolate(); + Local cped = GetCped(isolate, isolate->GetCurrentContext()); + return uni::Return(uni::NewBoolean(isolate, !cped->IsUndefined()), args); + } + /** * Fiber.__setMicrotaskDispatcher(fn): fn(runDispatched, token) must call * runDispatched(token) synchronously, normally inside a new fiber. @@ -1079,10 +1109,11 @@ class Fiber { Local token_tmpl = ObjectTemplate::New(isolate); token_tmpl->SetInternalFieldCount(1); token_template.Reset(isolate, token_tmpl); - cped_marker.Reset(isolate, Object::New(isolate)); run_dispatched.Reset(isolate, uni::GetFunction(uni::NewFunctionTemplate(isolate, RunDispatched))); fn->Set(context, uni::NewLatin1Symbol(isolate, "__setMicrotaskDispatcher"), uni::GetFunction(uni::NewFunctionTemplate(isolate, SetMicrotaskDispatcher))).FromJust(); + fn->Set(context, uni::NewLatin1Symbol(isolate, "__cpedInUse"), + uni::GetFunction(uni::NewFunctionTemplate(isolate, CpedInUse))).FromJust(); uni::SetAccessor(isolate, fn, uni::NewLatin1Symbol(isolate, "microtasksDispatched"), GetMicrotasksDispatched); run_next_dispatchable_microtask = (RunNextDispatchableMicrotaskFn)dlsym(RTLD_DEFAULT, "v8_qualia_RunNextDispatchableMicrotask"); if (run_next_dispatchable_microtask) { @@ -1105,7 +1136,6 @@ Fiber* Fiber::current = NULL; vector Fiber::orphaned_fibers; Persistent Fiber::fatal_stack; #ifdef FIBERS_AWAIT_DISPATCH -Persistent Fiber::cped_marker; Persistent Fiber::microtask_dispatcher; Persistent Fiber::run_dispatched; Persistent Fiber::token_template; From 1eacecdda9c198ce55971ca2c9bc06c42b64c1e0 Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Fri, 2 Oct 2026 12:24:52 -0600 Subject: [PATCH 4/5] fix: only warn about AsyncContextFrame when await dispatch is explicitly 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 --- fibers_async.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fibers_async.js b/fibers_async.js index 423cf75..4137cc8 100644 --- a/fibers_async.js +++ b/fibers_async.js @@ -69,8 +69,10 @@ _Fiber[Symbol.hasInstance] = function(obj) { // an `await`) that was registered inside a fiber. Run it in a hidden fiber so it can still block. // The job's own promise hooks restore its async context inside the fiber. // FIBERS_AWAIT_DISPATCH=0 turns this off. +_Fiber.awaitDispatchEnabled = false; if (typeof _Fiber.__setMicrotaskDispatcher === 'function' && process.env.FIBERS_AWAIT_DISPATCH !== '0' && !asyncContextFrameInUse()) { + _Fiber.awaitDispatchEnabled = true; const runNextDispatchable = process.env.FIBERS_AWAIT_REUSE !== '0' ? _Fiber.__runNextDispatchable : undefined; _Fiber.__setMicrotaskDispatcher(typeof runNextDispatchable === 'function' ? reusingDispatcher(runNextDispatchable) @@ -79,13 +81,15 @@ if (typeof _Fiber.__setMicrotaskDispatcher === 'function' && process.env.FIBERS_ // Node 24's AsyncContextFrame (on by default) keeps AsyncLocalStorage state in the V8 slot we tag // reactions with, so dispatch needs `--no-async-context-frame` (fibers needs it on node 24 anyway). +// Quiet unless FIBERS_AWAIT_DISPATCH=1 asks for dispatch: helper processes (build tools, child +// processes) load fibers without the flag. Fiber.awaitDispatchEnabled tells you which way it went. function asyncContextFrameInUse() { if (typeof _Fiber.__cpedInUse !== 'function') { return false; } const { AsyncLocalStorage } = require('async_hooks'); const inUse = new AsyncLocalStorage().run(true, () => _Fiber.__cpedInUse()); - if (inUse) { + if (inUse && process.env.FIBERS_AWAIT_DISPATCH === '1') { console.warn('[fibers] not running await continuations on fibers: AsyncContextFrame is on (start node with --no-async-context-frame)'); } return inUse; From d0ed894d58482256f732d546fd761b44643ba788 Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Fri, 2 Oct 2026 12:26:07 -0600 Subject: [PATCH 5/5] fix: disable the AsyncContextFrame probe after use 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 --- fibers_async.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fibers_async.js b/fibers_async.js index 4137cc8..700d778 100644 --- a/fibers_async.js +++ b/fibers_async.js @@ -88,7 +88,11 @@ function asyncContextFrameInUse() { return false; } const { AsyncLocalStorage } = require('async_hooks'); - const inUse = new AsyncLocalStorage().run(true, () => _Fiber.__cpedInUse()); + const probe = new AsyncLocalStorage(); + const inUse = probe.run(true, () => _Fiber.__cpedInUse()); + // Without AsyncContextFrame, an AsyncLocalStorage turns on promise hooks for the whole process + // (every promise pays for them); disabling the probe turns them off again if nothing else uses one. + probe.disable(); if (inUse && process.env.FIBERS_AWAIT_DISPATCH === '1') { console.warn('[fibers] not running await continuations on fibers: AsyncContextFrame is on (start node with --no-async-context-frame)'); }