current commit
261003.15 · ffi: accept safe integer numbers for 64-bit arguments
// Flags: --expose-internals
'use strict';
const common = require('../common');
common.skipIfFFIMissing();

const assert = require('node:assert');
const { endianness } = require('node:os');
const { test } = require('node:test');

if (endianness() === 'BE') {
  common.skip('shared-buffer FFI is disabled on big-endian hosts');
}

// Capture the unpatched DynamicLibrary.prototype.getFunction BEFORE loading
// `node:ffi`, which patches it. The SB-metadata test below uses the raw
// method to inspect Symbol-keyed internals that `inheritMetadata`
// deliberately does not forward onto the wrapper.
const { internalBinding } = require('internal/test/binding');
const ffiBinding = internalBinding('ffi');
const {
  kFastArguments,
  kSbInvokeSlow,
  kSbArguments,
  kSbReturn,
  kSbSharedBuffer,
} = ffiBinding;
const rawGetFunctionUnpatched = ffiBinding.DynamicLibrary.prototype.getFunction;

const ffi = require('node:ffi');
const { libraryPath } = require('./ffi-test-common');

test('numeric-only i32 function uses SB path', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
  });
  try {
    assert.strictEqual(functions.add_i32(20, 22), 42);
    assert.strictEqual(functions.add_i32(-10, 10), 0);
    assert.strictEqual(functions.add_i32(0, 0), 0);
    assert.strictEqual(functions.add_i32(2147483647, 0), 2147483647);
  } finally {
    lib.close();
  }
});

test('i8/u8/i16/u16 round-trip', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i8: { return: 'i8', arguments: ['i8', 'i8'] },
    add_u8: { return: 'u8', arguments: ['u8', 'u8'] },
    add_i16: { return: 'i16', arguments: ['i16', 'i16'] },
    add_u16: { return: 'u16', arguments: ['u16', 'u16'] },
  });
  try {
    assert.strictEqual(functions.add_i8(10, 20), 30);
    assert.strictEqual(functions.add_u8(100, 155), 255);
    assert.strictEqual(functions.add_i16(1000, 2000), 3000);
    assert.strictEqual(functions.add_u16(30000, 35535), 65535);
  } finally {
    lib.close();
  }
});

test('f32/f64 round-trip', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_f32: { return: 'f32', arguments: ['f32', 'f32'] },
    add_f64: { return: 'f64', arguments: ['f64', 'f64'] },
  });
  try {
    // 1.25 and 2.75 are exactly representable in float32, so the sum is exact.
    assert.strictEqual(functions.add_f32(1.25, 2.75), 4.0);
    assert.strictEqual(functions.add_f64(1.5, 2.5), 4.0);
  } finally {
    lib.close();
  }
});

test('i64/u64 Number and BigInt round-trip', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i64: { return: 'i64', arguments: ['i64', 'i64'] },
    add_u64: { return: 'u64', arguments: ['u64', 'u64'] },
  });
  try {
    assert.strictEqual(functions.add_i64(10n, 20n), 30n);
    assert.strictEqual(functions.add_u64(10n, 20n), 30n);
    assert.strictEqual(functions.add_i64(10, 20), 30n);
    assert.strictEqual(functions.add_u64(10, 20), 30n);
    assert.strictEqual(functions.add_i64(-10, 20n), 10n);
    assert.strictEqual(functions.add_u64(10n, 20), 30n);
  } finally {
    lib.close();
  }
});

test('zero-arg function', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    char_is_signed: { return: 'i32', arguments: [] },
  });
  try {
    const result = functions.char_is_signed();
    assert.strictEqual(typeof result, 'number');
    assert.ok(result === 0 || result === 1);
  } finally {
    lib.close();
  }
});

test('6-arg numeric function', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    sum_6_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32'] },
  });
  try {
    assert.strictEqual(functions.sum_6_i32(1, 2, 3, 4, 5, 6), 21);
  } finally {
    lib.close();
  }
});

test('pointer args: fast path (BigInt/null) and slow-path fallback (Buffer/ArrayBuffer)', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    identity_pointer: { return: 'pointer', arguments: ['pointer'] },
    pointer_to_usize: { return: 'u64', arguments: ['pointer'] },
  });
  try {
    assert.strictEqual(functions.identity_pointer(0n), 0n);
    assert.strictEqual(functions.identity_pointer(0x1234n), 0x1234n);
    assert.strictEqual(functions.identity_pointer(null), 0n);
    assert.strictEqual(functions.identity_pointer(undefined), 0n);
    assert.strictEqual(functions.pointer_to_usize(0x42n), 0x42n);

    const buf = Buffer.from('hello');
    const bufPtr = functions.identity_pointer(buf);
    assert.strictEqual(typeof bufPtr, 'bigint');
    assert.strictEqual(bufPtr, ffi.getRawPointer(buf));

    const abPtr = functions.identity_pointer(new ArrayBuffer(16));
    assert.strictEqual(typeof abPtr, 'bigint');
    assert.ok(abPtr !== 0n);
  } finally {
    lib.close();
  }
});

