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(); + } +});