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