test('string pointer uses shared-buffer pointer conversion', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    string_length: { return: 'u64', arguments: ['pointer'] },
    safe_strlen: { return: 'i32', arguments: ['string'] },
  });
  try {
    assert.strictEqual(functions.string_length('hello'), 5n);
    assert.strictEqual(functions.string_length(Buffer.from('world\0')), 5n);
    assert.strictEqual(functions.safe_strlen('hello'), 5);
    assert.strictEqual(functions.safe_strlen(null), -1);
  } finally {
    lib.close();
  }
});

test('non-SB-eligible signature falls back to raw function', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    string_duplicate: { return: 'pointer', arguments: ['pointer'] },
    free_string: { return: 'void', arguments: ['pointer'] },
  });
  try {
    const dup = functions.string_duplicate('round-trip');
    assert.strictEqual(typeof dup, 'bigint');
    assert.ok(dup !== 0n);
    functions.free_string(dup);
  } finally {
    lib.close();
  }
});

test('reentrancy across two FFI symbols', () => {
  // A JS callback invoked by one FFI function reenters a different FFI
  // function. Each has its own ArrayBuffer; neither may clobber the other.
  const { lib, functions } = ffi.dlopen(libraryPath, {
    call_int_callback: { return: 'i32', arguments: ['pointer', 'i32'] },
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
  });

  let callDepth = 0;
  let innerResult = -1;
  const callback = lib.registerCallback(
    { return: 'i32', arguments: ['i32'] },
    (x) => {
      callDepth++;
      if (callDepth === 1) innerResult = functions.add_i32(x, 100);
      return x * 2;
    },
  );

  try {
    const outer = functions.call_int_callback(callback, 7);
    assert.strictEqual(innerResult, 107);
    assert.strictEqual(outer, 14);
  } finally {
    lib.unregisterCallback(callback);
    lib.close();
  }
});

test('arity mismatch throws ERR_INVALID_ARG_VALUE', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
  });
  try {
    assert.throws(() => functions.add_i32(1), {
      code: 'ERR_INVALID_ARG_VALUE',
      message: /Invalid argument count: expected 2, got 1/,
    });
    assert.throws(() => functions.add_i32(1, 2, 3), {
      code: 'ERR_INVALID_ARG_VALUE',
      message: /Invalid argument count: expected 2, got 3/,
    });
  } finally {
    lib.close();
  }
});

test('arity 7+ uses the generic rest-params branch', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    sum_7_i32: {
      return: 'i32',
      arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32'],
    },
  });
  try {
    assert.strictEqual(functions.sum_7_i32(1, 2, 3, 4, 5, 6, 7), 28);
    assert.throws(
      () => functions.sum_7_i32(1, 2, 3, 4, 5, 6),
      { code: 'ERR_INVALID_ARG_VALUE', message: /expected 7, got 6/ },
    );
  } finally {
    lib.close();
  }
});

test('wrappers preserve name/length/pointer and the functions accessor returns wrappers', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
    identity_pointer: { return: 'pointer', arguments: ['pointer'] },
  });
  try {
    assert.strictEqual(functions.add_i32.name, 'add_i32');
    assert.strictEqual(typeof functions.add_i32.pointer, 'bigint');
    assert.ok(functions.add_i32.pointer !== 0n);

    assert.strictEqual(functions.identity_pointer.name, 'identity_pointer');
    assert.strictEqual(typeof functions.identity_pointer.pointer, 'bigint');
    assert.ok(functions.identity_pointer.pointer !== 0n);

    // `lib.functions.*` must also go through the SB wrapper.
    assert.strictEqual(typeof lib.functions.add_i32, 'function');
    assert.strictEqual(lib.functions.add_i32(20, 22), 42);
    assert.strictEqual(lib.functions.identity_pointer(0x1234n), 0x1234n);
  } finally {
    lib.close();
  }
});

