From 94914955e54848041a59f4c877d0afd5ebdb0bb9 Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Thu, 1 Oct 2026 13:23:59 -0600 Subject: [PATCH 1/2] feat(v8): let the embedder dispatch promise reaction jobs Prototype for running the code after an `await` inside a fiber. The microtask runner now offers fulfill/reject reaction jobs whose continuation-preserved embedder data (CPED) is set to the embedder via a new callback (include/v8-microtask-dispatch.h). The embedder can run the job later on another stack with RunDispatchedMicrotask, which keeps the usual bookkeeping (context, CPED, promise hooks, exceptions). Unmangled C entry points let addons find the API with dlsym and still load on unpatched node. After a reaction job V8 now restores the previous CPED instead of clearing it, so a job run on a fiber leaves the fiber's marker in place. Co-Authored-By: Claude Opus 5.5 --- deps/v8/include/v8-microtask-dispatch.h | 50 +++++++++ .../builtins/builtins-microtask-queue-gen.cc | 50 ++++++++- deps/v8/src/runtime/runtime-promise.cc | 101 ++++++++++++++++++ deps/v8/src/runtime/runtime.h | 1 + 4 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 deps/v8/include/v8-microtask-dispatch.h diff --git a/deps/v8/include/v8-microtask-dispatch.h b/deps/v8/include/v8-microtask-dispatch.h new file mode 100644 index 000000000000..58a6251f3e30 --- /dev/null +++ b/deps/v8/include/v8-microtask-dispatch.h @@ -0,0 +1,50 @@ +// Copyright 2026 Qualia Labs. Prototype, not upstream V8. +// +// Lets an embedder run a promise reaction job somewhere other than inline in +// the microtask loop, e.g. on a coroutine (node-fibers) stack. Only reaction +// jobs whose continuation-preserved embedder data (CPED, captured when the +// reaction was registered) is not undefined are offered to the embedder. + +#ifndef INCLUDE_V8_MICROTASK_DISPATCH_H_ +#define INCLUDE_V8_MICROTASK_DISPATCH_H_ + +#include "v8config.h" // NOLINT(build/include_directory) + +namespace v8 { + +class Isolate; + +// Opaque handle to a single promise reaction job taken out of the microtask +// queue. +struct DispatchedMicrotask; + +// Called by the microtask loop for a promise reaction job with non-undefined +// CPED. Return true to take ownership of |task|; the embedder must then call +// RunDispatchedMicrotask(isolate, task) exactly once (before the next +// microtask runs, or later). Return false to have V8 run the job inline as +// usual; V8 then frees |task|. +using MicrotaskDispatchCallback = bool (*)(Isolate* isolate, + DispatchedMicrotask* task, + void* data); + +V8_EXPORT void SetMicrotaskDispatchCallback(Isolate* isolate, + MicrotaskDispatchCallback callback, + void* data); + +// Runs |task| on the current stack with the usual microtask bookkeeping +// (context, CPED, promise hooks, exception reporting) and frees it. +V8_EXPORT void RunDispatchedMicrotask(Isolate* isolate, + DispatchedMicrotask* task); + +} // namespace v8 + +// Unmangled entry points, so an addon can find them with dlsym() and still +// load (with the feature off) on a node binary without this patch. +extern "C" { +V8_EXPORT void v8_qualia_SetMicrotaskDispatchCallback( + v8::Isolate* isolate, v8::MicrotaskDispatchCallback callback, void* data); +V8_EXPORT void v8_qualia_RunDispatchedMicrotask(v8::Isolate* isolate, + v8::DispatchedMicrotask* task); +} + +#endif // INCLUDE_V8_MICROTASK_DISPATCH_H_ diff --git a/deps/v8/src/builtins/builtins-microtask-queue-gen.cc b/deps/v8/src/builtins/builtins-microtask-queue-gen.cc index 6b0c63b34ff0..6992962361da 100644 --- a/deps/v8/src/builtins/builtins-microtask-queue-gen.cc +++ b/deps/v8/src/builtins/builtins-microtask-queue-gen.cc @@ -37,6 +37,13 @@ class MicrotaskQueueBuiltinsAssembler : public CodeStubAssembler { void PrepareForContext(TNode microtask_context, Label* bailout); void RunSingleMicrotask(TNode current_context, TNode microtask); + // Prototype (Qualia): offers a promise reaction job whose continuation- + // preserved embedder data is not undefined to the embedder (see + // include/v8-microtask-dispatch.h). Jumps to |dispatched| if it took it. + void MaybeDispatchPromiseReactionJob(TNode current_context, + TNode microtask, + Label* dispatched, Label* if_exception, + TVariable* var_exception); void IncrementFinishedMicrotaskCount(TNode microtask_queue); TNode GetCurrentContext(); @@ -115,6 +122,24 @@ void MicrotaskQueueBuiltinsAssembler::PrepareForContext( SetCurrentContext(native_context); } +void MicrotaskQueueBuiltinsAssembler::MaybeDispatchPromiseReactionJob( + TNode current_context, TNode microtask, + Label* dispatched, Label* if_exception, TVariable* var_exception) { + Label run_inline(this); + const TNode preserved_embedder_data = LoadObjectField( + microtask, + PromiseReactionJobTask::kContinuationPreservedEmbedderDataOffset); + GotoIf(IsUndefined(preserved_embedder_data), &run_inline); + TNode result; + { + ScopedExceptionHandler handler(this, if_exception, var_exception); + result = CallRuntime(Runtime::kDispatchMicrotask, current_context, + microtask); + } + Branch(TaggedEqual(result, TrueConstant()), dispatched, &run_inline); + BIND(&run_inline); +} + void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( TNode current_context, TNode microtask) { CSA_DCHECK(this, TaggedIsNotSmi(microtask)); @@ -130,7 +155,8 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( is_promise_fulfill_reaction_job(this), is_promise_reject_reaction_job(this), is_promise_resolve_thenable_job(this), - is_unreachable(this, Label::kDeferred), done(this); + is_unreachable(this, Label::kDeferred), done(this), + job_dispatched(this, Label::kDeferred); int32_t case_values[] = {CALLABLE_TASK_TYPE, CALLBACK_TASK_TYPE, PROMISE_FULFILL_REACTION_JOB_TASK_TYPE, @@ -225,6 +251,9 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( TNode microtask_context = LoadObjectField( microtask, PromiseReactionJobTask::kContextOffset); TNode native_context = LoadNativeContext(microtask_context); + MaybeDispatchPromiseReactionJob(current_context, microtask, + &job_dispatched, &if_exception, + &var_exception); PrepareForContext(native_context, &done); const TNode argument = @@ -237,6 +266,8 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( TNode preserved_embedder_data = LoadObjectField( microtask, PromiseReactionJobTask::kContinuationPreservedEmbedderDataOffset); + const TNode previous_embedder_data = LoadContextElement( + native_context, Context::CONTINUATION_PRESERVED_EMBEDDER_DATA_INDEX); Label preserved_data_done(this); GotoIf(IsUndefined(preserved_embedder_data), &preserved_data_done); StoreContextElement(native_context, @@ -263,7 +294,7 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( GotoIf(IsUndefined(preserved_embedder_data), &preserved_data_reset_done); StoreContextElement(native_context, Context::CONTINUATION_PRESERVED_EMBEDDER_DATA_INDEX, - UndefinedConstant()); + previous_embedder_data); Goto(&preserved_data_reset_done); BIND(&preserved_data_reset_done); @@ -278,6 +309,9 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( TNode microtask_context = LoadObjectField( microtask, PromiseReactionJobTask::kContextOffset); TNode native_context = LoadNativeContext(microtask_context); + MaybeDispatchPromiseReactionJob(current_context, microtask, + &job_dispatched, &if_exception, + &var_exception); PrepareForContext(native_context, &done); const TNode argument = @@ -290,6 +324,8 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( TNode preserved_embedder_data = LoadObjectField( microtask, PromiseReactionJobTask::kContinuationPreservedEmbedderDataOffset); + const TNode previous_embedder_data = LoadContextElement( + native_context, Context::CONTINUATION_PRESERVED_EMBEDDER_DATA_INDEX); Label preserved_data_done(this); GotoIf(IsUndefined(preserved_embedder_data), &preserved_data_done); StoreContextElement(native_context, @@ -316,7 +352,7 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( GotoIf(IsUndefined(preserved_embedder_data), &preserved_data_reset_done); StoreContextElement(native_context, Context::CONTINUATION_PRESERVED_EMBEDDER_DATA_INDEX, - UndefinedConstant()); + previous_embedder_data); Goto(&preserved_data_reset_done); BIND(&preserved_data_reset_done); @@ -328,6 +364,14 @@ void MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask( BIND(&is_unreachable); Unreachable(); + BIND(&job_dispatched); + { + // The embedder ran (or will run) the job elsewhere. + RewindEnteredContext(saved_entered_context_count); + SetCurrentContext(current_context); + Goto(&done); + } + BIND(&if_exception); { // Report unhandled exceptions from microtasks. diff --git a/deps/v8/src/runtime/runtime-promise.cc b/deps/v8/src/runtime/runtime-promise.cc index 352879a5933b..2e7a7b36f573 100644 --- a/deps/v8/src/runtime/runtime-promise.cc +++ b/deps/v8/src/runtime/runtime-promise.cc @@ -2,14 +2,17 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include "include/v8-microtask-dispatch.h" #include "src/api/api-inl.h" #include "src/debug/debug.h" #include "src/execution/arguments-inl.h" +#include "src/execution/execution.h" #include "src/execution/microtask-queue.h" #include "src/logging/counters.h" #include "src/objects/elements.h" #include "src/objects/heap-object-inl.h" #include "src/objects/js-promise-inl.h" +#include "src/objects/microtask-inl.h" #include "src/objects/objects-inl.h" #include "src/objects/oddball-inl.h" #include "src/runtime/runtime-utils.h" @@ -104,6 +107,104 @@ RUNTIME_FUNCTION(Runtime_RunMicrotaskCallback) { return ReadOnlyRoots(isolate).undefined_value(); } +// Prototype (Qualia): hand promise reaction jobs that carry continuation- +// preserved embedder data to the embedder, so it can run them on another +// stack (node-fibers). See include/v8-microtask-dispatch.h. +} // namespace internal + +struct DispatchedMicrotask { + internal::MicrotaskQueue* queue; +}; + +namespace internal { + +namespace { +v8::MicrotaskDispatchCallback g_microtask_dispatch_callback = nullptr; +void* g_microtask_dispatch_data = nullptr; +Isolate* g_microtask_dispatch_isolate = nullptr; +// Set by RunDispatchedMicrotask just before it runs its private queue, so the +// single job in that queue runs inline instead of being dispatched again. +bool g_run_next_dispatchable_inline = false; +} // namespace + +RUNTIME_FUNCTION(Runtime_DispatchMicrotask) { + HandleScope scope(isolate); + DCHECK_EQ(1, args.length()); + if (g_run_next_dispatchable_inline) { + g_run_next_dispatchable_inline = false; + return ReadOnlyRoots(isolate).false_value(); + } + if (g_microtask_dispatch_callback == nullptr || + g_microtask_dispatch_isolate != isolate) { + return ReadOnlyRoots(isolate).false_value(); + } + Handle microtask = args.at(0); + // A private single-job queue keeps the job alive (queues are GC roots) and + // lets RunDispatchedMicrotask reuse the RunMicrotasks builtin. + std::unique_ptr queue = MicrotaskQueue::New(isolate); + queue->EnqueueMicrotask(*microtask); + v8::DispatchedMicrotask* task = new v8::DispatchedMicrotask{queue.get()}; + bool taken = g_microtask_dispatch_callback( + reinterpret_cast(isolate), task, g_microtask_dispatch_data); + if (taken) { + queue.release(); // Owned (and possibly already freed) by the embedder. + } else { + delete task; + } + RETURN_FAILURE_IF_SCHEDULED_EXCEPTION(isolate); + return taken ? ReadOnlyRoots(isolate).true_value() + : ReadOnlyRoots(isolate).false_value(); +} + +} // namespace internal + +void SetMicrotaskDispatchCallback(Isolate* v8_isolate, + MicrotaskDispatchCallback callback, + void* data) { + internal::g_microtask_dispatch_callback = callback; + internal::g_microtask_dispatch_data = data; + internal::g_microtask_dispatch_isolate = + reinterpret_cast(v8_isolate); +} + +void RunDispatchedMicrotask(Isolate* v8_isolate, DispatchedMicrotask* task) { + internal::Isolate* isolate = reinterpret_cast(v8_isolate); + std::unique_ptr queue(task->queue); + delete task; + internal::HandleScope handle_scope(isolate); + internal::MaybeHandle maybe_exception; + internal::MaybeHandle maybe_result; + { + internal::HandleScopeImplementer::EnteredContextRewindScope rewind_scope( + isolate->handle_scope_implementer()); + internal::g_run_next_dispatchable_inline = true; + maybe_result = internal::Execution::TryRunMicrotasks(isolate, queue.get(), + &maybe_exception); + // Normally consumed by Runtime_DispatchMicrotask before the job starts. + internal::g_run_next_dispatchable_inline = false; + } + if (maybe_result.is_null() && maybe_exception.is_null()) { + isolate->OnTerminationDuringRunMicrotasks(); + } +} + +} // namespace v8 + +extern "C" { +void v8_qualia_SetMicrotaskDispatchCallback( + v8::Isolate* isolate, v8::MicrotaskDispatchCallback callback, void* data) { + v8::SetMicrotaskDispatchCallback(isolate, callback, data); +} + +void v8_qualia_RunDispatchedMicrotask(v8::Isolate* isolate, + v8::DispatchedMicrotask* task) { + v8::RunDispatchedMicrotask(isolate, task); +} +} + +namespace v8 { +namespace internal { + RUNTIME_FUNCTION(Runtime_PromiseStatus) { HandleScope scope(isolate); DCHECK_EQ(1, args.length()); diff --git a/deps/v8/src/runtime/runtime.h b/deps/v8/src/runtime/runtime.h index 877b277a5e26..ccdb3b851aab 100644 --- a/deps/v8/src/runtime/runtime.h +++ b/deps/v8/src/runtime/runtime.h @@ -238,6 +238,7 @@ namespace internal { F(ReThrow, 1, 1) \ F(ReThrowWithMessage, 2, 1) \ F(RunMicrotaskCallback, 2, 1) \ + F(DispatchMicrotask, 1, 1) \ F(PerformMicrotaskCheckpoint, 0, 1) \ F(SharedValueBarrierSlow, 1, 1) \ F(StackGuard, 0, 1) \ From 6212447d40e6d2e8cbcd1f3e25190c3768d14ead Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Fri, 2 Oct 2026 09:07:43 -0600 Subject: [PATCH 2/2] feat(v8): let the embedder run queued dispatchable jobs in a batch RunNextDispatchableMicrotask (and the unmangled v8_qualia_RunNextDispatchableMicrotask) takes the next job off the default microtask queue if it is a promise reaction job with CPED set, and runs it on the current stack the way RunDispatchedMicrotask does. An embedder running a dispatched job on a coroutine can use it to keep running the dispatchable jobs queued right behind it, instead of switching back to the main stack and being handed each one separately. The RunMicrotasks loop re-reads the queue's start and size before every job, so the drain it is nested in continues with the job after them. Co-Authored-By: Claude Opus 5.5 --- deps/v8/include/v8-microtask-dispatch.h | 11 +++++++ deps/v8/src/runtime/runtime-promise.cc | 39 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/deps/v8/include/v8-microtask-dispatch.h b/deps/v8/include/v8-microtask-dispatch.h index 58a6251f3e30..5557ea581a50 100644 --- a/deps/v8/include/v8-microtask-dispatch.h +++ b/deps/v8/include/v8-microtask-dispatch.h @@ -36,6 +36,16 @@ V8_EXPORT void SetMicrotaskDispatchCallback(Isolate* isolate, V8_EXPORT void RunDispatchedMicrotask(Isolate* isolate, DispatchedMicrotask* task); +// If the next job in the isolate's default microtask queue is a promise +// reaction job with non-undefined CPED, takes it off the queue and runs it on +// the current stack, as RunDispatchedMicrotask does, and returns true. +// Otherwise returns false and leaves the queue alone. Lets an embedder that is +// running a dispatched job keep running the dispatchable jobs right behind it +// without switching stacks for each one. Only call it while the job that was +// dispatched is still nested inside the microtask loop, so the order of jobs +// is unchanged. +V8_EXPORT bool RunNextDispatchableMicrotask(Isolate* isolate); + } // namespace v8 // Unmangled entry points, so an addon can find them with dlsym() and still @@ -45,6 +55,7 @@ V8_EXPORT void v8_qualia_SetMicrotaskDispatchCallback( v8::Isolate* isolate, v8::MicrotaskDispatchCallback callback, void* data); V8_EXPORT void v8_qualia_RunDispatchedMicrotask(v8::Isolate* isolate, v8::DispatchedMicrotask* task); +V8_EXPORT bool v8_qualia_RunNextDispatchableMicrotask(v8::Isolate* isolate); } #endif // INCLUDE_V8_MICROTASK_DISPATCH_H_ diff --git a/deps/v8/src/runtime/runtime-promise.cc b/deps/v8/src/runtime/runtime-promise.cc index 2e7a7b36f573..c33f1b198d61 100644 --- a/deps/v8/src/runtime/runtime-promise.cc +++ b/deps/v8/src/runtime/runtime-promise.cc @@ -13,6 +13,7 @@ #include "src/objects/heap-object-inl.h" #include "src/objects/js-promise-inl.h" #include "src/objects/microtask-inl.h" +#include "src/objects/promise-inl.h" #include "src/objects/objects-inl.h" #include "src/objects/oddball-inl.h" #include "src/runtime/runtime-utils.h" @@ -188,6 +189,40 @@ void RunDispatchedMicrotask(Isolate* v8_isolate, DispatchedMicrotask* task) { } } +bool RunNextDispatchableMicrotask(Isolate* v8_isolate) { + internal::Isolate* isolate = reinterpret_cast(v8_isolate); + internal::MicrotaskQueue* queue = isolate->default_microtask_queue(); + if (queue == nullptr || queue->size() == 0) return false; + internal::HandleScope handle_scope(isolate); + internal::Microtask front = queue->get(0); + if (!front.IsPromiseFulfillReactionJobTask() && + !front.IsPromiseRejectReactionJobTask()) { + return false; + } + if (internal::PromiseReactionJobTask::cast(front) + .continuation_preserved_embedder_data() + .IsUndefined(isolate)) { + return false; + } + internal::Handle microtask(front, isolate); + // Take it off the front of the queue the way the RunMicrotasks builtin does + // (that loop re-reads start and size before every job, so the drain this is + // nested in carries on with the job after it). + internal::Address base = reinterpret_cast(queue); + intptr_t* start = reinterpret_cast( + base + internal::MicrotaskQueue::kStartOffset); + intptr_t* size = reinterpret_cast( + base + internal::MicrotaskQueue::kSizeOffset); + *start = (*start + 1) % queue->capacity(); + *size -= 1; + std::unique_ptr private_queue = + internal::MicrotaskQueue::New(isolate); + private_queue->EnqueueMicrotask(*microtask); + RunDispatchedMicrotask(v8_isolate, + new DispatchedMicrotask{private_queue.release()}); + return true; +} + } // namespace v8 extern "C" { @@ -200,6 +235,10 @@ void v8_qualia_RunDispatchedMicrotask(v8::Isolate* isolate, v8::DispatchedMicrotask* task) { v8::RunDispatchedMicrotask(isolate, task); } + +bool v8_qualia_RunNextDispatchableMicrotask(v8::Isolate* isolate) { + return v8::RunNextDispatchableMicrotask(isolate); +} } namespace v8 {