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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.d/10569-napi-v10-typeof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Fixed

- **The Node-API host loads addons that declare Node-API 9 or 10 (#10456).** Current releases of argon2 (0.45), better-sqlite3 (13) and sharp (0.35) declare version 9 or 10 through node-addon-api, and the host rejected anything above 8 before the initializer ran ("addon requests Node-API version 10, but Perry supports versions 1 through 8"). The host now implements the stable surface through version 10, the highest Node 26 supports, and `napi_get_version` reports 10:
- Version 9: `node_api_symbol_for`, `node_api_create_syntax_error`, `node_api_throw_syntax_error`, `node_api_get_module_file_name`.
- Version 10: `node_api_create_property_key_latin1/utf8/utf16`, `node_api_create_external_string_latin1/utf16` (copied into a Perry string, reporting `copied = true` and running the finalizer before returning, the path Node itself takes when V8 cannot adopt the storage), and `node_api_create_buffer_from_arraybuffer`.
- Module versions follow Node's rules: no declaration or anything below 8 runs as 8, 9 and 10 run as declared, and a higher version or `NAPI_VERSION_EXPERIMENTAL` is rejected with the requested and supported versions in the error.
- Node keeps a declared version and file URL per module, but Perry shares one environment per agent. Both facts are now attributed to the addon whose code is running (initializer, function callbacks, async-work completions, TSFN callbacks and finalizers), so `node_api_get_module_file_name` answers per addon.
- An exception an addon leaves pending in an async-work completion, TSFN callback or finalizer used to stay pending and surface from an unrelated later call. It now follows Node's policy: it is reported as an uncaught exception, except that a TSFN callback in a module declaring a version below 10 emits `DEP0168` and drops it.

- **`napi_typeof` reports compiled classes as `function` (#10461).** The host classified values with its own tag checks. A class declaration's INT32-tagged class ref came back as `number` and a class expression's class object as `object`, so better-sqlite3's `initialize(SqliteError, …)` failed with "Expected first argument to be a function". `napi_typeof` now uses the same classifier as the `typeof` operator, keeping only the `null` and `external` cases that Node-API reports on its own. Related fixes:
- `napi_create_int32` and `napi_create_uint32` create plain doubles, so a small integer can no longer read as a class that happens to have that id. Before, `console.log` of such a return value could print `[class X]`.
- The number getters return `napi_number_expected` for a class.
- `Object(C)` returns the class itself instead of boxing its id.

- **Node-API calls are cheaper.** Every successful call used to allocate a new `napi_ok` error-info message. The host now reuses the stored message while the status and message are unchanged, and the callback trampoline sets module attribution inside the environment borrows it already takes. Instruction counts for 1M `napi_typeof` calls fell 14–33% depending on the value kind, and 1M JS-to-native calls fell 6%.

Validation: 10 new runtime unit tests. They cover `napi_typeof` for every value kind, including class refs, class objects, bound functions and externals, plus integer creation next to a colliding class id, the version rules, each version 9/10 entry point, module attribution across nested addons, the callback exception policy and last-error bookkeeping. The Node-API e2e gate adds a C fixture built as version 10, 8 and 11 addons. Its Perry transcript must match Node 26's byte for byte, and the version 11 build must be rejected. argon2 0.45.1 hash/verify output (argon2d/i/id, raw, random salt, short-salt error) is identical to Node. better-sqlite3 13.0.2 now loads, initializes and runs statements; row reads stop at the separate `Function(...)` interpreter gap (#10422).
7 changes: 4 additions & 3 deletions crates/perry-runtime/src/builtins/arithmetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ pub(crate) fn typeof_string_cache_cells_for_test() -> [*mut StringHeader; 8] {

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u32)]
enum ValueTypeofTag {
pub(crate) enum ValueTypeofTag {
Undefined = 0,
Object = 1,
Boolean = 2,
Expand All @@ -696,9 +696,10 @@ enum ValueTypeofTag {
/// this one classifier makes the literal-comparison entry point below exactly
/// agree with [`js_value_typeof`]: class refs, callable proxies, raw typed-array
/// pointers, stream handles, Symbols, closures, and class-expression objects
/// cannot drift between the two APIs.
/// cannot drift between the two APIs. The Node-API host's `napi_typeof` reads
/// the same classifier for the same reason (#10461).
#[inline]
fn classify_value_typeof(value: f64) -> ValueTypeofTag {
pub(crate) fn classify_value_typeof(value: f64) -> ValueTypeofTag {
let jsval = JSValue::from_bits(value.to_bits());

if jsval.is_undefined() || jsval.bits() == crate::value::TAG_HOLE {
Expand Down
9 changes: 7 additions & 2 deletions crates/perry-runtime/src/node_api_host/async_work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(crate) struct AsyncWorkInner {
execute: usize,
complete: usize,
data: usize,
module: Option<u32>,
state: AtomicU8,
deleted: AtomicBool,
}
Expand Down Expand Up @@ -92,6 +93,7 @@ pub unsafe extern "C" fn napi_create_async_work(
execute: execute.unwrap() as usize,
complete: complete.unwrap() as usize,
data: data as usize,
module: active_module(env),
state: AtomicU8::new(WORK_CREATED),
deleted: AtomicBool::new(false),
});
Expand Down Expand Up @@ -230,9 +232,9 @@ pub(crate) fn drain_async_completions() -> i32 {
let opened = unsafe { napi_open_handle_scope(env, &mut scope) } == NapiStatus::Ok;
let complete: unsafe extern "C" fn(NapiEnv, NapiStatus, *mut c_void) =
unsafe { std::mem::transmute(work.complete) };
unsafe {
with_active_module(env, work.module, || unsafe {
complete(env, status, work.data as *mut c_void);
}
});
if opened {
unsafe {
napi_close_handle_scope(env, scope);
Expand All @@ -241,6 +243,9 @@ pub(crate) fn drain_async_completions() -> i32 {
work.state.store(WORK_COMPLETE, Ordering::Release);
ACTIVE_WORK.fetch_sub(1, Ordering::AcqRel);
ran = ran.saturating_add(1);
// Node completes async work with the uncaught-exception policy
// enforced for every module version.
settle_callback_exception(env, work.module, true);
}
ran
}
Expand Down
48 changes: 48 additions & 0 deletions crates/perry-runtime/src/node_api_host/buffers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,54 @@ pub unsafe extern "C" fn napi_create_external_arraybuffer(
write_pointer_handle(env, buffer.cast(), result)
}

/// A Buffer view over `byte_length` bytes of an ArrayBuffer, sharing its
/// storage (Node-API 10). Status and exception behavior follow Node 26: a
/// non-ArrayBuffer is `napi_invalid_arg`, and an out-of-range window throws
/// `ERR_OUT_OF_RANGE` and returns that throw's status.
#[no_mangle]
pub unsafe extern "C" fn node_api_create_buffer_from_arraybuffer(
env: NapiEnv,
arraybuffer: NapiValue,
byte_offset: usize,
byte_length: usize,
result: *mut NapiValue,
) -> NapiStatus {
if pending_exception(env).is_some() {
return set_status(env, NapiStatus::PendingException, "an exception is pending");
}
if result.is_null() {
return set_status(env, NapiStatus::InvalidArg, "result must not be null");
}
let owner = match pointer_owner(env, arraybuffer) {
Ok(owner) if crate::buffer::is_array_buffer(owner) => owner,
_ => return set_status(env, NapiStatus::InvalidArg, "value must be an ArrayBuffer"),
};
let available = (*(owner as *const BufferHeader)).length as usize;
let window = byte_offset
.checked_add(byte_length)
.filter(|end| *end <= available)
.and_then(|_| {
Some((
i32::try_from(byte_offset).ok()?,
i32::try_from(byte_length).ok()?,
))
});
let Some((offset, length)) = window else {
return super::values::napi_throw_range_error(
env,
c"ERR_OUT_OF_RANGE".as_ptr(),
c"The byte offset + length is out of range".as_ptr(),
);
};
// Nothing has allocated since the owner was read from its live handle.
let view = crate::buffer::js_buffer_from_arraybuffer_slice(
JSValue::pointer(owner as *const u8).bits() as i64,
offset,
length,
);
write_pointer_handle(env, view.cast(), result)
}

#[no_mangle]
pub unsafe extern "C" fn napi_is_arraybuffer(
env: NapiEnv,
Expand Down
16 changes: 15 additions & 1 deletion crates/perry-runtime/src/node_api_host/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ fn current_callback_record(index: usize) -> Option<NativeCallbackRecord> {
env.callbacks.get(index).map(|record| NativeCallbackRecord {
callback: record.callback,
data: record.data,
module: record.module,
})
})
.flatten()
Expand Down Expand Up @@ -82,7 +83,18 @@ extern "C" fn napi_callback_thunk(
new_target,
});
let info_ptr = (&mut *info) as *mut CallbackInfoRecord as NapiCallbackInfo;
with_env_mut(env, |env| env.active_callback_infos.push(info_ptr as usize));
// The callback runs attributed to the addon that created the function.
// The switch rides on the borrows this trampoline already takes, so a
// native call pays no extra environment lookups for it.
let previous_module = with_env_mut(env, |env| {
env.active_callback_infos.push(info_ptr as usize);
let previous = env.active_module;
if callback.module.is_some() {
env.active_module = callback.module;
}
previous
})
.flatten();

let returned = unsafe { native_callback(env, info_ptr) };
let returned_bits = if returned.is_null() {
Expand All @@ -92,6 +104,7 @@ extern "C" fn napi_callback_thunk(
};

with_env_mut(env, |env| {
env.active_module = previous_module;
if let Some(position) = env
.active_callback_infos
.iter()
Expand Down Expand Up @@ -149,6 +162,7 @@ pub unsafe extern "C" fn napi_create_function(
env.callbacks.push(NativeCallbackRecord {
callback,
data: data as usize,
module: env.active_module,
});
index
}) {
Expand Down
18 changes: 8 additions & 10 deletions crates/perry-runtime/src/node_api_host/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,20 +365,17 @@ pub unsafe extern "C" fn napi_module_register(module: *mut NapiModule) {

unsafe fn initialize_addon(
env: NapiEnv,
path: &Path,
handle: usize,
legacy: Option<usize>,
) -> Result<u64, String> {
type VersionFn = unsafe extern "C" fn() -> i32;
type RegisterFn = unsafe extern "C" fn(NapiEnv, NapiValue) -> NapiValue;
if let Some(version) = find_symbol(handle, b"node_api_module_get_api_version_v1") {
let declared = find_symbol(handle, b"node_api_module_get_api_version_v1").map(|version| {
let version: VersionFn = std::mem::transmute(version);
let requested = version();
if requested < 1 || requested as u32 > NAPI_VERSION {
return Err(format!(
"addon requests Node-API version {requested}, but Perry supports versions 1 through {NAPI_VERSION}"
));
}
}
version()
});
let api_version = effective_module_version(declared)?;
let mut exports = std::ptr::null_mut();
let status = napi_create_object(env, &mut exports);
if status != NapiStatus::Ok {
Expand All @@ -404,7 +401,8 @@ unsafe fn initialize_addon(
.to_string(),
);
};
let returned = register(env, exports);
let module = register_module(env, path, api_version);
let returned = with_active_module(env, module, || register(env, exports));
if pending_exception(env).is_some() {
return Err("addon initializer left a pending JavaScript exception".to_string());
}
Expand Down Expand Up @@ -467,7 +465,7 @@ pub fn load_addon(request: &str) -> Result<f64, String> {
return Err(error);
}
};
match unsafe { initialize_addon(env, handle, legacy) } {
match unsafe { initialize_addon(env, &path, handle, legacy) } {
Ok(bits) => Ok((handle, bits)),
Err(error) => {
unsafe { close_library(handle) };
Expand Down
7 changes: 5 additions & 2 deletions crates/perry-runtime/src/node_api_host/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub(crate) struct FinalizerRecord {
pub callback: unsafe extern "C" fn(NapiEnv, *mut c_void, *mut c_void),
pub data: usize,
pub hint: usize,
pub module: Option<u32>,
}

static NEXT_FINALIZER_ID: AtomicU64 = AtomicU64::new(1);
Expand Down Expand Up @@ -55,6 +56,7 @@ pub(crate) fn finalizer(
callback,
data: data as usize,
hint: hint as usize,
module: super::modules::current_active_module(),
})
}

Expand Down Expand Up @@ -208,19 +210,20 @@ pub(crate) fn drain_pending_finalizers() -> i32 {
let env = current_env();
let mut scope = std::ptr::null_mut();
let opened = unsafe { napi_open_handle_scope(env, &mut scope) } == NapiStatus::Ok;
unsafe {
with_active_module(env, callback.module, || unsafe {
(callback.callback)(
env,
callback.data as *mut c_void,
callback.hint as *mut c_void,
);
}
});
if opened {
unsafe {
napi_close_handle_scope(env, scope);
}
}
ran = ran.saturating_add(1);
settle_callback_exception(env, callback.module, true);
}
ran
}
Expand Down
26 changes: 23 additions & 3 deletions crates/perry-runtime/src/node_api_host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod functions;
mod lifecycle;
mod loader;
mod metadata;
mod modules;
mod promises;
mod properties;
mod scopes;
Expand All @@ -35,6 +36,7 @@ pub use functions::*;
pub use lifecycle::*;
pub use loader::*;
pub use metadata::*;
pub use modules::*;
pub use promises::*;
pub use properties::*;
pub use scopes::*;
Expand All @@ -55,7 +57,9 @@ pub type NapiThreadsafeFunction = *mut c_void;
pub type NapiAsyncCleanupHookHandle = *mut c_void;

pub const NAPI_AUTO_LENGTH: usize = usize::MAX;
pub const NAPI_VERSION: u32 = 8;
/// Highest Node-API version the host implements, and what `napi_get_version`
/// reports: Node 26's `NODE_API_SUPPORTED_VERSION_MAX` (#10456).
pub const NAPI_VERSION: u32 = 10;

#[repr(i32)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -131,6 +135,8 @@ pub(crate) struct ReferenceRecord {
pub(crate) struct NativeCallbackRecord {
pub callback: usize,
pub data: usize,
/// The addon that created the function; see [`modules::ModuleRecord`].
pub module: Option<u32>,
}

pub(crate) struct CallbackInfoRecord {
Expand Down Expand Up @@ -180,12 +186,15 @@ pub(crate) struct Env {
async_work_lookup: crate::fast_hash::PtrHashMap<usize, usize>,
tsfns: Vec<std::sync::Arc<ThreadsafeFunctionInner>>,
loaded_addons: Vec<LoadedAddon>,
modules: Vec<ModuleRecord>,
active_module: Option<u32>,
currently_loading_filename: Option<String>,
instance_data: Option<InstanceDataRecord>,
shutting_down: bool,
external_memory: i64,
pending_exception_bits: Option<u64>,
last_status: NapiStatus,
last_message: &'static str,
last_error_message: CString,
error_info: NapiExtendedErrorInfo,
}
Expand Down Expand Up @@ -218,12 +227,15 @@ impl Env {
async_work_lookup: crate::fast_hash::new_ptr_hash_map(),
tsfns: Vec::new(),
loaded_addons: Vec::new(),
modules: Vec::new(),
active_module: None,
currently_loading_filename: None,
instance_data: None,
shutting_down: false,
external_memory: 0,
pending_exception_bits: None,
last_status: NapiStatus::Ok,
last_message: "napi_ok",
last_error_message: CString::new("napi_ok").unwrap(),
error_info: NapiExtendedErrorInfo {
error_message: std::ptr::null(),
Expand All @@ -242,8 +254,14 @@ impl Env {
}

fn set_status(&mut self, status: NapiStatus, message: &'static str) -> NapiStatus {
self.last_status = status;
self.last_error_message = CString::new(message).expect("static N-API error has no NUL");
// Nearly every call reports the `napi_ok` it reported last time; only
// a changed status or message needs a new NUL-terminated copy, so a
// successful call no longer allocates for its bookkeeping.
if self.last_status != status || self.last_message != message {
self.last_status = status;
self.last_message = message;
self.last_error_message = CString::new(message).expect("static N-API error has no NUL");
}
self.refresh_error_info();
status
}
Expand Down Expand Up @@ -563,3 +581,5 @@ pub(crate) fn reset_env_for_test() {

#[cfg(test)]
mod tests;
#[cfg(test)]
mod v10_tests;
Loading
Loading