test('integer boundaries for i8/u8/i16/u16/i32/u32', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i8: { return: 'i8', arguments: ['i8', 'i8'] },
    add_u8: { return: 'u8', arguments: ['u8', 'u8'] },
    add_i16: { return: 'i16', arguments: ['i16', 'i16'] },
    add_u16: { return: 'u16', arguments: ['u16', 'u16'] },
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
    add_u32: { return: 'u32', arguments: ['u32', 'u32'] },
  });

  try {
    assert.strictEqual(functions.add_i8(127, 0), 127);
    assert.strictEqual(functions.add_i8(-128, 0), -128);
    assert.strictEqual(functions.add_u8(255, 0), 255);
    assert.strictEqual(functions.add_u8(0, 0), 0);
    assert.strictEqual(functions.add_i16(32767, 0), 32767);
    assert.strictEqual(functions.add_i16(-32768, 0), -32768);
    assert.strictEqual(functions.add_u16(65535, 0), 65535);
    assert.strictEqual(functions.add_i32(2147483647, 0), 2147483647);
    assert.strictEqual(functions.add_i32(-2147483648, 0), -2147483648);
    assert.strictEqual(functions.add_u32(4294967295, 0), 4294967295);
    assert.strictEqual(functions.add_u32(0, 0), 0);

    const expect = { code: 'ERR_INVALID_ARG_VALUE' };
    assert.throws(() => functions.add_i8(128, 0), expect);
    assert.throws(() => functions.add_i8(-129, 0), expect);
    assert.throws(() => functions.add_u8(256, 0), expect);
    assert.throws(() => functions.add_u8(-1, 0), expect);
    assert.throws(() => functions.add_i16(32768, 0), expect);
    assert.throws(() => functions.add_i16(-32769, 0), expect);
    assert.throws(() => functions.add_u16(65536, 0), expect);
    assert.throws(() => functions.add_u16(-1, 0), expect);
    assert.throws(() => functions.add_i32(2147483648, 0), expect);
    assert.throws(() => functions.add_i32(-2147483649, 0), expect);
    assert.throws(() => functions.add_u32(4294967296, 0), expect);
    assert.throws(() => functions.add_u32(-1, 0), expect);

    assert.throws(() => functions.add_i32(1.5, 0), expect);
    assert.throws(() => functions.add_i32(NaN, 0), expect);
    assert.throws(() => functions.add_i32(Infinity, 0), expect);
    assert.throws(() => functions.add_i32('1', 0), expect);
  } finally {
    lib.close();
  }
});

test('i64/u64 Number and BigInt boundaries', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i64: { return: 'i64', arguments: ['i64', 'i64'] },
    add_u64: { return: 'u64', arguments: ['u64', 'u64'] },
  });

  try {
    const I64_MAX = (1n << 63n) - 1n;
    const I64_MIN = -(1n << 63n);
    const U64_MAX = (1n << 64n) - 1n;

    assert.strictEqual(functions.add_i64(I64_MAX, 0n), I64_MAX);
    assert.strictEqual(functions.add_i64(I64_MIN, 0n), I64_MIN);
    assert.strictEqual(functions.add_u64(U64_MAX, 0n), U64_MAX);
    assert.strictEqual(functions.add_u64(0n, 0n), 0n);
    assert.strictEqual(functions.add_i64(Number.MAX_SAFE_INTEGER, 0), BigInt(Number.MAX_SAFE_INTEGER));
    assert.strictEqual(functions.add_i64(Number.MIN_SAFE_INTEGER, 0), BigInt(Number.MIN_SAFE_INTEGER));
    assert.strictEqual(functions.add_u64(Number.MAX_SAFE_INTEGER, 0), BigInt(Number.MAX_SAFE_INTEGER));

    const expect = { code: 'ERR_INVALID_ARG_VALUE' };
    assert.throws(() => functions.add_i64(I64_MAX + 1n, 0n), expect);
    assert.throws(() => functions.add_i64(I64_MIN - 1n, 0n), expect);
    assert.throws(() => functions.add_u64(U64_MAX + 1n, 0n), expect);
    assert.throws(() => functions.add_u64(-1n, 0n), expect);

    assert.throws(() => functions.add_i64(Number.MAX_SAFE_INTEGER + 1, 0), expect);
    assert.throws(() => functions.add_i64(Number.MIN_SAFE_INTEGER - 1, 0), expect);
    assert.throws(() => functions.add_i64(1.5, 0), expect);
    assert.throws(() => functions.add_i64(Number.NaN, 0), expect);
    assert.throws(() => functions.add_i64(Number.POSITIVE_INFINITY, 0), expect);
    assert.throws(() => functions.add_u64(-1, 0), expect);
    assert.throws(() => functions.add_u64(Number.MAX_SAFE_INTEGER + 1, 0), expect);
    assert.throws(() => functions.add_u64(1.5, 0), expect);
    assert.throws(() => functions.add_u64(Number.NaN, 0), expect);
    assert.throws(() => functions.add_u64(Number.POSITIVE_INFINITY, 0), expect);
    assert.throws(() => functions.add_i64(1n, '2'), expect);
  } finally {
    lib.close();
  }
});

