current commit
261030.9 · ffi: reject arguments after callback in registerCallback
'use strict';
const common = require('../common');
common.skipIfFFIMissing();
const assert = require('node:assert');
const { test } = require('node:test');
const ffi = require('node:ffi');
const { cString, fixtureSymbols, libraryPath } = require('./ffi-test-common');

function getLibrary() {
  return ffi.dlopen(libraryPath, fixtureSymbols);
}

test('ffi callbacks can be registered and invoked', () => {
  const { lib, functions: symbols } = getLibrary();
  const seen = [];
  const intCallback = lib.registerCallback(
    { arguments: ['i32'], return: 'i32' },
    (value) => value * 2,
  );
  const stringCallback = lib.registerCallback(
    { arguments: ['pointer'], return: 'void' },
    (ptr) => seen.push(ffi.toString(ptr)),
  );
  const binaryCallback = lib.registerCallback(
    { arguments: ['i32', 'i32'], return: 'i32' },
    (a, b) => a + b,
  );

  try {
    assert.strictEqual(symbols.call_int_callback(intCallback, 21), 42);
    symbols.call_string_callback(stringCallback, cString('hello callback'));
    assert.deepStrictEqual(seen, ['hello callback']);
    assert.strictEqual(symbols.call_binary_int_callback(binaryCallback, 19, 23), 42);

    const nullPointerCallback = lib.registerCallback({ return: 'pointer' }, () => null);
    const undefinedPointerCallback = lib.registerCallback({ return: 'pointer' }, () => undefined);
    try {
      assert.strictEqual(symbols.call_pointer_callback_is_null(nullPointerCallback), 1);
      assert.strictEqual(symbols.call_pointer_callback_is_null(undefinedPointerCallback), 1);
    } finally {
      lib.unregisterCallback(nullPointerCallback);
      lib.unregisterCallback(undefinedPointerCallback);
    }
  } finally {
    lib.unregisterCallback(intCallback);
    lib.unregisterCallback(stringCallback);
    lib.unregisterCallback(binaryCallback);
    lib.close();
  }
});

test('ffi callback ref and unref APIs work', () => {
  const { lib, functions: symbols } = getLibrary();
  let called = false;
  const values = [];
  const voidCallback = lib.registerCallback(() => {
    called = true;
  });
  const countingCallback = lib.registerCallback(
    { arguments: ['i32'], return: 'i32' },
    (value) => {
      values.push(value);
      return 0;
    },
  );
  assert.throws(() => lib.registerCallback(() => {}, { arguments: ['i32'], return: 'i32' }), {
    code: 'ERR_INVALID_ARG_TYPE',
    message: 'Callback must be the last argument',
  });

  try {
    lib.unrefCallback(voidCallback);
    lib.refCallback(voidCallback);
    symbols.call_void_callback(voidCallback);
    symbols.call_callback_multiple_times(countingCallback, 5);

    assert.strictEqual(called, true);
    assert.deepStrictEqual(values, [0, 1, 2, 3, 4]);

    lib.unregisterCallback(voidCallback);
    lib.unregisterCallback(countingCallback);

    assert.throws(() => lib.refCallback(voidCallback), /Callback not found/);
    assert.throws(() => lib.unregisterCallback(-1n), /The first argument must be a non-negative bigint/);
  } finally {
    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();
  }
});