diff --git a/BUILDING.md b/BUILDING.md
index 6fc93ca2ad0e..4dedb7a4c3b2 100644
--- a/BUILDING.md
+++ b/BUILDING.md
@@ -98,6 +98,12 @@ and libc version. The table below lists the support tier for each supported
combination. A list of [supported compile toolchains](#supported-toolchains) is
also supplied for tier 1 platforms.
+Some built-in functionality requires WebAssembly or runtime allocation of
+executable memory. When a capability is unavailable, Node.js does not guarantee
+an alternative implementation of the affected feature. See the
+[`--jitless` documentation](doc/api/cli.md#--jitless) and
+[FFI documentation](doc/api/ffi.md) for the corresponding limitations and errors.
+
**For production applications, run Node.js on supported platforms only (Tier 1 or 2).**
Node.js does not support a platform version if a vendor has expired support
diff --git a/doc/api/cli.md b/doc/api/cli.md
index 4e17d81169db..0f3fdae88eec 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -2209,6 +2209,16 @@ Disable [runtime allocation of executable memory][jitless]. This may be
required on some platforms for security reasons. It can also reduce attack
surface on other platforms, but the performance impact may be severe.
+When WebAssembly is unavailable in this mode, TypeScript parsing and WebAssembly
+module imports throw [`ERR_WEBASSEMBLY_NOT_SUPPORTED`][]. Node.js does not
+guarantee alternative implementations of features that require WebAssembly or
+runtime allocation of executable memory when those capabilities are unavailable.
+
+This flag controls V8's runtime code generation. It does not impose an
+operating-system restriction on executable memory allocated by native addons or
+[`node:ffi`][]. FFI operations that require unavailable executable memory can
+throw [`ERR_RX_MEMORY_NOT_SUPPORTED`][].
+
### `--localstorage-file=file`
+
+A feature requiring runtime allocation of executable memory was used in an
+environment where that capability is unavailable. This includes creating an
+otherwise eligible FFI Fast API function, or allocating an FFI callback when
+libffi cannot provide a closure and executable memory is unavailable.
+
+Node.js does not guarantee a non-generated-code fallback for these operations.
+This error can be caught without terminating the process.
+
### `ERR_SCRIPT_EXECUTION_INTERRUPTED`
@@ -3653,6 +3669,11 @@ A feature requiring WebAssembly was used, but WebAssembly is not supported or
has been disabled in the current environment (for example, when running with
`--jitless`).
+TypeScript parsing and WebAssembly module imports, including source phase
+imports, report this error when WebAssembly is unavailable. Node.js does not
+provide an alternative implementation for these operations. This error can be
+caught without terminating the process.
+
### `ERR_WEBASSEMBLY_RESPONSE`
diff --git a/doc/api/esm.md b/doc/api/esm.md
index 7bee67774583..ef15a51dcfe5 100644
--- a/doc/api/esm.md
+++ b/doc/api/esm.md
@@ -745,6 +745,11 @@ imports is supported.
Both of these integrations are in line with the
[ES Module Integration Proposal for WebAssembly][].
+When WebAssembly is unavailable in the current environment, importing a Wasm
+module throws [`ERR_WEBASSEMBLY_NOT_SUPPORTED`](errors.md#err_webassembly_not_supported).
+This also applies to source phase imports. Node.js does not provide an
+alternative implementation when WebAssembly is unavailable.
+
### Wasm Source Phase Imports
> Stability: 1.2 - Release candidate
diff --git a/doc/api/ffi.md b/doc/api/ffi.md
index ee56074a682c..d3c01f6e0529 100644
--- a/doc/api/ffi.md
+++ b/doc/api/ffi.md
@@ -45,6 +45,13 @@ The following targets are not supported by bundled libffi:
When using the [Permission Model][], FFI APIs are
restricted unless the [`--allow-ffi`][] flag is provided.
+Some FFI operations require runtime allocation of executable memory. If an
+otherwise eligible Fast API function cannot be created because executable memory
+is unavailable, creation throws
+[`ERR_RX_MEMORY_NOT_SUPPORTED`](errors.md#err_rx_memory_not_supported) rather than
+selecting another call path. Callback allocation can also report this error.
+Loading the module or a library does not itself require generated trampolines.
+
## Overview
The `node:ffi` module exposes two groups of APIs:
@@ -465,6 +472,11 @@ Returns an object containing all previously resolved symbol addresses.
Creates a native callback pointer backed by a JavaScript function.
+If libffi cannot allocate a closure and executable memory is unavailable, this
+method throws [`ERR_RX_MEMORY_NOT_SUPPORTED`](errors.md#err_rx_memory_not_supported).
+Other closure allocation failures throw `ERR_FFI_CALL_FAILED`. Platform-specific
+libffi implementations may provide callbacks without dynamically generated code.
+
When `signature` is omitted, the callback uses a default `void ()` signature.
The return value is the callback pointer address as a `bigint`. It can be
@@ -640,7 +652,8 @@ met:
PPC64 always use another call path.
* The process can allocate executable memory. Node.js checks once per process
whether it can allocate memory and mark it executable. If that check fails,
- this path is disabled for the entire process.
+ creating an otherwise eligible function throws
+ [`ERR_RX_MEMORY_NOT_SUPPORTED`](errors.md#err_rx_memory_not_supported).
* Neither the return type nor any argument type is `function`.
* The signature has at most 8 arguments, and every argument fits in the
argument registers available to the trampoline on the current platform.
@@ -650,8 +663,10 @@ The register limits are platform-specific. Integer and pointer-like arguments
share one set of registers, and floating-point arguments share another. The
limits for each architecture are listed in [Type names][].
-A signature that fails any of these checks is not an error. The function is
-created on the next call path that supports it.
+A signature that is unsupported by the Fast API types, argument limits, or
+platform is not an error. The function is created on the next call path that
+supports it. Missing executable-memory support is checked only after signature
+and platform eligibility and does not select another call path.
### Shared buffer call path
diff --git a/doc/api/typescript.md b/doc/api/typescript.md
index 3959fd58b56c..701280375ba5 100644
--- a/doc/api/typescript.md
+++ b/doc/api/typescript.md
@@ -82,6 +82,11 @@ Node.js will replace TypeScript syntax with whitespace,
and no type checking is performed.
To disable this feature, use the flag [`--no-strip-types`][].
+The built-in TypeScript parser requires WebAssembly. If WebAssembly is
+unavailable, parsing throws
+[`ERR_WEBASSEMBLY_NOT_SUPPORTED`](errors.md#err_webassembly_not_supported).
+Node.js does not provide a non-WebAssembly parser as a fallback.
+
Node.js ignores `tsconfig.json` files and therefore
features that depend on settings within `tsconfig.json`,
such as paths or converting newer JavaScript syntax to older standards, are
diff --git a/doc/node.1 b/doc/node.1
index 70488dff26ca..fbfca02c6461 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -1179,6 +1179,14 @@ Opens the REPL even if stdin does not appear to be a terminal.
Disable runtime allocation of executable memory. This may be
required on some platforms for security reasons. It can also reduce attack
surface on other platforms, but the performance impact may be severe.
+When WebAssembly is unavailable in this mode, TypeScript parsing and WebAssembly
+module imports throw \fBERR_WEBASSEMBLY_NOT_SUPPORTED\fR. Node.js does not
+guarantee alternative implementations of features that require WebAssembly or
+runtime allocation of executable memory when those capabilities are unavailable.
+This flag controls V8's runtime code generation. It does not impose an
+operating-system restriction on executable memory allocated by native addons or
+\fBnode:ffi\fR. FFI operations that require unavailable executable memory can
+throw \fBERR_RX_MEMORY_NOT_SUPPORTED\fR.
.
.It Fl -localstorage-file Ns = Ns Ar file
The file used to store \fBlocalStorage\fR data. If the file does not exist, it is
diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js
index 84ff29f08039..8b90849143d8 100644
--- a/lib/internal/modules/esm/translators.js
+++ b/lib/internal/modules/esm/translators.js
@@ -54,6 +54,7 @@ const { emitExperimentalWarning, kEmptyObject, setOwnProperty, isWindows } = req
const {
ERR_INVALID_RETURN_PROPERTY_VALUE,
ERR_UNKNOWN_BUILTIN_MODULE,
+ ERR_WEBASSEMBLY_NOT_SUPPORTED,
} = require('internal/errors').codes;
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
const moduleWrap = internalBinding('module_wrap');
@@ -558,6 +559,9 @@ translators.set('wasm', function(url, translateContext) {
const { source } = translateContext;
// WebAssembly global is not available during snapshot building, so we need to get it lazily.
const { WebAssembly } = globalThis;
+ if (WebAssembly === undefined) {
+ throw new ERR_WEBASSEMBLY_NOT_SUPPORTED('WebAssembly module imports');
+ }
assertBufferSource(source, false, 'load');
debug(`Translating WASMModule ${url}`, translateContext);
diff --git a/src/ffi/fast.cc b/src/ffi/fast.cc
index 24809e744a20..4f6606d50251 100644
--- a/src/ffi/fast.cc
+++ b/src/ffi/fast.cc
@@ -330,37 +330,29 @@ bool IsFastLibraryGuardSupported() {
#endif
}
-std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn,
- const bool* closed,
- v8::Isolate* isolate) {
- // Bail early if executable memory allocation doesn't work on this process
- // (missing MAP_JIT entitlement, hardened runtime, SELinux execmem, etc.).
- // The self-test runs once and caches the result.
- if (!IsJitMemorySupported()) {
- return nullptr;
- }
-
+v8::Maybe> CreateFastFFIMetadata(
+ const FFIFunction& fn, const bool* closed, v8::Isolate* isolate) {
// Check signature-level eligibility (type checks, register caps, platform
- // support). Returning nullptr here lets the caller fall back to SharedBuffer
+ // support). Null metadata lets the caller fall back to SharedBuffer
// or the generic libffi path.
const char* eligibility_reason;
if (!IsFastCallEligible(fn, &eligibility_reason)) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
- // Reject unsupported result types first. Returning nullptr means the caller
+ // Reject unsupported result types first. Null metadata means the caller
// can still fall back to SharedBuffer or the generic libffi path.
FastFFIType result;
if (!FastScalarTypeFromName(fn.return_type_name, &result)) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
if (fn.args.size() != fn.arg_type_names.size()) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
// Keep the initial Fast API implementation bounded to signatures V8 and the
// platform trampolines can describe without stack argument support.
if (fn.arg_type_names.size() > 8) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
std::vector args;
@@ -373,10 +365,10 @@ std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn,
for (const std::string& name : fn.arg_type_names) {
FastFFIType type;
if (!FastArgTypeFromName(name, &type)) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
if (type == FastFFIType::kVoid) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
needs_bigint = needs_bigint || NeedsBigIntRepresentation(type);
needs_callback_options =
@@ -384,6 +376,16 @@ std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn,
args.push_back(type);
}
+ // Check RX memory only after signature and platform eligibility, so ordinary
+ // unsupported signatures can still use the non-generated invocation paths.
+ if (!IsJitMemorySupported()) {
+ THROW_ERR_RX_MEMORY_NOT_SUPPORTED(
+ isolate,
+ "Executable memory is not supported in this environment, "
+ "but is required for FFI Fast API calls");
+ return v8::Nothing>();
+ }
+
auto metadata = std::make_unique();
// The platform-specific trampoline is the executable entrypoint V8 calls.
// If the platform rejects the signature, the whole fast metadata object is
@@ -391,7 +393,7 @@ std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn,
FastFFITrampolineConfig config{fn.ptr, closed, isolate};
if (!node_ffi_create_fast_trampoline(
config, args.data(), args.size(), result, &metadata->trampoline)) {
- return nullptr;
+ return v8::Just(std::unique_ptr());
}
metadata->arg_info.reserve(args.size() + 1);
@@ -418,7 +420,7 @@ std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn,
metadata->c_function =
v8::CFunction(metadata->trampoline.code, metadata->c_function_info.get());
metadata->guards_library = guards_library;
- return metadata;
+ return v8::Just(std::move(metadata));
}
} // namespace node::ffi
diff --git a/src/ffi/fast.h b/src/ffi/fast.h
index 7aced6a7cc54..d7c8cbadb203 100644
--- a/src/ffi/fast.h
+++ b/src/ffi/fast.h
@@ -73,9 +73,10 @@ std::shared_ptr CloneWithRawPointerArgNames(
const std::shared_ptr& fn);
std::shared_ptr CloneWithFastBufferArgNames(
const std::shared_ptr& fn);
-std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn,
- const bool* closed,
- v8::Isolate* isolate);
+// A null metadata value allows signature/platform fallback. Nothing means an
+// exception was thrown because an eligible signature requires RX memory.
+v8::Maybe> CreateFastFFIMetadata(
+ const FFIFunction& fn, const bool* closed, v8::Isolate* isolate);
} // namespace node::ffi
diff --git a/src/ffi/jit_memory.cc b/src/ffi/jit_memory.cc
index 023b55757691..e6dea459c62d 100644
--- a/src/ffi/jit_memory.cc
+++ b/src/ffi/jit_memory.cc
@@ -23,13 +23,6 @@ namespace node::ffi {
namespace {
bool SelfTest() {
-#if !defined(__aarch64__) && !defined(_M_ARM64) && !defined(__x86_64__) && \
- !defined(_M_X64) && !defined(__powerpc64__) && !defined(__ppc64__) && \
- !defined(__PPC64__) && !defined(__loongarch64) && \
- !(defined(__riscv) && __riscv_xlen == 64) && !defined(__s390x__)
- // No stub emitter for this platform; nothing to test.
- return false;
-#else
#if defined(__aarch64__) || defined(_M_ARM64)
// AArch64 BR LR: 0xD65F03C0
constexpr uint32_t kInstruction = 0xD65F03C0;
@@ -51,7 +44,8 @@ bool SelfTest() {
constexpr uint16_t kInstruction = 0x07fe;
constexpr size_t kInstructionSize = sizeof(uint16_t);
#else
- // x86_64 RET: 0xC3
+ // The probe is never executed, so this byte also works on platforms without
+ // a Fast API stub emitter. On x86_64 it represents RET.
constexpr uint8_t kInstruction = 0xC3;
constexpr size_t kInstructionSize = sizeof(uint8_t);
#endif
@@ -97,7 +91,7 @@ bool SelfTest() {
defined(__ppc64__) || defined(__PPC64__) || defined(__loongarch64) || \
(defined(__riscv) && __riscv_xlen == 64) || defined(__s390x__)
std::memcpy(code, &kInstruction, kInstructionSize);
-#elif defined(__x86_64__)
+#else
code[0] = kInstruction;
#endif
@@ -129,7 +123,6 @@ bool SelfTest() {
munmap(page, page_size);
return ok;
#endif
-#endif
}
} // namespace
diff --git a/src/ffi/jit_memory.h b/src/ffi/jit_memory.h
index 259c1c46cc72..f5ee3d4315e2 100644
--- a/src/ffi/jit_memory.h
+++ b/src/ffi/jit_memory.h
@@ -4,9 +4,10 @@
namespace node::ffi {
-// Returns true if executable memory allocation (mmap + mprotect to RX) works
-// on this process. Runs a one-time self-test that allocates a tiny stub,
-// writes a ret-style instruction, and transitions it to RX. The page is not
+// Returns true if executable memory allocation (mmap + mprotect to RX, or the
+// corresponding Windows APIs) works in this process. Runs a one-time self-test
+// that allocates a tiny stub,
+// writes probe bytes, and transitions it to RX. The page is not
// executed: a successful RX transition is the support signal, and executing a
// freshly generated probe could crash the process on systems that block it.
//
@@ -19,6 +20,7 @@ namespace node::ffi {
// The self-test runs exactly once (std::call_once) and the result is cached
// process-wide. Subsequent calls return the cached value without re-running
// the test.
+// This query does not depend on Fast API stub-emitter support or --jitless.
bool IsJitMemorySupported();
} // namespace node::ffi
diff --git a/src/node_errors.h b/src/node_errors.h
index 6e29fe8584ee..8fcea26f6b67 100644
--- a/src/node_errors.h
+++ b/src/node_errors.h
@@ -125,6 +125,7 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details);
V(ERR_OPERATION_FAILED, TypeError) \
V(ERR_OPTIONS_BEFORE_BOOTSTRAPPING, Error) \
V(ERR_OUT_OF_RANGE, RangeError) \
+ V(ERR_RX_MEMORY_NOT_SUPPORTED, Error) \
V(ERR_SCRIPT_EXECUTION_INTERRUPTED, Error) \
V(ERR_SCRIPT_EXECUTION_TIMEOUT, Error) \
V(ERR_SOURCE_PHASE_NOT_DEFINED, SyntaxError) \
diff --git a/src/node_ffi.cc b/src/node_ffi.cc
index 2688bd28eba1..7581da4e7c6e 100644
--- a/src/node_ffi.cc
+++ b/src/node_ffi.cc
@@ -9,6 +9,7 @@
#include "env-inl.h"
#include "ffi/data.h"
#include "ffi/fast.h"
+#include "ffi/jit_memory.h"
#include "ffi/types.h"
#include "node_binding.h"
#include "node_errors.h"
@@ -290,7 +291,10 @@ MaybeLocal DynamicLibrary::CreateFunction(
// signature, fall back to SharedBuffer for supported scalar shapes, then to
// the generic libffi invoker.
std::shared_ptr fast_fn = CloneWithRawPointerArgNames(fn);
- info->fast_metadata = CreateFastFFIMetadata(*fast_fn, &fn->closed, isolate);
+ if (!CreateFastFFIMetadata(*fast_fn, &fn->closed, isolate)
+ .MoveTo(&info->fast_metadata)) {
+ return {};
+ }
bool use_fast_api = info->fast_metadata != nullptr;
bool use_sb = !use_fast_api && IsSBEligibleSignature(*fn);
bool has_ptr_args = use_sb && SignatureHasPointerArgs(*fn);
@@ -456,8 +460,10 @@ MaybeLocal DynamicLibrary::CreateFunction(
// argument is Buffer/ArrayBuffer-backed memory.
std::shared_ptr fast_buffer_fn =
CloneWithFastBufferArgNames(fn);
- info->fast_buffer_metadata =
- CreateFastFFIMetadata(*fast_buffer_fn, &fn->closed, isolate);
+ if (!CreateFastFFIMetadata(*fast_buffer_fn, &fn->closed, isolate)
+ .MoveTo(&info->fast_buffer_metadata)) {
+ return {};
+ }
if (info->fast_buffer_metadata != nullptr) {
// Store the secondary invoker on the primary raw function under a hidden
// Symbol. Keeping it separate avoids overloading SharedBuffer slow-path
@@ -1108,6 +1114,15 @@ void DynamicLibrary::RegisterCallback(const FunctionCallbackInfo& args) {
ffi_closure_alloc(sizeof(ffi_closure), &callback->ptr));
if (callback->closure == nullptr) {
+ // libffi may use platform-specific static trampolines, so only diagnose
+ // missing RX memory after its own closure allocation has failed.
+ if (!IsJitMemorySupported()) {
+ THROW_ERR_RX_MEMORY_NOT_SUPPORTED(
+ env,
+ "Executable memory is not supported in this environment, "
+ "but is required for FFI callbacks");
+ return;
+ }
THROW_ERR_FFI_CALL_FAILED(env, "ffi_closure_alloc failed");
return;
}
diff --git a/test/es-module/test-esm-wasm-jitless.mjs b/test/es-module/test-esm-wasm-jitless.mjs
new file mode 100644
index 000000000000..127662845807
--- /dev/null
+++ b/test/es-module/test-esm-wasm-jitless.mjs
@@ -0,0 +1,22 @@
+// Flags: --jitless
+import '../common/index.mjs';
+import assert from 'node:assert/strict';
+import * as fixtures from '../common/fixtures.mjs';
+
+const url = fixtures.fileURL('es-modules/simple.wasm').href;
+const error = {
+ code: 'ERR_WEBASSEMBLY_NOT_SUPPORTED',
+ message: 'WebAssembly is not supported in this environment, but is required for WebAssembly module imports',
+};
+
+await assert.rejects(import(url), error);
+await assert.rejects(import.source(url), error);
+for (const statement of [
+ `import * as mod from ${JSON.stringify(url)};`,
+ `import source mod from ${JSON.stringify(url)};`,
+]) {
+ await assert.rejects(import(`data:text/javascript,${encodeURIComponent(statement)}`), error);
+}
+
+// Catching the missing capability must leave ordinary module imports usable.
+assert.strictEqual((await import('data:text/javascript,export default 42')).default, 42);
diff --git a/test/ffi/fixture_library/binding.gyp b/test/ffi/fixture_library/binding.gyp
index 90b1ede01662..d66f2a609f03 100644
--- a/test/ffi/fixture_library/binding.gyp
+++ b/test/ffi/fixture_library/binding.gyp
@@ -5,6 +5,9 @@
'sources': ['ffi_test_library.c'],
'type': 'shared_library',
'conditions': [
+ ['OS == "linux"', {
+ 'sources': ['deny_executable_memory.c'],
+ }],
['OS in "aix os400"', {
'product_extension': 'so',
'ldflags': [ '-Wl,-G' ],
diff --git a/test/ffi/fixture_library/deny_executable_memory.c b/test/ffi/fixture_library/deny_executable_memory.c
new file mode 100644
index 000000000000..c3fffb883a94
--- /dev/null
+++ b/test/ffi/fixture_library/deny_executable_memory.c
@@ -0,0 +1,40 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// The ignored function pointer keeps this fixture off the Fast API path, so it
+// can install the restriction before Node's first executable-memory probe.
+int deny_executable_memory(void (*unused)(void)) {
+ (void)unused;
+#if defined(__NR_mmap) && defined(__NR_mprotect)
+ struct sock_filter filter[] = {
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mprotect, 2, 0),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mmap, 1, 0),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
+ offsetof(struct seccomp_data, args[2])),
+ BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, PROT_EXEC, 0, 1),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EACCES),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ };
+ struct sock_fprog program = {
+ .len = sizeof(filter) / sizeof(filter[0]),
+ .filter = filter,
+ };
+
+ // SAFETY: Only the test thread is restricted, and the filter returns EACCES
+ // for executable mappings rather than terminating it. The test uses jitless.
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 ||
+ prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) != 0) {
+ return errno;
+ }
+ return 0;
+#else
+ return ENOSYS;
+#endif
+}
diff --git a/test/ffi/test-ffi-jitless.js b/test/ffi/test-ffi-jitless.js
new file mode 100644
index 000000000000..0877a49adaab
--- /dev/null
+++ b/test/ffi/test-ffi-jitless.js
@@ -0,0 +1,50 @@
+// Flags: --jitless
+'use strict';
+
+const common = require('../common');
+common.skipIfFFIMissing();
+const assert = require('node:assert');
+const ffi = require('node:ffi');
+const { libraryPath, fixtureSymbols } = require('./ffi-test-common');
+
+const lib = new ffi.DynamicLibrary(libraryPath);
+try {
+ // A function parameter excludes Fast API, independently of RX support.
+ const fallback = lib.getFunction('identity_i64_fallback', {
+ arguments: ['pointer', 'i64', 'function'],
+ return: 'i64',
+ });
+ // SAFETY: The fixture ignores both null pointers and returns the scalar value.
+ assert.strictEqual(fallback(null, 42n, null), 42n);
+
+ let add;
+ try {
+ add = lib.getFunction('add_i32', fixtureSymbols.add_i32);
+ } catch (err) {
+ assert.strictEqual(err.code, 'ERR_RX_MEMORY_NOT_SUPPORTED');
+ }
+ if (add !== undefined) {
+ // SAFETY: The fixture signature matches two int32 arguments and its result.
+ assert.strictEqual(add(20, 22), 42);
+ }
+
+ let callback;
+ try {
+ callback = lib.registerCallback({ arguments: ['i32'], return: 'i32' }, (value) => value * 2);
+ } catch (err) {
+ assert.strictEqual(err.code, 'ERR_RX_MEMORY_NOT_SUPPORTED');
+ }
+ if (callback !== undefined) {
+ try {
+ const invoke = lib.getFunction('call_int_callback', {
+ arguments: ['function', 'i32'], return: 'i32',
+ });
+ // SAFETY: The callback is live, runs on this thread, and matches the ABI.
+ assert.strictEqual(invoke(callback, 21), 42);
+ } finally {
+ lib.unregisterCallback(callback);
+ }
+ }
+} finally {
+ lib.close();
+}
diff --git a/test/ffi/test-ffi-no-rx-memory.js b/test/ffi/test-ffi-no-rx-memory.js
new file mode 100644
index 000000000000..f2b5f960932d
--- /dev/null
+++ b/test/ffi/test-ffi-no-rx-memory.js
@@ -0,0 +1,65 @@
+// Flags: --jitless
+'use strict';
+
+const common = require('../common');
+common.skipIfFFIMissing();
+if (!common.isLinux) {
+ common.skip('This test requires Linux seccomp');
+}
+if (require('node:os').endianness() !== 'LE') {
+ common.skip('The seccomp fixture reads little-endian syscall arguments');
+}
+if (!['x64', 'arm64', 'ppc64', 'riscv64', 'loong64', 's390x'].includes(process.arch)) {
+ common.skip('No Fast API stub emitter for this architecture');
+}
+const assert = require('node:assert');
+const ffi = require('node:ffi');
+const { libraryPath, fixtureSymbols } = require('./ffi-test-common');
+
+const lib = new ffi.DynamicLibrary(libraryPath);
+try {
+ const deny = lib.getFunction('deny_executable_memory', {
+ arguments: ['function'],
+ return: 'i32',
+ });
+ // SAFETY: The fixture ignores the null function pointer and only restricts
+ // executable mappings in this jitless test thread.
+ const result = deny(null);
+ if (result !== 0) {
+ common.skip(`Cannot install seccomp filter: ${result}`);
+ }
+
+ // Repeated rejection must not cache a callable or apply a different signature.
+ for (let i = 0; i < 2; i++) {
+ assert.throws(() => lib.getFunction('add_i32', fixtureSymbols.add_i32), {
+ code: 'ERR_RX_MEMORY_NOT_SUPPORTED',
+ message: 'Executable memory is not supported in this environment, but is required for FFI Fast API calls',
+ });
+ }
+ assert.throws(() => lib.getFunction('identity_pointer', {
+ arguments: ['pointer'], return: 'pointer',
+ }), { code: 'ERR_RX_MEMORY_NOT_SUPPORTED' });
+
+ const fallback = lib.getFunction('identity_i64_fallback', {
+ arguments: ['pointer', 'i64', 'function'],
+ return: 'i64',
+ });
+ // SAFETY: The fixture ignores both null pointers and returns the scalar value.
+ assert.strictEqual(fallback(null, 42n, null), 42n);
+
+ // Static libffi trampolines may still work. Dynamic closure failures must
+ // report the missing capability instead of an opaque allocation error.
+ let callback;
+ try {
+ callback = lib.registerCallback(() => {});
+ } catch (err) {
+ assert.strictEqual(err.code, 'ERR_RX_MEMORY_NOT_SUPPORTED');
+ assert.strictEqual(err.message,
+ 'Executable memory is not supported in this environment, but is required for FFI callbacks');
+ }
+ if (callback !== undefined) {
+ lib.unregisterCallback(callback);
+ }
+} finally {
+ lib.close();
+}