test('SB wrapper accepts safe Number values for i64/u64', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    identity_i64_fallback: { return: 'i64', arguments: ['pointer', 'i64', 'function'] },
    identity_u64_fallback: { return: 'u64', arguments: ['pointer', 'u64', 'function'] },
  });

  try {
    // A function argument excludes Fast API; BigInt and null pointers use SB.
    for (const value of [0, -0, 42, Number.MAX_SAFE_INTEGER, 0n, 42n]) {
      assert.strictEqual(functions.identity_i64_fallback(0n, value, null), BigInt(value));
      assert.strictEqual(functions.identity_u64_fallback(0n, value, null), BigInt(value));
    }
    for (const value of [-42, Number.MIN_SAFE_INTEGER, -(2n ** 63n), (2n ** 63n) - 1n]) {
      assert.strictEqual(functions.identity_i64_fallback(0n, value, null), BigInt(value));
    }
    assert.strictEqual(functions.identity_u64_fallback(0n, (2n ** 64n) - 1n, null),
                       (2n ** 64n) - 1n);

    const signedError = { code: 'ERR_INVALID_ARG_VALUE' };
    const unsignedError = { code: 'ERR_INVALID_ARG_VALUE' };
    for (const value of [Number.MAX_SAFE_INTEGER + 1, Number.MIN_SAFE_INTEGER - 1,
                         1.5, NaN, Infinity, -Infinity, '1', null, undefined, true, {}]) {
      assert.throws(() => functions.identity_i64_fallback(0n, value, null), signedError);
      assert.throws(() => functions.identity_u64_fallback(0n, value, null), unsignedError);
    }
    for (const value of [-(2n ** 63n) - 1n, 2n ** 63n]) {
      assert.throws(() => functions.identity_i64_fallback(0n, value, null), signedError);
    }
    for (const value of [-1, -1n, 2n ** 64n]) {
      assert.throws(() => functions.identity_u64_fallback(0n, value, null), unsignedError);
    }
  } finally {
    lib.close();
  }
});

test('generic conversion accepts safe Number values for i64/u64', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    identity_i64_fallback: { return: 'i64', arguments: ['pointer', 'i64', 'function'] },
    identity_u64_fallback: { return: 'u64', arguments: ['pointer', 'u64', 'function'] },
  });
  const buffer = Buffer.alloc(1);

  try {
    // A Buffer pointer selects generic conversion before validating i64/u64.
    for (const value of [0, -0, 42, Number.MAX_SAFE_INTEGER, 0n, 42n]) {
      assert.strictEqual(functions.identity_i64_fallback(buffer, value, null), BigInt(value));
      assert.strictEqual(functions.identity_u64_fallback(buffer, value, null), BigInt(value));
    }
    for (const value of [-42, Number.MIN_SAFE_INTEGER, -(2n ** 63n), (2n ** 63n) - 1n]) {
      assert.strictEqual(functions.identity_i64_fallback(buffer, value, null), BigInt(value));
    }
    assert.strictEqual(functions.identity_u64_fallback(buffer, (2n ** 64n) - 1n, null),
                       (2n ** 64n) - 1n);

    const signedError = { code: 'ERR_INVALID_ARG_VALUE' };
    const unsignedError = { code: 'ERR_INVALID_ARG_VALUE' };
    for (const value of [Number.MAX_SAFE_INTEGER + 1, Number.MIN_SAFE_INTEGER - 1,
                         1.5, NaN, Infinity, -Infinity, '1', null, undefined, true, {}]) {
      assert.throws(() => functions.identity_i64_fallback(buffer, value, null), signedError);
      assert.throws(() => functions.identity_u64_fallback(buffer, value, null), unsignedError);
    }
    for (const value of [-(2n ** 63n) - 1n, 2n ** 63n]) {
      assert.throws(() => functions.identity_i64_fallback(buffer, value, null), signedError);
    }
    for (const value of [-1, -1n, 2n ** 64n]) {
      assert.throws(() => functions.identity_u64_fallback(buffer, value, null), unsignedError);
    }
  } finally {
    lib.close();
  }
});

test('char type picks signed/unsigned range based on host ABI', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    char_is_signed: { return: 'i32', arguments: [] },
    identity_char: { return: 'char', arguments: ['char'] },
  });

  try {
    const isSigned = functions.char_is_signed() !== 0;
    const expect = { code: 'ERR_INVALID_ARG_VALUE' };

    assert.strictEqual(functions.identity_char(65), 65);

    if (isSigned) {
      assert.strictEqual(functions.identity_char(-128), -128);
      assert.strictEqual(functions.identity_char(127), 127);
      assert.throws(() => functions.identity_char(128), expect);
      assert.throws(() => functions.identity_char(-129), expect);
    } else {
      assert.strictEqual(functions.identity_char(255), 255);
      assert.strictEqual(functions.identity_char(0), 0);
      assert.throws(() => functions.identity_char(256), expect);
      assert.throws(() => functions.identity_char(-1), expect);
    }
  } finally {
    lib.close();
  }
});

