Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/node_ffi.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -13,6 +14,7 @@
#include "ffi/types.h"
#include "node_binding.h"
#include "node_errors.h"
#include "node_internals.h"

namespace node {

Expand Down Expand Up @@ -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(
Expand Down
60 changes: 60 additions & 0 deletions test/ffi/test-ffi-callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
Loading