Commit 1eb734d568c for nodejs
commit 1eb734d568c9db7278c37a37e89d44a3356cdb63
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date: Fri Oct 23 20:55:50 2026 -0700
ffi: drain task queues after event loop callbacks
When native code invokes a registered callback from the event loop,
no callback scope is active. A nextTick or microtask queued by the
callback only ran when an unrelated Node.js callback fired, or at
beforeExit.
Open an InternalCallbackScope in InvokeCallback when no scope is
active, so the queues are drained when the callback returns.
Callbacks invoked synchronously during a call into native code
already run inside the caller's scope and are left unchanged.
Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
Assisted-by: claude:opus-5.5
PR-URL: https://github.com/nodejs/node/pull/66918
Fixes: https://github.com/nodejs/node/issues/66917
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Daeyeon Jeong <daeyeon.dev@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
diff --git a/src/node_ffi.cc b/src/node_ffi.cc
index 7581da4e7c6..fe110eeae8e 100644
--- a/src/node_ffi.cc
+++ b/src/node_ffi.cc
@@ -5,6 +5,7 @@
#include <cstring>
#include <limits>
#include <memory>
+#include <optional>
#include "base_object-inl.h"
#include "env-inl.h"
#include "ffi/data.h"
@@ -13,6 +14,7 @@
#include "ffi/types.h"
#include "node_binding.h"
#include "node_errors.h"
+#include "node_internals.h"
namespace node {
@@ -772,6 +774,17 @@ void DynamicLibrary::InvokeCallback(ffi_cif* cif,
callback_args[i] = ToJSArgument(isolate, cb->args[i], args[i]);
}
+ // When native code invokes the callback from the event loop (for example
+ // through a libuv handle), no callback scope is active. Open one so that
+ // nextTick and microtasks queued by the callback are drained when it
+ // returns, as for Node.js's own callbacks. Callbacks invoked synchronously
+ // during a call into native code already run inside the caller's scope.
+ Context::Scope context_scope(context);
+ std::optional<InternalCallbackScope> callback_scope;
+ if (env->async_callback_scope_depth() == 0) {
+ callback_scope.emplace(env, Object::New(isolate), async_context{0, 0});
+ }
+
TryCatch try_catch(isolate);
Local<Function> callback = Local<Function>::New(isolate, cb->fn);
MaybeLocal<Value> result = callback->Call(
diff --git a/test/ffi/test-ffi-callbacks.js b/test/ffi/test-ffi-callbacks.js
index e402343bbc2..2dca67d6605 100644
--- a/test/ffi/test-ffi-callbacks.js
+++ b/test/ffi/test-ffi-callbacks.js
@@ -82,3 +82,63 @@ test('ffi callback ref and unref APIs work', () => {
lib.close();
}
});
+
+test('ffi callbacks drain nextTick and microtasks only from the event loop', {
+ skip: common.isWindows,
+}, async () => {
+ const { lib, functions: symbols } = getLibrary();
+ let ran = false;
+ const syncCallback = lib.registerCallback(
+ { arguments: ['i32'], return: 'i32' },
+ (value) => {
+ queueMicrotask(() => { ran = true; });
+ return value;
+ },
+ );
+
+ // The process exports libuv, so a uv timer can invoke the callback from
+ // the event loop with no JS on the stack.
+ const { lib: uvLib, functions: uv } = ffi.dlopen(null, {
+ uv_handle_size: { arguments: ['i32'], return: 'u64' },
+ uv_timer_init: { arguments: ['pointer', 'pointer'], return: 'i32' },
+ uv_timer_start: { arguments: ['pointer', 'pointer', 'u64', 'u64'], return: 'i32' },
+ uv_close: { arguments: ['pointer', 'pointer'], return: 'void' },
+ });
+ const UV_TIMER = 13;
+ const timerSize = Number(uv.uv_handle_size(UV_TIMER)) + 64;
+ const timers = [new Uint8Array(timerSize), new Uint8Array(timerSize)];
+ const { promise, resolve } = Promise.withResolvers();
+ const order = [];
+ const timerCallbacks = [
+ uvLib.registerCallback({ arguments: ['pointer'], return: 'void' }, () => {
+ order.push('callback');
+ process.nextTick(() => order.push('nextTick'));
+ queueMicrotask(() => order.push('microtask'));
+ }),
+ // The second timer runs right after the first one, with no Node.js
+ // callback in between that could drain the queues instead.
+ uvLib.registerCallback({ arguments: ['pointer'], return: 'void' }, () => {
+ resolve([...order]);
+ for (const timer of timers) uv.uv_close(timer, 0n);
+ }),
+ ];
+
+ try {
+ // A callback invoked synchronously runs inside the caller's scope, so its
+ // microtasks must not run before the native call returns.
+ assert.strictEqual(symbols.call_int_callback(syncCallback, 1), 1);
+ assert.strictEqual(ran, false);
+
+ const loop = ffi.getCurrentEventLoop();
+ for (let i = 0; i < timers.length; i++) {
+ assert.strictEqual(uv.uv_timer_init(loop, timers[i]), 0);
+ assert.strictEqual(uv.uv_timer_start(timers[i], timerCallbacks[i], 0n, 0n), 0);
+ }
+ assert.deepStrictEqual(await promise, ['callback', 'nextTick', 'microtask']);
+ // Let the uv_close() of the timer handles complete.
+ await new Promise(setImmediate);
+ } finally {
+ lib.close();
+ uvLib.close();
+ }
+});