test('SB metadata is Symbol-keyed, attribute-hardened, and not leaked onto the wrapper', () => {
  const rawLib = new ffiBinding.DynamicLibrary(libraryPath);
  try {
    const rawFn = rawGetFunctionUnpatched.call(
      rawLib, 'add_i32', { return: 'i32', arguments: ['i32', 'i32'] });

    for (const [name, sym] of [
      ['kFastArguments', kFastArguments],
      ['kSbSharedBuffer', kSbSharedBuffer],
      ['kSbInvokeSlow', kSbInvokeSlow],
      ['kSbArguments', kSbArguments],
      ['kSbReturn', kSbReturn],
    ]) {
      assert.strictEqual(typeof sym, 'symbol', `${name} must be a Symbol`);
    }

    const rawSbDesc = Object.getOwnPropertyDescriptor(rawFn, kSbSharedBuffer);
    const rawFnUsesFastFFI = rawSbDesc === undefined;

    if (rawFnUsesFastFFI) {
      // Fast-API-eligible signatures bypass the SB wrapper and therefore do not
      // carry SB metadata.
      for (const [name, sym] of [
        ['kSbSharedBuffer', kSbSharedBuffer],
        ['kSbArguments', kSbArguments],
        ['kSbReturn', kSbReturn],
        ['kSbInvokeSlow', kSbInvokeSlow],
      ]) {
        const desc = Object.getOwnPropertyDescriptor(rawFn, sym);
        assert.strictEqual(desc, undefined, `${name} present on Fast API function`);
      }
    }

    if (rawFnUsesFastFFI) {
      // Fast string signatures carry parameter metadata so the JS wrapper can
      // perform string-to-pointer conversion, but still do not carry SB state.
      const rawStringFn = rawGetFunctionUnpatched.call(
        rawLib, 'safe_strlen', { return: 'u64', arguments: ['string'] });
      const paramsDesc = Object.getOwnPropertyDescriptor(rawStringFn, kFastArguments);
      assert.ok(paramsDesc !== undefined, 'kFastArguments missing on Fast string function');
      assert.strictEqual(paramsDesc.enumerable, false);
      assert.strictEqual(paramsDesc.configurable, false);
      assert.strictEqual(paramsDesc.writable, false);
      assert.strictEqual(
        Object.getOwnPropertyDescriptor(rawStringFn, kSbArguments), undefined);
      assert.strictEqual(
        Object.getOwnPropertyDescriptor(rawStringFn, kSbSharedBuffer), undefined);
      assert.strictEqual(
        Object.getOwnPropertyDescriptor(rawStringFn, kSbReturn), undefined);
      assert.strictEqual(
        Object.getOwnPropertyDescriptor(rawStringFn, kSbInvokeSlow), undefined);
    }

    assert.deepStrictEqual(Object.keys(rawFn), ['pointer']);
    const ownSyms = Object.getOwnPropertySymbols(rawFn);
    if (rawFnUsesFastFFI) {
      assert.ok(!ownSyms.includes(kSbSharedBuffer));
      assert.ok(!ownSyms.includes(kSbArguments));
      assert.ok(!ownSyms.includes(kSbReturn));
    } else {
      assert.ok(ownSyms.includes(kSbSharedBuffer));
      assert.ok(ownSyms.includes(kSbArguments));
      assert.ok(ownSyms.includes(kSbReturn));
      assert.strictEqual(rawSbDesc.enumerable, false);
      assert.strictEqual(rawSbDesc.configurable, false);
      assert.strictEqual(rawSbDesc.writable, false);
    }

    // Internals must not be forwarded by `inheritMetadata`.
    const { lib, functions } = ffi.dlopen(libraryPath, {
      add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
    });
    try {
      assert.strictEqual(functions.add_i32[kSbSharedBuffer], undefined);
      assert.strictEqual(functions.add_i32[kSbInvokeSlow], undefined);
      assert.strictEqual(functions.add_i32[kSbArguments], undefined);
      assert.strictEqual(functions.add_i32[kSbReturn], undefined);
    } finally {
      lib.close();
    }
  } finally {
    rawLib.close();
  }
});

test('pointer fast-path range check: [0, 2^64 - 1]', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    identity_pointer: { return: 'pointer', arguments: ['pointer'] },
  });
  try {
    assert.strictEqual(functions.identity_pointer(0n), 0n);
    assert.strictEqual(functions.identity_pointer((1n << 64n) - 1n), (1n << 64n) - 1n);

    const expect = { code: 'ERR_INVALID_ARG_VALUE' };
    assert.throws(() => functions.identity_pointer(-1n), expect);
    assert.throws(() => functions.identity_pointer(1n << 64n), expect);
  } finally {
    lib.close();
  }
});

