From d9cf6ad5ec4bb3aa129450b22099e4b38682c34e Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:44:06 -0700 Subject: [PATCH] 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 --- src/node_ffi.cc | 13 ++++++++ test/ffi/test-ffi-callbacks.js | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 7581da4e7c6e..fe110eeae8ea 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -5,6 +5,7 @@ #include #include #include +#include #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 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 callback = Local::New(isolate, cb->fn); MaybeLocal result = callback->Call( diff --git a/test/ffi/test-ffi-callbacks.js b/test/ffi/test-ffi-callbacks.js index e402343bbc29..2dca67d6605c 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(); + } +});