test('self-recursive reentrancy: a single function\'s ArrayBuffer survives a nested call', () => {
  // Stricter invariant than the two-symbol case: `InvokeFunctionSB` must
  // copy args out of the ArrayBuffer to stack before `ffi_call` so a recursive
  // call can reuse the same buffer without clobbering the outer frame.
  const { lib, functions } = ffi.dlopen(libraryPath, {
    call_binary_int_callback: {
      return: 'i32',
      arguments: ['function', 'i32', 'i32'],
    },
  });

  try {
    let depth = 0;
    const callback = lib.registerCallback(
      { return: 'i32', arguments: ['i32', 'i32'] },
      common.mustCall((a, b) => {
        depth++;
        if (depth === 1) {
          const inner = functions.call_binary_int_callback(callback, 100, 200);
          assert.strictEqual(inner, 300);
        }
        return a + b;
      }, 2),
    );

    try {
      assert.strictEqual(functions.call_binary_int_callback(callback, 10, 20), 30);
    } finally {
      lib.unregisterCallback(callback);
    }
  } finally {
    lib.close();
  }
});

test('void-return 0-arg wrapper branch', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    reset_counter: { return: 'void', arguments: [] },
    increment_counter: { return: 'void', arguments: [] },
    get_counter: { return: 'i32', arguments: [] },
  });
  try {
    assert.strictEqual(functions.reset_counter(), undefined);
    assert.strictEqual(functions.get_counter(), 0);

    functions.increment_counter();
    functions.increment_counter();
    functions.increment_counter();
    assert.strictEqual(functions.get_counter(), 3);

    assert.strictEqual(functions.reset_counter(), undefined);
    assert.strictEqual(functions.get_counter(), 0);
  } finally {
    lib.close();
  }
});

test('void-return wrapper at every specialized arity observes side effects', () => {
  // The arity ladder has a separate void-return closure for each arity.
  // A wiring bug in a mid-arity void specialization would not be caught
  // by the 0-arg void test above, so exercise the side effects directly
  // at every arity the ladder specializes (1..6) plus the 7+ rest-params
  // fallback.
  const { lib, functions } = ffi.dlopen(libraryPath, {
    store_i32: { return: 'void', arguments: ['i32'] },
    store_sum_2_i32: { return: 'void', arguments: ['i32', 'i32'] },
    store_sum_3_i32: { return: 'void', arguments: ['i32', 'i32', 'i32'] },
    store_sum_4_i32: {
      return: 'void',
      arguments: ['i32', 'i32', 'i32', 'i32'],
    },
    store_sum_5_i32: {
      return: 'void',
      arguments: ['i32', 'i32', 'i32', 'i32', 'i32'],
    },
    store_sum_6_i32: {
      return: 'void',
      arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32'],
    },
    store_sum_8_i32: {
      return: 'void',
      arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32'],
    },
    get_scratch: { return: 'i32', arguments: [] },
  });
  try {
    // Powers-of-two summands detect a dropped or duplicated slot at each
    // arity.
    assert.strictEqual(functions.store_i32(7), undefined);
    assert.strictEqual(functions.get_scratch(), 7);

    assert.strictEqual(functions.store_sum_2_i32(10, 32), undefined);
    assert.strictEqual(functions.get_scratch(), 42);

    assert.strictEqual(functions.store_sum_3_i32(1, 2, 4), undefined);
    assert.strictEqual(functions.get_scratch(), 7);

    assert.strictEqual(functions.store_sum_4_i32(1, 2, 4, 8), undefined);
    assert.strictEqual(functions.get_scratch(), 15);

    assert.strictEqual(functions.store_sum_5_i32(1, 2, 4, 8, 16), undefined);
    assert.strictEqual(functions.get_scratch(), 31);

    assert.strictEqual(
      functions.store_sum_6_i32(1, 2, 4, 8, 16, 32), undefined);
    assert.strictEqual(functions.get_scratch(), 63);

    // 7+ args takes the generic rest-params void branch rather than a
    // per-arity specialization.
    assert.strictEqual(
      functions.store_sum_8_i32(1, 2, 4, 8, 16, 32, 64, 128), undefined);
    assert.strictEqual(functions.get_scratch(), 255);

    // Validation still runs on every void-return branch, including the
    // rest-params fallback.
    assert.throws(
      () => functions.store_i32(1.5),
      { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(
      () => functions.store_sum_2_i32(1.5, 2),
      { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(
      () => functions.store_sum_3_i32(1, 1.5, 3),
      { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(
      () => functions.store_sum_4_i32(1, 2, 1.5, 4),
      { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(
      () => functions.store_sum_5_i32(1, 2, 3, 1.5, 5),
      { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(
      () => functions.store_sum_6_i32(1, 2, 3, 4, 5),
      { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(
      () => functions.store_sum_8_i32(1, 2, 3, 4, 5, 6, 7, 1.5),
      { code: 'ERR_INVALID_ARG_VALUE' });

    // Wrong arity hits the `throwFFIArgCountError` branch inside each
    // specialization (1..6 and the 7+ rest-params fallback).
    for (const [name, expected, badArgs] of [
      ['store_i32', 1, []],
      ['store_sum_2_i32', 2, [1]],
      ['store_sum_3_i32', 3, [1, 2]],
      ['store_sum_4_i32', 4, [1, 2, 3]],
      ['store_sum_5_i32', 5, [1, 2, 3, 4]],
      ['store_sum_6_i32', 6, [1, 2, 3, 4, 5]],
      ['store_sum_8_i32', 8, [1, 2, 3, 4, 5, 6, 7]],
    ]) {
      assert.throws(
        () => functions[name](...badArgs),
        {
          code: 'ERR_INVALID_ARG_VALUE',
          message: new RegExp(`expected ${expected}, got ${badArgs.length}`),
        });
    }
  } finally {
    lib.close();
  }
});

test('value-return wrapper arity mismatch hits every specialized branch', () => {
  // `sum_7_i32` already exercises the 7+ rest-params branch elsewhere;
  // this test targets the per-arity `throwFFIArgCountError` call in the
  // value-return closures for arities 1..6 so each specialization's
  // argument-count guard runs at least once.
  const { lib, functions } = ffi.dlopen(libraryPath, {
    logical_not: { return: 'i32', arguments: ['i32'] },
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
    sum_3_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32'] },
    sum_4_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32', 'i32'] },
    sum_five_i32: {
      return: 'i32',
      arguments: ['i32', 'i32', 'i32', 'i32', 'i32'],
    },
    sum_6_i32: {
      return: 'i32',
      arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32'],
    },
  });
  try {
    for (const [name, expected, badArgs] of [
      ['logical_not', 1, []],
      ['add_i32', 2, [1]],
      ['sum_3_i32', 3, [1, 2]],
      ['sum_4_i32', 4, [1, 2, 3]],
      ['sum_five_i32', 5, [1, 2, 3, 4]],
      ['sum_6_i32', 6, [1, 2, 3, 4, 5]],
    ]) {
      assert.throws(
        () => functions[name](...badArgs),
        {
          code: 'ERR_INVALID_ARG_VALUE',
          message: new RegExp(`expected ${expected}, got ${badArgs.length}`),
        });
    }

    // Sanity-check that a correct call still returns a value at each
    // arity — a bug that swallowed the return on the value-return path
    // would be caught here.
    assert.strictEqual(functions.logical_not(0), 1);
    assert.strictEqual(functions.add_i32(1, 2), 3);
    assert.strictEqual(functions.sum_3_i32(1, 2, 4), 7);
    assert.strictEqual(functions.sum_4_i32(1, 2, 4, 8), 15);
    assert.strictEqual(functions.sum_five_i32(1, 2, 4, 8, 16), 31);
    assert.strictEqual(functions.sum_6_i32(1, 2, 4, 8, 16, 32), 63);
  } finally {
    lib.close();
  }
});

test('pointer-dispatch wrapper rejects wrong-arity calls', () => {
  // Pointer signatures share a single rest-params wrapper rather than the
  // per-arity ladder, but it still has its own `throwFFIArgCountError`
  // branch that needs to be exercised.
  const { lib, functions } = ffi.dlopen(libraryPath, {
    identity_pointer: { return: 'pointer', arguments: ['pointer'] },
  });
  try {
    assert.throws(
      () => functions.identity_pointer(),
      {
        code: 'ERR_INVALID_ARG_VALUE',
        message: /expected 1, got 0/,
      });
    assert.throws(
      () => functions.identity_pointer(0n, 0n),
      {
        code: 'ERR_INVALID_ARG_VALUE',
        message: /expected 1, got 2/,
      });
  } finally {
    lib.close();
  }
});

test('mid-arity wrappers (1, 3, 4, 5)', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    logical_not: { return: 'i32', arguments: ['i32'] },
    sum_3_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32'] },
    sum_4_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32', 'i32'] },
    sum_five_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32', 'i32', 'i32'] },
  });
  try {
    assert.strictEqual(functions.logical_not(0), 1);
    assert.strictEqual(functions.logical_not(42), 0);

    // Powers-of-two summands: a dropped or duplicated slot would change the total.
    assert.strictEqual(functions.sum_3_i32(1, 2, 4), 7);
    assert.strictEqual(functions.sum_4_i32(1, 2, 4, 8), 15);
    assert.strictEqual(functions.sum_five_i32(1, 2, 4, 8, 16), 31);
  } finally {
    lib.close();
  }
});

test('float specials: NaN, ±Infinity, -0 round-trip bit-exact', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_f64: { return: 'f64', arguments: ['f64', 'f64'] },
    multiply_f64: { return: 'f64', arguments: ['f64', 'f64'] },
  });
  try {
    assert.ok(Number.isNaN(functions.add_f64(NaN, 1.0)));
    assert.strictEqual(functions.add_f64(Infinity, 1.0), Infinity);
    assert.strictEqual(functions.add_f64(-Infinity, 1.0), -Infinity);
    assert.ok(Object.is(functions.multiply_f64(-0, 1.0), -0));
  } finally {
    lib.close();
  }
});

test('arity-7+ branch still runs per-arg validation', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    sum_7_i32: { return: 'i32', arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32'] },
  });
  try {
    assert.throws(
      () => functions.sum_7_i32(1, 2, 3, 1.5, 5, 6, 7),
      { code: 'ERR_INVALID_ARG_VALUE' },
    );
  } finally {
    lib.close();
  }
});

test('mixed-kind signature (i32, f32, f64, u32) dispatches the right writer per slot', () => {
  // Four distinct `sbTypeInfo.kind` values (int, float, float, int) — a
  // wiring bug that reused one writer across slots would surface here.
  const { lib, functions } = ffi.dlopen(libraryPath, {
    mixed_operation: { arguments: ['i32', 'f32', 'f64', 'u32'], return: 'f64' },
  });

  try {
    assert.strictEqual(functions.mixed_operation(10, 2.5, 3.5, 4), 20);
    assert.strictEqual(functions.mixed_operation(-1, 0.25, 0.75, 0), 0);

    const expect = { code: 'ERR_INVALID_ARG_VALUE' };
    // -1 on u32 slot: distinguishes u32 writer from i32 (i32 accepts -1).
    assert.throws(() => functions.mixed_operation(0, 0.0, 0.0, -1), expect);
    // 2^31 on i32 slot: distinguishes i32 writer from u32 (u32 accepts it).
    assert.throws(() => functions.mixed_operation(2147483648, 0.0, 0.0, 0), expect);
    // Float slots reject BigInt / string (the int/float writers both gate on `typeof`).
    assert.throws(() => functions.mixed_operation(0, 1n, 0.0, 0), expect);
    assert.throws(() => functions.mixed_operation(0, 0.0, 'x', 0), expect);
  } finally {
    lib.close();
  }
});

test('lib.getFunctions() with no arguments wraps every cached function', () => {
  // Regression: the no-args branch previously returned raw native functions
  // whose shared buffer was uninitialized, producing garbage numeric results.
  // Mix SB-eligible signatures with one that is not (`string_length` takes a
  // string, which bypasses the fast path) so the no-args branch has to walk
  // the early-return path in `wrapWithSharedBuffer` alongside the wrapped
  // branch.
  const { lib } = ffi.dlopen(libraryPath, {
    add_i32: { return: 'i32', arguments: ['i32', 'i32'] },
    add_f64: { return: 'f64', arguments: ['f64', 'f64'] },
    mixed_operation: { arguments: ['i32', 'f32', 'f64', 'u32'], return: 'f64' },
    identity_pointer: { return: 'pointer', arguments: ['pointer'] },
    string_length: { return: 'u64', arguments: ['string'] },
  });

  try {
    const all = lib.getFunctions();
    assert.strictEqual(Object.getPrototypeOf(all), null);

    // SB-eligible entries go through the shared-buffer wrapper.
    assert.strictEqual(all.add_i32(20, 22), 42);
    assert.strictEqual(all.add_f64(1.5, 2.5), 4.0);
    assert.strictEqual(all.identity_pointer(0x42n), 0x42n);

    // Non-eligible entry returns its raw native wrapper unchanged; it still
    // has to be callable from the object returned by `getFunctions()`.
    assert.strictEqual(all.string_length('hello'), 5n);

    assert.deepStrictEqual(
      Object.keys(all).sort(),
      ['add_f64', 'add_i32', 'identity_pointer',
       'mixed_operation', 'string_length']);

    assert.throws(() => all.add_i32(1), { code: 'ERR_INVALID_ARG_VALUE' });
    assert.throws(() => all.add_i32(1.5, 0), { code: 'ERR_INVALID_ARG_VALUE' });

    assert.strictEqual(typeof all.add_i32.pointer, 'bigint');
    assert.ok(all.add_i32.pointer !== 0n);

    // The wrapper object is no longer frozen; nothing in the SB design
    // requires it.
    assert.ok(!Object.isFrozen(all));
  } finally {
    lib.close();
  }
});

test('mixed pointer + numeric signature uses the pointer-dispatch wrapper', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    call_int_callback: { return: 'i32', arguments: ['pointer', 'i32'] },
  });

  try {
    const cb = lib.registerCallback(
      { return: 'i32', arguments: ['i32'] },
      (x) => x * 2,
    );
    try {
      assert.strictEqual(functions.call_int_callback(cb, 7), 14);
      // Negative i32 must land in the numeric writer (not the pointer writer,
      // which would reject a negative BigInt).
      assert.strictEqual(functions.call_int_callback(cb, -5), -10);
    } finally {
      lib.unregisterCallback(cb);
    }
  } finally {
    lib.close();
  }
});