diff --git a/changelog.d/10569-napi-v10-typeof.md b/changelog.d/10569-napi-v10-typeof.md new file mode 100644 index 0000000000..6ffa3da48c --- /dev/null +++ b/changelog.d/10569-napi-v10-typeof.md @@ -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). diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index dda0c4d5a7..dd59114868 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -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, @@ -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 { diff --git a/crates/perry-runtime/src/node_api_host/async_work.rs b/crates/perry-runtime/src/node_api_host/async_work.rs index b7fe403414..34bd64c5e6 100644 --- a/crates/perry-runtime/src/node_api_host/async_work.rs +++ b/crates/perry-runtime/src/node_api_host/async_work.rs @@ -21,6 +21,7 @@ pub(crate) struct AsyncWorkInner { execute: usize, complete: usize, data: usize, + module: Option, state: AtomicU8, deleted: AtomicBool, } @@ -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), }); @@ -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); @@ -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 } diff --git a/crates/perry-runtime/src/node_api_host/buffers.rs b/crates/perry-runtime/src/node_api_host/buffers.rs index 6380735ab9..93e4bc5da4 100644 --- a/crates/perry-runtime/src/node_api_host/buffers.rs +++ b/crates/perry-runtime/src/node_api_host/buffers.rs @@ -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, diff --git a/crates/perry-runtime/src/node_api_host/functions.rs b/crates/perry-runtime/src/node_api_host/functions.rs index db56b1514d..9c4471529a 100644 --- a/crates/perry-runtime/src/node_api_host/functions.rs +++ b/crates/perry-runtime/src/node_api_host/functions.rs @@ -29,6 +29,7 @@ fn current_callback_record(index: usize) -> Option { env.callbacks.get(index).map(|record| NativeCallbackRecord { callback: record.callback, data: record.data, + module: record.module, }) }) .flatten() @@ -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() { @@ -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() @@ -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 }) { diff --git a/crates/perry-runtime/src/node_api_host/loader.rs b/crates/perry-runtime/src/node_api_host/loader.rs index 742660d3e3..4f7141d0ad 100644 --- a/crates/perry-runtime/src/node_api_host/loader.rs +++ b/crates/perry-runtime/src/node_api_host/loader.rs @@ -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, ) -> Result { 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 { @@ -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()); } @@ -467,7 +465,7 @@ pub fn load_addon(request: &str) -> Result { 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) }; diff --git a/crates/perry-runtime/src/node_api_host/metadata.rs b/crates/perry-runtime/src/node_api_host/metadata.rs index e14922aef9..c4775571c7 100644 --- a/crates/perry-runtime/src/node_api_host/metadata.rs +++ b/crates/perry-runtime/src/node_api_host/metadata.rs @@ -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, } static NEXT_FINALIZER_ID: AtomicU64 = AtomicU64::new(1); @@ -55,6 +56,7 @@ pub(crate) fn finalizer( callback, data: data as usize, hint: hint as usize, + module: super::modules::current_active_module(), }) } @@ -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 } diff --git a/crates/perry-runtime/src/node_api_host/mod.rs b/crates/perry-runtime/src/node_api_host/mod.rs index 169fe1d376..f3d05720d7 100644 --- a/crates/perry-runtime/src/node_api_host/mod.rs +++ b/crates/perry-runtime/src/node_api_host/mod.rs @@ -17,6 +17,7 @@ mod functions; mod lifecycle; mod loader; mod metadata; +mod modules; mod promises; mod properties; mod scopes; @@ -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::*; @@ -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)] @@ -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, } pub(crate) struct CallbackInfoRecord { @@ -180,12 +186,15 @@ pub(crate) struct Env { async_work_lookup: crate::fast_hash::PtrHashMap, tsfns: Vec>, loaded_addons: Vec, + modules: Vec, + active_module: Option, currently_loading_filename: Option, instance_data: Option, shutting_down: bool, external_memory: i64, pending_exception_bits: Option, last_status: NapiStatus, + last_message: &'static str, last_error_message: CString, error_info: NapiExtendedErrorInfo, } @@ -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(), @@ -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 } @@ -563,3 +581,5 @@ pub(crate) fn reset_env_for_test() { #[cfg(test)] mod tests; +#[cfg(test)] +mod v10_tests; diff --git a/crates/perry-runtime/src/node_api_host/modules.rs b/crates/perry-runtime/src/node_api_host/modules.rs new file mode 100644 index 0000000000..b0a086e7fb --- /dev/null +++ b/crates/perry-runtime/src/node_api_host/modules.rs @@ -0,0 +1,179 @@ +//! Per-addon identity inside Perry's shared Node-API environment (#10456). +//! +//! Node gives every loaded module its own `napi_env`, which carries the +//! module's declared Node-API version and file URL. Perry keeps one +//! environment per agent, so the two per-module facts Node-API makes +//! observable are attributed instead: each host-initiated entry into addon +//! code that receives an environment (the initializer, native function +//! callbacks, async-work completions, TSFN callbacks and finalizers) runs with +//! the module that created it marked active. + +use super::*; +use std::ffi::{c_char, CString}; +use std::path::Path; + +/// `NODE_API_DEFAULT_MODULE_API_VERSION`: what Node assumes for an addon that +/// declares no version, or any version below it. +pub(crate) const NAPI_DEFAULT_MODULE_VERSION: i32 = 8; +/// `NAPI_VERSION_EXPERIMENTAL`, declared by addons built with +/// `NAPI_EXPERIMENTAL`. The experimental entry points are not exported. +pub(crate) const NAPI_VERSION_EXPERIMENTAL: i32 = i32::MAX; + +pub(crate) struct ModuleRecord { + /// `file://` URL of the canonical sidecar path the addon was loaded from. + /// The `CString` buffer never moves, and records are never removed. + file_url: CString, + /// The declared version after Node's clamp to at least version 8. + api_version: i32, +} + +/// Apply Node's module-version rules to a declared +/// `node_api_module_get_api_version_v1()` result (`None` when the addon does +/// not export one). +pub(crate) fn effective_module_version(declared: Option) -> Result { + let Some(declared) = declared else { + return Ok(NAPI_DEFAULT_MODULE_VERSION); + }; + if declared == NAPI_VERSION_EXPERIMENTAL { + return Err(format!( + "addon requests the experimental Node-API surface (NAPI_EXPERIMENTAL), which Perry does not provide; Perry supports versions 1 through {NAPI_VERSION}" + )); + } + if declared < 1 || declared as u32 > NAPI_VERSION { + return Err(format!( + "addon requests Node-API version {declared}, but Perry supports versions 1 through {NAPI_VERSION}" + )); + } + Ok(declared.max(NAPI_DEFAULT_MODULE_VERSION)) +} + +/// Record a module whose initializer is about to run and return the index +/// its callbacks are attributed to. +pub(crate) fn register_module(env: NapiEnv, path: &Path, api_version: i32) -> Option { + let text = path.to_string_lossy(); + // A canonical Windows path carries the verbatim prefix, which is not part + // of the file URL Node reports. + let text = text.strip_prefix(r"\\?\").unwrap_or(&text); + let url = crate::url::node_compat::path_to_file_url_string(text, cfg!(windows)); + let file_url = CString::new(url).ok()?; + with_env_mut(env, |env| { + let index = u32::try_from(env.modules.len()).ok()?; + env.modules.push(ModuleRecord { + file_url, + api_version, + }); + Some(index) + }) + .flatten() +} + +/// The module whose code is running on `env`, captured by records that call +/// back into that module later. +pub(crate) fn active_module(env: NapiEnv) -> Option { + with_env(env, |env| env.active_module).flatten() +} + +/// [`active_module`] for the current agent's environment, for record +/// constructors that do not receive an environment. +pub(crate) fn current_active_module() -> Option { + NODE_API_ENV.with(|cell| cell.try_borrow().ok()?.as_deref()?.active_module) +} + +/// Run addon code attributed to `module`, restoring the previous attribution +/// afterwards. `None` (a record created outside any addon) keeps the current +/// attribution. +pub(crate) fn with_active_module(env: NapiEnv, module: Option, f: impl FnOnce() -> R) -> R { + let Some(module) = module else { + return f(); + }; + let previous = with_env_mut(env, |env| env.active_module.replace(module)).flatten(); + let result = f(); + with_env_mut(env, |env| env.active_module = previous); + result +} + +/// Deliver an exception an addon left pending from a callback with no +/// JavaScript caller to return it to (`node_napi_env__::CallbackIntoModule`). +/// +/// Node reports it as an uncaught exception. For the TSFN callbacks, which +/// Node calls with `enforceUncaughtExceptionPolicy = false`, a module that +/// declares a version below 10 instead gets the DEP0168 warning and the +/// exception is dropped. Once the environment is shutting down it is dropped, +/// as Node does for a terminating environment. +pub(crate) fn settle_callback_exception(env: NapiEnv, module: Option, enforce_policy: bool) { + let Some((exception, report)) = with_env_mut(env, |env| { + let exception = env.pending_exception_bits.take()?; + let api_version = module + .and_then(|index| env.modules.get(index as usize)) + .map_or(NAPI_VERSION as i32, |record| record.api_version); + let report = if env.shutting_down { + None + } else { + Some(enforce_policy || api_version >= 10) + }; + Some((exception, report)) + }) + .flatten() else { + return; + }; + match report { + Some(true) => report_uncaught_exception(f64::from_bits(exception)), + Some(false) => emit_uncaught_callback_deprecation(), + None => {} + } +} + +fn report_uncaught_exception(mut error: f64) { + // No generated frame is on the stack here, so trap the delivery: an + // exception thrown by an 'uncaughtException' listener is itself uncaught + // and is delivered again, as the timer and watcher pumps do. + loop { + match crate::exception::js_call_catching(|| { + crate::os::emit_process_uncaught_exception(error); + f64::from_bits(crate::value::TAG_UNDEFINED) + }) { + Ok(_) => return, + Err(thrown) => error = thrown, + } + } +} + +fn emit_uncaught_callback_deprecation() { + fn string(text: &str) -> f64 { + let ptr = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(ptr).bits()) + } + let scope = crate::gc::RuntimeHandleScope::new(); + let message = scope.root_nanbox_f64(string( + "Uncaught Node-API callback exception detected, please run node with option --force-node-api-uncaught-exceptions-policy=true to handle those exceptions properly.", + )); + let kind = scope.root_nanbox_f64(string("DeprecationWarning")); + let code = string("DEP0168"); + crate::process::js_process_emit_warning(message.get_nanbox_f64(), kind.get_nanbox_f64(), code); +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_get_module_file_name( + env: NapiEnv, + result: *mut *const c_char, +) -> NapiStatus { + if result.is_null() { + return set_status(env, NapiStatus::InvalidArg, "result must not be null"); + } + // Code outside every attributed entry point (an environment cleanup hook, + // which is not handed an environment) sees the most recently loaded + // module. + let Some(url) = with_env(env, |env| { + let index = env + .active_module + .map(|index| index as usize) + .or_else(|| env.modules.len().checked_sub(1)); + index + .and_then(|index| env.modules.get(index)) + .map_or(c"".as_ptr(), |record| record.file_url.as_ptr()) + }) else { + return NapiStatus::InvalidArg; + }; + *result = url; + ok(env) +} diff --git a/crates/perry-runtime/src/node_api_host/symbols.txt b/crates/perry-runtime/src/node_api_host/symbols.txt index 46ab569f56..3bffc5b46f 100644 --- a/crates/perry-runtime/src/node_api_host/symbols.txt +++ b/crates/perry-runtime/src/node_api_host/symbols.txt @@ -143,3 +143,13 @@ napi_typeof napi_unref_threadsafe_function napi_unwrap napi_wrap +node_api_create_buffer_from_arraybuffer +node_api_create_external_string_latin1 +node_api_create_external_string_utf16 +node_api_create_property_key_latin1 +node_api_create_property_key_utf16 +node_api_create_property_key_utf8 +node_api_create_syntax_error +node_api_get_module_file_name +node_api_symbol_for +node_api_throw_syntax_error diff --git a/crates/perry-runtime/src/node_api_host/tests.rs b/crates/perry-runtime/src/node_api_host/tests.rs index cdef459c68..01172af2d3 100644 --- a/crates/perry-runtime/src/node_api_host/tests.rs +++ b/crates/perry-runtime/src/node_api_host/tests.rs @@ -37,6 +37,63 @@ fn reports_supported_node_api_version() { assert_eq!(version, NAPI_VERSION); } +#[test] +fn last_error_info_tracks_each_status_and_message() { + let env = test_env(); + let read = || { + let mut info = std::ptr::null(); + assert_eq!( + unsafe { napi_get_last_error_info(env, &mut info) }, + NapiStatus::Ok + ); + let info = unsafe { &*info }; + let message = unsafe { std::ffi::CStr::from_ptr(info.error_message) }; + (info.error_code, message.to_str().unwrap().to_string()) + }; + let number = int32(env, 1); + assert_eq!(read(), (NapiStatus::Ok, "napi_ok".to_string())); + let mut ignored = false; + assert_eq!( + unsafe { napi_get_value_bool(env, number, &mut ignored) }, + NapiStatus::BooleanExpected + ); + assert_eq!( + read(), + ( + NapiStatus::BooleanExpected, + "value must be a boolean".to_string() + ) + ); + let mut string = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_coerce_to_string(env, number, &mut string) }, + NapiStatus::Ok + ); + assert_eq!(read(), (NapiStatus::Ok, "napi_ok".to_string())); + assert_eq!( + unsafe { napi_get_value_double(env, number, std::ptr::null_mut()) }, + NapiStatus::InvalidArg + ); + assert_eq!( + read(), + ( + NapiStatus::InvalidArg, + "result must not be null".to_string() + ) + ); + assert_eq!( + unsafe { napi_get_value_bool(env, std::ptr::null_mut(), &mut ignored) }, + NapiStatus::InvalidArg + ); + assert_eq!( + read(), + ( + NapiStatus::InvalidArg, + "value is not a live handle".to_string() + ) + ); +} + #[test] fn primitive_values_round_trip_and_report_types() { let env = test_env(); diff --git a/crates/perry-runtime/src/node_api_host/tsfn.rs b/crates/perry-runtime/src/node_api_host/tsfn.rs index 62ef4e731c..8a6aa73dfa 100644 --- a/crates/perry-runtime/src/node_api_host/tsfn.rs +++ b/crates/perry-runtime/src/node_api_host/tsfn.rs @@ -42,6 +42,7 @@ pub(crate) struct ThreadsafeFunctionInner { finalize_callback: usize, context: usize, call_js: usize, + module: Option, } struct ThreadsafeFunctionToken { @@ -138,6 +139,7 @@ pub unsafe extern "C" fn napi_create_threadsafe_function( finalize_callback: thread_finalize_cb.map_or(0, |callback| callback as usize), context: context as usize, call_js: call_js_cb.map_or(0, |callback| callback as usize), + module: active_module(env), }); // Tokens are permanent tombstones. Their tiny allocation is intentionally // not reused, so a stale addon handle can never alias a later TSFN. @@ -323,37 +325,40 @@ fn invoke_item(inner: &Arc, env: NapiEnv, data: usize, .and_then(|bits| add_handle(env, bits).ok()) .unwrap_or(std::ptr::null_mut()) }; - if inner.call_js != 0 { - let callback: unsafe extern "C" fn(NapiEnv, NapiValue, *mut c_void, *mut c_void) = - unsafe { std::mem::transmute(inner.call_js) }; - unsafe { - callback( - if aborted { std::ptr::null_mut() } else { env }, - function, - inner.context as *mut c_void, - data as *mut c_void, - ); - } - } else if !aborted && !function.is_null() { - let mut global = std::ptr::null_mut(); - if unsafe { napi_get_global(env, &mut global) } == NapiStatus::Ok { + with_active_module(env, inner.module, || { + if inner.call_js != 0 { + let callback: unsafe extern "C" fn(NapiEnv, NapiValue, *mut c_void, *mut c_void) = + unsafe { std::mem::transmute(inner.call_js) }; unsafe { - napi_call_function( - env, - global, + callback( + if aborted { std::ptr::null_mut() } else { env }, function, - 0, - std::ptr::null(), - std::ptr::null_mut(), + inner.context as *mut c_void, + data as *mut c_void, ); } + } else if !aborted && !function.is_null() { + let mut global = std::ptr::null_mut(); + if unsafe { napi_get_global(env, &mut global) } == NapiStatus::Ok { + unsafe { + napi_call_function( + env, + global, + function, + 0, + std::ptr::null(), + std::ptr::null_mut(), + ); + } + } } - } + }); if opened { unsafe { napi_close_handle_scope(env, scope); } } + settle_callback_exception(env, inner.module, false); } fn maybe_finalize(handle: usize, inner: &Arc, env: NapiEnv) -> bool { @@ -368,13 +373,14 @@ fn maybe_finalize(handle: usize, inner: &Arc, env: Napi if inner.finalize_callback != 0 { let callback: unsafe extern "C" fn(NapiEnv, *mut c_void, *mut c_void) = unsafe { std::mem::transmute(inner.finalize_callback) }; - unsafe { + with_active_module(env, inner.module, || unsafe { callback( env, inner.finalize_data as *mut c_void, inner.context as *mut c_void, ); - } + }); + settle_callback_exception(env, inner.module, false); } if let Ok(mut js) = inner.js.lock() { js.function_bits = None; diff --git a/crates/perry-runtime/src/node_api_host/v10_tests.rs b/crates/perry-runtime/src/node_api_host/v10_tests.rs new file mode 100644 index 0000000000..d870cd12f7 --- /dev/null +++ b/crates/perry-runtime/src/node_api_host/v10_tests.rs @@ -0,0 +1,723 @@ +//! Node-API 9/10 surface, per-module attribution (#10456) and `napi_typeof` +//! classification of Perry's callable representations (#10461). + +use super::*; +use crate::closure::ClosureHeader; +use crate::value::JSValue; +use std::cell::RefCell; +use std::ffi::{c_char, c_void, CStr}; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn test_env() -> NapiEnv { + crate::gc::ensure_gc_initialized(); + reset_env_for_test(); + current_env() +} + +fn handle(env: NapiEnv, bits: u64) -> NapiValue { + add_handle(env, bits).expect("live Node-API environment") +} + +fn type_of(env: NapiEnv, value: NapiValue) -> NapiValueType { + let mut value_type = NapiValueType::Undefined; + assert_eq!( + unsafe { napi_typeof(env, value, &mut value_type) }, + NapiStatus::Ok + ); + value_type +} + +fn utf8(env: NapiEnv, text: &CStr) -> NapiValue { + let mut value = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_string_utf8(env, text.as_ptr(), NAPI_AUTO_LENGTH, &mut value) }, + NapiStatus::Ok + ); + value +} + +fn read_utf8(env: NapiEnv, value: NapiValue) -> String { + let mut buffer = [0 as c_char; 256]; + let mut length = 0; + assert_eq!( + unsafe { + napi_get_value_string_utf8(env, value, buffer.as_mut_ptr(), buffer.len(), &mut length) + }, + NapiStatus::Ok + ); + String::from_utf8_lossy(unsafe { + std::slice::from_raw_parts(buffer.as_ptr().cast::(), length) + }) + .into_owned() +} + +fn named(env: NapiEnv, object: NapiValue, name: &CStr) -> NapiValue { + let mut value = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_get_named_property(env, object, name.as_ptr(), &mut value) }, + NapiStatus::Ok + ); + value +} + +fn take_exception(env: NapiEnv) -> NapiValue { + let mut exception = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_get_and_clear_last_exception(env, &mut exception) }, + NapiStatus::Ok + ); + assert!(!exception.is_null(), "an exception must be pending"); + exception +} + +extern "C" fn plain_closure_body(_closure: *const ClosureHeader) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +unsafe extern "C" fn empty_callback(_env: NapiEnv, _info: NapiCallbackInfo) -> NapiValue { + std::ptr::null_mut() +} + +/// A class id no compiled program in this test binary registers. +const REGISTERED_CLASS: u32 = 0x1_0461; + +#[test] +fn typeof_classifies_every_value_kind_like_javascript_typeof() { + let env = test_env(); + let undefined = handle(env, crate::value::TAG_UNDEFINED); + let null = handle(env, crate::value::TAG_NULL); + let boolean = handle(env, JSValue::bool(true).bits()); + let double = handle(env, JSValue::number(1.5).bits()); + let string = utf8(env, c"text"); + let mut symbol = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_symbol(env, string, &mut symbol) }, + NapiStatus::Ok + ); + let mut bigint = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_bigint_int64(env, 7, &mut bigint) }, + NapiStatus::Ok + ); + let mut object = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_object(env, &mut object) }, + NapiStatus::Ok + ); + let mut array = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_array(env, &mut array) }, + NapiStatus::Ok + ); + let mut external = std::ptr::null_mut(); + assert_eq!( + unsafe { + napi_create_external( + env, + 0x10461usize as *mut c_void, + None, + std::ptr::null_mut(), + &mut external, + ) + }, + NapiStatus::Ok + ); + let mut native_function = std::ptr::null_mut(); + assert_eq!( + unsafe { + napi_create_function( + env, + c"native".as_ptr(), + NAPI_AUTO_LENGTH, + Some(empty_callback), + std::ptr::null_mut(), + &mut native_function, + ) + }, + NapiStatus::Ok + ); + let closure = crate::closure::js_closure_alloc(plain_closure_body as *const u8, 0); + let closure = handle(env, JSValue::pointer(closure.cast()).bits()); + let no_arguments: [f64; 0] = []; + let bound = unsafe { + crate::closure::js_function_bind( + f64::from_bits(value_bits(env, closure).unwrap()), + no_arguments.as_ptr(), + 0, + ) + }; + let bound = handle(env, bound.to_bits()); + + unsafe { crate::object::js_register_class_id(REGISTERED_CLASS) }; + // A class declaration's value: `INT32_TAG | class_id` (#10461). + let class_ref = handle(env, JSValue::int32(REGISTERED_CLASS as i32).bits()); + // A class expression's value: an object stamped as a class. + let class_object = crate::object::js_object_alloc(REGISTERED_CLASS, 0); + let class_object = handle(env, JSValue::pointer(class_object.cast()).bits()); + crate::object::js_object_mark_class( + JSValue::from_bits(value_bits(env, class_object).unwrap()).as_pointer::() as i64, + ); + + for (name, value, expected) in [ + ("undefined", undefined, NapiValueType::Undefined), + ("null", null, NapiValueType::Null), + ("boolean", boolean, NapiValueType::Boolean), + ("number", double, NapiValueType::Number), + ("string", string, NapiValueType::String), + ("symbol", symbol, NapiValueType::Symbol), + ("bigint", bigint, NapiValueType::Bigint), + ("object", object, NapiValueType::Object), + ("array", array, NapiValueType::Object), + ("external", external, NapiValueType::External), + ("native function", native_function, NapiValueType::Function), + ("closure", closure, NapiValueType::Function), + ("bound function", bound, NapiValueType::Function), + ("class ref", class_ref, NapiValueType::Function), + ("class object", class_object, NapiValueType::Function), + ] { + assert_eq!(type_of(env, value), expected, "napi_typeof({name})"); + } +} + +#[test] +fn created_integers_stay_numbers_when_a_class_id_shares_their_value() { + let env = test_env(); + unsafe { crate::object::js_register_class_id(REGISTERED_CLASS) }; + let mut int32 = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_int32(env, REGISTERED_CLASS as i32, &mut int32) }, + NapiStatus::Ok + ); + let mut uint32 = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_uint32(env, REGISTERED_CLASS, &mut uint32) }, + NapiStatus::Ok + ); + for value in [int32, uint32] { + assert_eq!(type_of(env, value), NapiValueType::Number); + let mut read = 0; + assert_eq!( + unsafe { napi_get_value_int32(env, value, &mut read) }, + NapiStatus::Ok + ); + assert_eq!(read, REGISTERED_CLASS as i32); + } + + // The constructor itself is not a number: Node reports + // napi_number_expected for a function, and ToObject returns it unchanged. + let class_ref = handle(env, JSValue::int32(REGISTERED_CLASS as i32).bits()); + let mut int_out = 0; + assert_eq!( + unsafe { napi_get_value_int32(env, class_ref, &mut int_out) }, + NapiStatus::NumberExpected + ); + let mut double_out = 0.0; + assert_eq!( + unsafe { napi_get_value_double(env, class_ref, &mut double_out) }, + NapiStatus::NumberExpected + ); + let mut as_object = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_coerce_to_object(env, class_ref, &mut as_object) }, + NapiStatus::Ok + ); + let mut same = false; + assert_eq!( + unsafe { napi_strict_equals(env, as_object, class_ref, &mut same) }, + NapiStatus::Ok + ); + assert!(same, "ToObject(class) must return the class itself"); +} + +#[test] +fn module_versions_follow_node_rules_up_to_version_10() { + assert_eq!(NAPI_VERSION, 10); + assert_eq!(effective_module_version(None), Ok(8)); + for (declared, effective) in [(1, 8), (8, 8), (9, 9), (10, 10)] { + assert_eq!(effective_module_version(Some(declared)), Ok(effective)); + } + let too_new = effective_module_version(Some(11)).unwrap_err(); + assert!(too_new.contains("version 11"), "{too_new}"); + assert!(too_new.contains("1 through 10"), "{too_new}"); + assert!(effective_module_version(Some(0)).is_err()); + let experimental = effective_module_version(Some(NAPI_VERSION_EXPERIMENTAL)).unwrap_err(); + assert!(experimental.contains("NAPI_EXPERIMENTAL"), "{experimental}"); +} + +#[test] +fn symbol_for_and_syntax_errors_match_node_api_9() { + let env = test_env(); + let mut first = std::ptr::null_mut(); + let mut second = std::ptr::null_mut(); + assert_eq!( + unsafe { node_api_symbol_for(env, c"perry.key".as_ptr(), NAPI_AUTO_LENGTH, &mut first) }, + NapiStatus::Ok + ); + // An explicit length selects the same registry key. + assert_eq!( + unsafe { node_api_symbol_for(env, c"perry.key!".as_ptr(), 9, &mut second) }, + NapiStatus::Ok + ); + assert_eq!(type_of(env, first), NapiValueType::Symbol); + let mut same = false; + assert_eq!( + unsafe { napi_strict_equals(env, first, second, &mut same) }, + NapiStatus::Ok + ); + assert!(same, "Symbol.for must return the registered symbol"); + let mut unique = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_symbol(env, utf8(env, c"perry.key"), &mut unique) }, + NapiStatus::Ok + ); + assert_eq!( + unsafe { napi_strict_equals(env, first, unique, &mut same) }, + NapiStatus::Ok + ); + assert!(!same); + + let mut error = std::ptr::null_mut(); + assert_eq!( + unsafe { + node_api_create_syntax_error( + env, + utf8(env, c"ERR_SYNTAX"), + utf8(env, c"bad token"), + &mut error, + ) + }, + NapiStatus::Ok + ); + assert_eq!(read_utf8(env, named(env, error, c"name")), "SyntaxError"); + assert_eq!(read_utf8(env, named(env, error, c"message")), "bad token"); + assert_eq!(read_utf8(env, named(env, error, c"code")), "ERR_SYNTAX"); + + assert_eq!( + unsafe { node_api_throw_syntax_error(env, c"ERR_THROWN".as_ptr(), c"thrown".as_ptr()) }, + NapiStatus::Ok + ); + let thrown = take_exception(env); + assert_eq!(read_utf8(env, named(env, thrown, c"name")), "SyntaxError"); + assert_eq!(read_utf8(env, named(env, thrown, c"code")), "ERR_THROWN"); +} + +static EXTERNAL_FINALIZED: AtomicUsize = AtomicUsize::new(0); + +unsafe extern "C" fn external_string_finalizer( + _env: NapiEnv, + data: *mut c_void, + hint: *mut c_void, +) { + assert_eq!(hint as usize, 0x10456); + // Clobber the addon's storage the way `free` may: the string must already + // own a copy. + *data.cast::() = b'#'; + EXTERNAL_FINALIZED.fetch_add(1, Ordering::SeqCst); +} + +#[test] +fn property_keys_and_external_strings_match_node_api_10() { + let env = test_env(); + let mut object = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_object(env, &mut object) }, + NapiStatus::Ok + ); + let mut latin1_key = std::ptr::null_mut(); + let mut utf8_key = std::ptr::null_mut(); + let mut utf16_key = std::ptr::null_mut(); + let utf16 = [u16::from(b'k'), 0x00e9]; + unsafe { + assert_eq!( + node_api_create_property_key_latin1(env, c"k1".as_ptr(), 2, &mut latin1_key), + NapiStatus::Ok + ); + assert_eq!( + node_api_create_property_key_utf8(env, c"k2".as_ptr(), NAPI_AUTO_LENGTH, &mut utf8_key), + NapiStatus::Ok + ); + assert_eq!( + node_api_create_property_key_utf16(env, utf16.as_ptr(), utf16.len(), &mut utf16_key), + NapiStatus::Ok + ); + for (key, text) in [ + (latin1_key, c"one"), + (utf8_key, c"two"), + (utf16_key, c"three"), + ] { + assert_eq!( + napi_set_property(env, object, key, utf8(env, text)), + NapiStatus::Ok + ); + } + } + assert_eq!(read_utf8(env, named(env, object, c"k1")), "one"); + assert_eq!(read_utf8(env, named(env, object, c"k2")), "two"); + assert_eq!(read_utf8(env, named(env, object, c"k\u{e9}")), "three"); + + EXTERNAL_FINALIZED.store(0, Ordering::SeqCst); + let mut latin1 = *b"external\xe9"; + let mut value = std::ptr::null_mut(); + let mut copied = false; + assert_eq!( + unsafe { + node_api_create_external_string_latin1( + env, + latin1.as_mut_ptr().cast(), + latin1.len(), + Some(external_string_finalizer), + 0x10456usize as *mut c_void, + &mut value, + &mut copied, + ) + }, + NapiStatus::Ok + ); + assert!(copied, "a copied external string must report copied = true"); + assert_eq!( + EXTERNAL_FINALIZED.load(Ordering::SeqCst), + 1, + "a copied external string's finalizer runs before the call returns" + ); + assert_eq!(read_utf8(env, value), "external\u{e9}"); + + let mut utf16 = [u16::from(b'u'), 0xd83d, 0xde00]; + assert_eq!( + unsafe { + node_api_create_external_string_utf16( + env, + utf16.as_mut_ptr(), + utf16.len(), + Some(external_string_finalizer), + 0x10456usize as *mut c_void, + &mut value, + std::ptr::null_mut(), + ) + }, + NapiStatus::Ok + ); + assert_eq!(EXTERNAL_FINALIZED.load(Ordering::SeqCst), 2); + assert_eq!(read_utf8(env, value), "u\u{1f600}"); + + // A failed creation leaves ownership with the addon: no finalizer. + assert_eq!( + unsafe { + node_api_create_external_string_latin1( + env, + latin1.as_mut_ptr().cast(), + latin1.len(), + Some(external_string_finalizer), + 0x10456usize as *mut c_void, + std::ptr::null_mut(), + &mut copied, + ) + }, + NapiStatus::InvalidArg + ); + assert_eq!(EXTERNAL_FINALIZED.load(Ordering::SeqCst), 2); +} + +#[test] +fn buffer_from_arraybuffer_shares_storage_and_checks_its_window() { + let env = test_env(); + let mut arraybuffer = std::ptr::null_mut(); + let mut bytes = std::ptr::null_mut(); + assert_eq!( + unsafe { napi_create_arraybuffer(env, 8, &mut bytes, &mut arraybuffer) }, + NapiStatus::Ok + ); + let mut buffer = std::ptr::null_mut(); + assert_eq!( + unsafe { node_api_create_buffer_from_arraybuffer(env, arraybuffer, 2, 4, &mut buffer) }, + NapiStatus::Ok + ); + let mut is_buffer = false; + assert_eq!( + unsafe { napi_is_buffer(env, buffer, &mut is_buffer) }, + NapiStatus::Ok + ); + assert!(is_buffer); + let mut data = std::ptr::null_mut(); + let mut length = 0; + assert_eq!( + unsafe { napi_get_buffer_info(env, buffer, &mut data, &mut length) }, + NapiStatus::Ok + ); + assert_eq!(length, 4); + assert_eq!(data, unsafe { bytes.cast::().add(2) }.cast()); + unsafe { *data.cast::() = 0x5a }; + assert_eq!(unsafe { *bytes.cast::().add(2) }, 0x5a); + + // Node throws ERR_OUT_OF_RANGE and returns the throw's own status. + let mut ignored = std::ptr::null_mut(); + assert_eq!( + unsafe { node_api_create_buffer_from_arraybuffer(env, arraybuffer, 6, 4, &mut ignored) }, + NapiStatus::Ok + ); + let range_error = take_exception(env); + assert_eq!( + read_utf8(env, named(env, range_error, c"name")), + "RangeError" + ); + assert_eq!( + read_utf8(env, named(env, range_error, c"code")), + "ERR_OUT_OF_RANGE" + ); + assert_eq!( + unsafe { + node_api_create_buffer_from_arraybuffer(env, arraybuffer, usize::MAX, 2, &mut ignored) + }, + NapiStatus::Ok + ); + take_exception(env); + assert_eq!( + unsafe { node_api_create_buffer_from_arraybuffer(env, buffer, 0, 1, &mut ignored) }, + NapiStatus::InvalidArg + ); +} + +thread_local! { + static SEEN_MODULE_FILE: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn module_file_name(env: NapiEnv) -> String { + let mut name = std::ptr::null(); + assert_eq!( + unsafe { node_api_get_module_file_name(env, &mut name) }, + NapiStatus::Ok + ); + unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() +} + +unsafe extern "C" fn record_module_callback(env: NapiEnv, _info: NapiCallbackInfo) -> NapiValue { + SEEN_MODULE_FILE.with(|seen| seen.borrow_mut().push(module_file_name(env))); + std::ptr::null_mut() +} + +#[test] +fn module_file_name_follows_the_addon_whose_code_runs() { + let env = test_env(); + assert_eq!(module_file_name(env), "", "no addon has loaded yet"); + let first = register_module(env, Path::new("/opt/perry app/first.node"), 10); + let second = register_module(env, Path::new("/opt/perry app/second.node"), 9); + assert_eq!((first, second), (Some(0), Some(1))); + const FIRST: &str = "file:///opt/perry%20app/first.node"; + const SECOND: &str = "file:///opt/perry%20app/second.node"; + + let function = with_active_module(env, first, || { + assert_eq!(module_file_name(env), FIRST); + with_active_module(env, second, || assert_eq!(module_file_name(env), SECOND)); + assert_eq!(module_file_name(env), FIRST); + let mut function = std::ptr::null_mut(); + assert_eq!( + unsafe { + napi_create_function( + env, + c"record".as_ptr(), + NAPI_AUTO_LENGTH, + Some(record_module_callback), + std::ptr::null_mut(), + &mut function, + ) + }, + NapiStatus::Ok + ); + function + }); + assert_eq!(active_module(env), None); + + // Called while the second addon is running, the first addon's function + // still observes its own module. + SEEN_MODULE_FILE.with(|seen| seen.borrow_mut().clear()); + with_active_module(env, second, || { + let mut receiver = std::ptr::null_mut(); + unsafe { + assert_eq!(napi_get_undefined(env, &mut receiver), NapiStatus::Ok); + assert_eq!( + napi_call_function( + env, + receiver, + function, + 0, + std::ptr::null(), + std::ptr::null_mut() + ), + NapiStatus::Ok + ); + } + assert_eq!(module_file_name(env), SECOND); + }); + SEEN_MODULE_FILE.with(|seen| assert_eq!(*seen.borrow(), [FIRST.to_string()])); +} + +thread_local! { + static UNCAUGHT_CALLBACK_ERRORS: RefCell = const { RefCell::new(0) }; +} + +extern "C" fn count_uncaught_callback_error(_closure: *const ClosureHeader, _error: f64) -> f64 { + UNCAUGHT_CALLBACK_ERRORS.with(|count| *count.borrow_mut() += 1); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn listen_for_uncaught_exceptions() { + crate::os::test_clear_process_event_listeners(); + UNCAUGHT_CALLBACK_ERRORS.with(|count| *count.borrow_mut() = 0); + crate::closure::js_register_closure_arity(count_uncaught_callback_error as *const u8, 1); + let listener = crate::closure::js_closure_alloc(count_uncaught_callback_error as *const u8, 0); + let listener = JSValue::pointer(listener.cast()).bits(); + let event = crate::string::js_string_from_bytes(b"uncaughtException".as_ptr(), 17); + let event = JSValue::string_ptr(event).bits(); + let _ = crate::os::js_process_on(event as i64, listener as i64); +} + +fn uncaught_callback_errors() -> usize { + UNCAUGHT_CALLBACK_ERRORS.with(|count| *count.borrow()) +} + +static THROWING_TSFN_CALLS: AtomicUsize = AtomicUsize::new(0); + +unsafe extern "C" fn throwing_tsfn_call_js( + env: NapiEnv, + _function: NapiValue, + _context: *mut c_void, + _data: *mut c_void, +) { + THROWING_TSFN_CALLS.fetch_add(1, Ordering::SeqCst); + assert_eq!( + napi_throw_error(env, std::ptr::null(), c"tsfn failed".as_ptr()), + NapiStatus::Ok + ); +} + +fn call_throwing_tsfn(env: NapiEnv, module: Option) { + let name = utf8(env, c"throwing"); + let mut tsfn = std::ptr::null_mut(); + with_active_module(env, module, || { + assert_eq!( + unsafe { + napi_create_threadsafe_function( + env, + std::ptr::null_mut(), + std::ptr::null_mut(), + name, + 0, + 1, + std::ptr::null_mut(), + None, + std::ptr::null_mut(), + Some(throwing_tsfn_call_js), + &mut tsfn, + ) + }, + NapiStatus::Ok + ); + }); + unsafe { + assert_eq!( + napi_call_threadsafe_function( + tsfn, + std::ptr::null_mut(), + NapiThreadsafeFunctionCallMode::Nonblocking + ), + NapiStatus::Ok + ); + assert_eq!( + napi_release_threadsafe_function(tsfn, NapiThreadsafeFunctionReleaseMode::Release), + NapiStatus::Ok + ); + } + process_pending(); +} + +#[test] +fn callback_exceptions_follow_the_declaring_modules_policy() { + let env = test_env(); + listen_for_uncaught_exceptions(); + let legacy = register_module(env, Path::new("/opt/legacy.node"), 8); + let current = register_module(env, Path::new("/opt/current.node"), 10); + + // Node-API 10: an exception left by a TSFN callback is uncaught. + THROWING_TSFN_CALLS.store(0, Ordering::SeqCst); + call_throwing_tsfn(env, current); + assert_eq!(THROWING_TSFN_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(uncaught_callback_errors(), 1); + assert_eq!(pending_exception(env), None); + + // Below 10, Node emits DEP0168 instead and drops the exception, which + // must not leak into the next Node-API call either. + call_throwing_tsfn(env, legacy); + assert_eq!(THROWING_TSFN_CALLS.load(Ordering::SeqCst), 2); + assert_eq!(uncaught_callback_errors(), 1); + assert_eq!(pending_exception(env), None); + + // Finalizers and async-work completion enforce the policy for every + // version; a shutting-down environment drops the exception. + assert_eq!( + unsafe { napi_throw_error(env, std::ptr::null(), c"finalizer".as_ptr()) }, + NapiStatus::Ok + ); + settle_callback_exception(env, legacy, true); + assert_eq!(uncaught_callback_errors(), 2); + with_env_mut(env, |env| env.shutting_down = true); + assert_eq!( + unsafe { napi_throw_error(env, std::ptr::null(), c"late".as_ptr()) }, + NapiStatus::Ok + ); + settle_callback_exception(env, current, true); + assert_eq!(uncaught_callback_errors(), 2); + assert_eq!(pending_exception(env), None); + crate::os::test_clear_process_event_listeners(); +} + +static ASYNC_THROWN: AtomicUsize = AtomicUsize::new(0); + +unsafe extern "C" fn quiet_execute(_env: NapiEnv, _data: *mut c_void) {} + +unsafe extern "C" fn throwing_complete(env: NapiEnv, _status: NapiStatus, _data: *mut c_void) { + ASYNC_THROWN.fetch_add(1, Ordering::SeqCst); + assert_eq!( + napi_throw_error(env, std::ptr::null(), c"complete failed".as_ptr()), + NapiStatus::Ok + ); +} + +#[test] +fn async_work_completion_exceptions_are_uncaught() { + ASYNC_THROWN.store(0, Ordering::SeqCst); + let env = test_env(); + listen_for_uncaught_exceptions(); + let legacy = register_module(env, Path::new("/opt/legacy.node"), 8); + let name = utf8(env, c"work"); + let mut work = std::ptr::null_mut(); + with_active_module(env, legacy, || { + assert_eq!( + unsafe { + napi_create_async_work( + env, + std::ptr::null_mut(), + name, + Some(quiet_execute), + Some(throwing_complete), + std::ptr::null_mut(), + &mut work, + ) + }, + NapiStatus::Ok + ); + }); + assert_eq!(unsafe { napi_queue_async_work(env, work) }, NapiStatus::Ok); + for _ in 0..500 { + process_pending(); + if ASYNC_THROWN.load(Ordering::SeqCst) != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(ASYNC_THROWN.load(Ordering::SeqCst), 1); + assert_eq!(uncaught_callback_errors(), 1); + assert_eq!(pending_exception(env), None); + crate::os::test_clear_process_event_listeners(); +} diff --git a/crates/perry-runtime/src/node_api_host/values.rs b/crates/perry-runtime/src/node_api_host/values.rs index 3b1139865f..ddeaea38ea 100644 --- a/crates/perry-runtime/src/node_api_host/values.rs +++ b/crates/perry-runtime/src/node_api_host/values.rs @@ -31,6 +31,11 @@ fn write_handle(env: NapiEnv, bits: u64, result: *mut NapiValue) -> NapiStatus { fn value_as_number(bits: u64) -> Option { let value = JSValue::from_bits(bits); if value.is_int32() { + // A compiled class constructor shares the INT32 encoding. It is a + // function, as `napi_typeof` reports, not its class id (#10461). + if crate::object::class_ref_id(f64::from_bits(bits)).is_some() { + return None; + } Some(value.as_int32() as f64) } else if value.is_number() { Some(value.as_number()) @@ -286,7 +291,9 @@ pub unsafe extern "C" fn napi_create_int32( value: i32, result: *mut NapiValue, ) -> NapiStatus { - write_handle(env, JSValue::int32(value).bits(), result) + // A plain double, like every other JavaScript number: the INT32 encoding + // is shared with class constructor refs, so `1` could read as a class. + write_handle(env, JSValue::number(value as f64).bits(), result) } #[no_mangle] @@ -295,12 +302,7 @@ pub unsafe extern "C" fn napi_create_uint32( value: u32, result: *mut NapiValue, ) -> NapiStatus { - let bits = if value <= i32::MAX as u32 { - JSValue::int32(value as i32).bits() - } else { - JSValue::number(value as f64).bits() - }; - write_handle(env, bits, result) + write_handle(env, JSValue::number(value as f64).bits(), result) } #[no_mangle] @@ -420,27 +422,27 @@ pub unsafe extern "C" fn napi_typeof( return set_status(env, NapiStatus::InvalidArg, "value is not a live handle"); }; let js = JSValue::from_bits(bits); - let value_type = if js.is_undefined() { - NapiValueType::Undefined - } else if js.is_null() { + let value_type = if js.is_null() { NapiValueType::Null - } else if js.is_bool() { - NapiValueType::Boolean - } else if js.is_number() || js.is_int32() { - NapiValueType::Number - } else if js.is_any_string() { - NapiValueType::String - } else if js.is_bigint() { - NapiValueType::Bigint - } else if crate::symbol::js_is_symbol(f64::from_bits(bits)) != 0 { - NapiValueType::Symbol } else if js.is_pointer() && super::metadata::is_external_owner(js.as_pointer::() as usize) { NapiValueType::External - } else if js.is_pointer() && crate::closure::is_closure_ptr(js.as_pointer::() as usize) { - NapiValueType::Function } else { - NapiValueType::Object + // Everything else classifies exactly as the `typeof` operator does, + // so Perry's other callable representations (INT32-tagged class + // refs, class objects, callable proxies) are functions here too + // (#10461). + use crate::builtins::arithmetic::ValueTypeofTag; + match crate::builtins::arithmetic::classify_value_typeof(f64::from_bits(bits)) { + ValueTypeofTag::Undefined => NapiValueType::Undefined, + ValueTypeofTag::Object => NapiValueType::Object, + ValueTypeofTag::Boolean => NapiValueType::Boolean, + ValueTypeofTag::Number => NapiValueType::Number, + ValueTypeofTag::String => NapiValueType::String, + ValueTypeofTag::Function => NapiValueType::Function, + ValueTypeofTag::BigInt => NapiValueType::Bigint, + ValueTypeofTag::Symbol => NapiValueType::Symbol, + } }; *result = value_type; ok(env) @@ -511,6 +513,112 @@ pub unsafe extern "C" fn napi_create_string_utf16( create_string(env, &wtf8, true, result) } +// Node creates a property key as an internalized V8 string. Perry strings have +// no separate internalized form, so a key is an ordinary string. +#[no_mangle] +pub unsafe extern "C" fn node_api_create_property_key_latin1( + env: NapiEnv, + value: *const c_char, + length: usize, + result: *mut NapiValue, +) -> NapiStatus { + napi_create_string_latin1(env, value, length, result) +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_create_property_key_utf8( + env: NapiEnv, + value: *const c_char, + length: usize, + result: *mut NapiValue, +) -> NapiStatus { + napi_create_string_utf8(env, value, length, result) +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_create_property_key_utf16( + env: NapiEnv, + value: *const u16, + length: usize, + result: *mut NapiValue, +) -> NapiStatus { + napi_create_string_utf16(env, value, length, result) +} + +/// Finish an external string that was copied into a Perry string. This is the +/// path Node itself takes when V8 cannot adopt external storage: report +/// `copied = true` and run the finalizer before returning, so the addon's +/// buffer is released exactly once. +unsafe fn finish_copied_external_string( + env: NapiEnv, + status: NapiStatus, + data: *mut c_void, + finalize_callback: NapiFinalize, + finalize_hint: *mut c_void, + copied: *mut bool, +) -> NapiStatus { + if status != NapiStatus::Ok { + return status; + } + if !copied.is_null() { + *copied = true; + } + if let Some(callback) = finalize_callback { + let exception_was_pending = pending_exception(env).is_some(); + callback(env, data, finalize_hint); + if !exception_was_pending { + super::modules::settle_callback_exception( + env, + super::modules::active_module(env), + true, + ); + } + } + ok(env) +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_create_external_string_latin1( + env: NapiEnv, + value: *mut c_char, + length: usize, + finalize_callback: NapiFinalize, + finalize_hint: *mut c_void, + result: *mut NapiValue, + copied: *mut bool, +) -> NapiStatus { + let status = napi_create_string_latin1(env, value, length, result); + finish_copied_external_string( + env, + status, + value.cast(), + finalize_callback, + finalize_hint, + copied, + ) +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_create_external_string_utf16( + env: NapiEnv, + value: *mut u16, + length: usize, + finalize_callback: NapiFinalize, + finalize_hint: *mut c_void, + result: *mut NapiValue, + copied: *mut bool, +) -> NapiStatus { + let status = napi_create_string_utf16(env, value, length, result); + finish_copied_external_string( + env, + status, + value.cast(), + finalize_callback, + finalize_hint, + copied, + ) +} + #[no_mangle] pub unsafe extern "C" fn napi_get_value_string_utf8( env: NapiEnv, @@ -974,10 +1082,11 @@ fn create_error_kind( as *mut crate::string::StringHeader; let scope = crate::gc::RuntimeHandleScope::new(); let message_root = scope.root_string_ptr(message_ptr); - // `kind` is js_typeerror_new / js_rangeerror_new / js_error_new_with_message, - // all of which route through `alloc_error`; that opens its own handle scope - // and roots `message` before its first allocation, so a scoped raw argument - // is sound here (#7341 self-rooting entry point). + // `kind` is js_typeerror_new / js_rangeerror_new / js_syntaxerror_new / + // js_error_new_with_message, all of which route through `alloc_error`; + // that opens its own handle scope and roots `message` before its first + // allocation, so a scoped raw argument is sound here (#7341 self-rooting + // entry point). let error = message_root.with_const_ptr::(|ptr| kind(ptr.cast_mut())); let status = write_handle(env, pointer_bits(error.cast()), result); @@ -1024,6 +1133,16 @@ pub unsafe extern "C" fn napi_create_range_error( create_error_kind(env, code, message, result, crate::error::js_rangeerror_new) } +#[no_mangle] +pub unsafe extern "C" fn node_api_create_syntax_error( + env: NapiEnv, + code: NapiValue, + message: NapiValue, + result: *mut NapiValue, +) -> NapiStatus { + create_error_kind(env, code, message, result, crate::error::js_syntaxerror_new) +} + #[no_mangle] pub unsafe extern "C" fn napi_is_error( env: NapiEnv, @@ -1176,6 +1295,34 @@ pub unsafe extern "C" fn napi_create_symbol( write_handle(env, pointer_bits(symbol.cast()), result) } +#[no_mangle] +pub unsafe extern "C" fn node_api_symbol_for( + env: NapiEnv, + description: *const c_char, + length: usize, + result: *mut NapiValue, +) -> NapiStatus { + if result.is_null() { + return set_status(env, NapiStatus::InvalidArg, "result must not be null"); + } + let mut key = std::ptr::null_mut(); + let status = napi_create_string_utf8(env, description, length, &mut key); + if status != NapiStatus::Ok { + return status; + } + let Ok(key_bits) = value_bits(env, key) else { + return set_status( + env, + NapiStatus::InvalidArg, + "symbol key is not a live handle", + ); + }; + // A string key cannot throw. The registry copies the key text out before + // creating the symbol, which is process-lifetime rather than GC-owned. + let symbol = crate::symbol::js_symbol_for(f64::from_bits(key_bits)); + write_handle(env, symbol.to_bits(), result) +} + #[no_mangle] pub unsafe extern "C" fn napi_create_date( env: NapiEnv, @@ -1282,6 +1429,15 @@ pub unsafe extern "C" fn napi_throw_range_error( throw_c_error(env, code, message, napi_create_range_error) } +#[no_mangle] +pub unsafe extern "C" fn node_api_throw_syntax_error( + env: NapiEnv, + code: *const c_char, + message: *const c_char, +) -> NapiStatus { + throw_c_error(env, code, message, node_api_create_syntax_error) +} + #[no_mangle] pub unsafe extern "C" fn napi_instanceof( env: NapiEnv, diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 5f0b4afe33..1ae09c00e7 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -149,6 +149,11 @@ pub extern "C" fn js_object_coerce(value: f64) -> f64 { if jsval.is_any_string() { return crate::builtins::js_boxed_string_new(value, 1); } + if crate::object::class_ref_id(value).is_some() { + // A constructor ClassRef shares the INT32 encoding but is already a + // Function object, so ToObject returns it unchanged (#10461). + return value; + } crate::builtins::js_boxed_number_new(value) } diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 41f886a772..1a2300d051 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -212,6 +212,7 @@ mod node_api_symbol_inventory_tests { include_str!("../../../../../perry-runtime/src/node_api_host/loader.rs"), include_str!("../../../../../perry-runtime/src/node_api_host/metadata.rs"), include_str!("../../../../../perry-runtime/src/node_api_host/mod.rs"), + include_str!("../../../../../perry-runtime/src/node_api_host/modules.rs"), include_str!("../../../../../perry-runtime/src/node_api_host/promises.rs"), include_str!("../../../../../perry-runtime/src/node_api_host/properties.rs"), include_str!("../../../../../perry-runtime/src/node_api_host/scopes.rs"), diff --git a/crates/perry/src/commands/compile/native_addon_sidecar.rs b/crates/perry/src/commands/compile/native_addon_sidecar.rs index 8c1a4a5765..614306d96a 100644 --- a/crates/perry/src/commands/compile/native_addon_sidecar.rs +++ b/crates/perry/src/commands/compile/native_addon_sidecar.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use super::{host_target_triple, rust_target_triple, CompilationContext, NativeAddonModule}; pub(super) const NODE_API_POLICY_VERSION: u32 = 1; -pub(super) const NODE_API_VERSION: u32 = 8; +pub(super) const NODE_API_VERSION: u32 = 10; pub(super) const SHIPPING_MODEL: &str = "sidecar-v1"; #[derive(Serialize)] diff --git a/crates/perry/tests/fixtures/node_api_host/addon_v10.c b/crates/perry/tests/fixtures/node_api_host/addon_v10.c new file mode 100644 index 0000000000..f2dd71e0e9 --- /dev/null +++ b/crates/perry/tests/fixtures/node_api_host/addon_v10.c @@ -0,0 +1,328 @@ +/* Node-API 9/10 fixture for #10456 and #10461. + * + * Built three times with -DFIXTURE_API_VERSION=10, 8 and 11. It declares its + * own prototypes so neither Node's headers nor a JavaScript engine are needed + * to build it, and every entry point it imports must resolve from the host. + */ +#include +#include +#include +#include + +#define NAPI_IMPORT __attribute__((visibility("default"))) +#define NAPI_EXPORT __attribute__((visibility("default"))) +#define NAPI_AUTO_LENGTH SIZE_MAX + +#ifndef FIXTURE_API_VERSION +#define FIXTURE_API_VERSION 10 +#endif + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef void* napi_async_work; +typedef void* napi_threadsafe_function; +typedef int32_t napi_status; +typedef int32_t napi_valuetype; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef void (*napi_finalize)(napi_env, void*, void*); +typedef void (*napi_async_execute_callback)(napi_env, void*); +typedef void (*napi_async_complete_callback)(napi_env, napi_status, void*); +typedef void (*napi_threadsafe_function_call_js)(napi_env, napi_value, void*, void*); + +NAPI_IMPORT napi_status napi_create_function( + napi_env, const char*, size_t, napi_callback, void*, napi_value*); +NAPI_IMPORT napi_status napi_get_cb_info( + napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**); +NAPI_IMPORT napi_status napi_set_named_property(napi_env, napi_value, const char*, napi_value); +NAPI_IMPORT napi_status napi_set_property(napi_env, napi_value, napi_value, napi_value); +NAPI_IMPORT napi_status napi_create_object(napi_env, napi_value*); +NAPI_IMPORT napi_status napi_create_int32(napi_env, int32_t, napi_value*); +NAPI_IMPORT napi_status napi_create_uint32(napi_env, uint32_t, napi_value*); +NAPI_IMPORT napi_status napi_create_string_utf8(napi_env, const char*, size_t, napi_value*); +NAPI_IMPORT napi_status napi_get_value_string_utf8(napi_env, napi_value, char*, size_t, size_t*); +NAPI_IMPORT napi_status napi_get_value_double(napi_env, napi_value, double*); +NAPI_IMPORT napi_status napi_get_value_uint32(napi_env, napi_value, uint32_t*); +NAPI_IMPORT napi_status napi_get_boolean(napi_env, bool, napi_value*); +NAPI_IMPORT napi_status napi_get_null(napi_env, napi_value*); +NAPI_IMPORT napi_status napi_typeof(napi_env, napi_value, napi_valuetype*); +NAPI_IMPORT napi_status napi_new_instance(napi_env, napi_value, size_t, const napi_value*, napi_value*); +NAPI_IMPORT napi_status napi_get_version(napi_env, uint32_t*); +NAPI_IMPORT napi_status napi_throw_error(napi_env, const char*, const char*); +NAPI_IMPORT napi_status napi_create_async_work( + napi_env, napi_value, napi_value, napi_async_execute_callback, + napi_async_complete_callback, void*, napi_async_work*); +NAPI_IMPORT napi_status napi_queue_async_work(napi_env, napi_async_work); +NAPI_IMPORT napi_status napi_delete_async_work(napi_env, napi_async_work); +NAPI_IMPORT napi_status napi_create_threadsafe_function( + napi_env, napi_value, napi_value, napi_value, size_t, size_t, void*, napi_finalize, + void*, napi_threadsafe_function_call_js, napi_threadsafe_function*); +NAPI_IMPORT napi_status napi_call_threadsafe_function(napi_threadsafe_function, void*, int32_t); +NAPI_IMPORT napi_status napi_release_threadsafe_function(napi_threadsafe_function, int32_t); + +/* Node-API 9 */ +NAPI_IMPORT napi_status node_api_symbol_for(napi_env, const char*, size_t, napi_value*); +NAPI_IMPORT napi_status node_api_create_syntax_error(napi_env, napi_value, napi_value, napi_value*); +NAPI_IMPORT napi_status node_api_throw_syntax_error(napi_env, const char*, const char*); +NAPI_IMPORT napi_status node_api_get_module_file_name(napi_env, const char**); + +/* Node-API 10 */ +NAPI_IMPORT napi_status node_api_create_external_string_latin1( + napi_env, char*, size_t, napi_finalize, void*, napi_value*, bool*); +NAPI_IMPORT napi_status node_api_create_external_string_utf16( + napi_env, uint16_t*, size_t, napi_finalize, void*, napi_value*, bool*); +NAPI_IMPORT napi_status node_api_create_property_key_latin1(napi_env, const char*, size_t, napi_value*); +NAPI_IMPORT napi_status node_api_create_property_key_utf8(napi_env, const char*, size_t, napi_value*); +NAPI_IMPORT napi_status node_api_create_property_key_utf16(napi_env, const uint16_t*, size_t, napi_value*); +NAPI_IMPORT napi_status node_api_create_buffer_from_arraybuffer( + napi_env, napi_value, size_t, size_t, napi_value*); + +static napi_value arg0(napi_env env, napi_callback_info info, napi_value* extra) { + size_t argc = 3; + napi_value argv[3] = {0, 0, 0}; + napi_get_cb_info(env, info, &argc, argv, 0, 0); + if (extra) { + extra[0] = argv[1]; + extra[1] = argv[2]; + } + return argv[0]; +} + +static napi_value string(napi_env env, const char* text) { + napi_value value = 0; + napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &value); + return value; +} + +static napi_value TypeOf(napi_env env, napi_callback_info info) { + static const char* names[] = {"undefined", "null", "boolean", "number", "string", + "symbol", "object", "function", "external", "bigint"}; + napi_valuetype type = 0; + if (napi_typeof(env, arg0(env, info, 0), &type) != 0 || type < 0 || type > 9) { + return string(env, "napi_typeof failed"); + } + return string(env, names[type]); +} + +static napi_value IntTypeOf(napi_env env, napi_callback_info info) { + uint32_t input = 0; + napi_value created = 0; + napi_valuetype type = 0; + napi_get_value_uint32(env, arg0(env, info, 0), &input); + napi_create_int32(env, (int32_t)input, &created); + napi_typeof(env, created, &type); + return string(env, type == 3 ? "number" : "not-a-number"); +} + +static napi_value NumberStatus(napi_env env, napi_callback_info info) { + double ignored = 0; + napi_value result = 0; + napi_create_int32(env, napi_get_value_double(env, arg0(env, info, 0), &ignored), &result); + return result; +} + +static napi_value Construct(napi_env env, napi_callback_info info) { + napi_value result = 0; + if (napi_new_instance(env, arg0(env, info, 0), 0, 0, &result) != 0) { + napi_get_null(env, &result); + } + return result; +} + +static napi_value Version(napi_env env, napi_callback_info info) { + uint32_t version = 0; + napi_value result = 0; + (void)info; + napi_get_version(env, &version); + napi_create_uint32(env, version, &result); + return result; +} + +static napi_value SymbolFor(napi_env env, napi_callback_info info) { + char key[64]; + size_t length = 0; + napi_value result = 0; + napi_get_value_string_utf8(env, arg0(env, info, 0), key, sizeof key, &length); + node_api_symbol_for(env, key, length, &result); + return result; +} + +static napi_value SyntaxError(napi_env env, napi_callback_info info) { + napi_value rest[2] = {0, 0}; + napi_value code = arg0(env, info, rest); + napi_value result = 0; + node_api_create_syntax_error(env, code, rest[0], &result); + return result; +} + +static napi_value ThrowSyntax(napi_env env, napi_callback_info info) { + (void)info; + node_api_throw_syntax_error(env, "ERR_FIXTURE_SYNTAX", "fixture syntax"); + return 0; +} + +static napi_value ModuleFile(napi_env env, napi_callback_info info) { + const char* file = 0; + (void)info; + if (node_api_get_module_file_name(env, &file) != 0 || file == 0) { + return string(env, "node_api_get_module_file_name failed"); + } + return string(env, file); +} + +static int finalized_strings = 0; +static bool contract_held = true; +static char latin1_storage[] = "external latin1 \xe9"; +static uint16_t utf16_storage[] = {'u', 't', 'f', '1', '6', ' ', 0xd83d, 0xde00, 0}; + +static void count_string_finalizer(napi_env env, void* data, void* hint) { + (void)env; + (void)data; + (void)hint; + finalized_strings++; +} + +/* Node may adopt the storage (finalizer later) or copy it (finalizer already + * ran); either way `copied` must describe what happened. */ +static void check_contract(bool copied, int before) { + if (copied != (finalized_strings == before + 1)) { + contract_held = false; + } +} + +static napi_value ExternalLatin1(napi_env env, napi_callback_info info) { + napi_value result = 0; + bool copied = false; + int before = finalized_strings; + (void)info; + if (node_api_create_external_string_latin1(env, latin1_storage, NAPI_AUTO_LENGTH, + count_string_finalizer, 0, &result, + &copied) != 0) { + return string(env, "external latin1 failed"); + } + check_contract(copied, before); + return result; +} + +static napi_value ExternalUtf16(napi_env env, napi_callback_info info) { + napi_value result = 0; + bool copied = false; + int before = finalized_strings; + (void)info; + if (node_api_create_external_string_utf16(env, utf16_storage, 8, count_string_finalizer, 0, + &result, &copied) != 0) { + return string(env, "external utf16 failed"); + } + check_contract(copied, before); + return result; +} + +static napi_value ExternalContract(napi_env env, napi_callback_info info) { + napi_value result = 0; + (void)info; + napi_get_boolean(env, contract_held, &result); + return result; +} + +static napi_value KeysObject(napi_env env, napi_callback_info info) { + static const uint16_t utf16_key[] = {'u', 't', 'f', '1', '6', 0xe9}; + napi_value object = 0, key = 0, value = 0; + (void)info; + napi_create_object(env, &object); + node_api_create_property_key_latin1(env, "latin1\xe9", NAPI_AUTO_LENGTH, &key); + napi_create_int32(env, 1, &value); + napi_set_property(env, object, key, value); + node_api_create_property_key_utf8(env, "utf8", 4, &key); + napi_create_int32(env, 2, &value); + napi_set_property(env, object, key, value); + node_api_create_property_key_utf16(env, utf16_key, 6, &key); + napi_create_int32(env, 3, &value); + napi_set_property(env, object, key, value); + return object; +} + +static napi_value BufferView(napi_env env, napi_callback_info info) { + napi_value rest[2] = {0, 0}; + napi_value arraybuffer = arg0(env, info, rest); + double offset = 0, length = 0; + napi_value result = 0; + napi_get_value_double(env, rest[0], &offset); + napi_get_value_double(env, rest[1], &length); + if (node_api_create_buffer_from_arraybuffer(env, arraybuffer, (size_t)offset, (size_t)length, + &result) != 0) { + return string(env, "node_api_create_buffer_from_arraybuffer failed"); + } + return result; +} + +static napi_async_work pending_work = 0; + +static void work_execute(napi_env env, void* data) { + (void)env; + (void)data; +} + +static void work_complete(napi_env env, napi_status status, void* data) { + (void)status; + (void)data; + napi_delete_async_work(env, pending_work); + pending_work = 0; + napi_throw_error(env, 0, "async work completion failed"); +} + +static napi_value ThrowFromWork(napi_env env, napi_callback_info info) { + (void)info; + if (napi_create_async_work(env, 0, string(env, "fixture"), work_execute, work_complete, 0, + &pending_work) != 0 || + napi_queue_async_work(env, pending_work) != 0) { + napi_throw_error(env, 0, "could not queue async work"); + } + return 0; +} + +static void tsfn_call_js(napi_env env, napi_value function, void* context, void* data) { + (void)function; + (void)context; + (void)data; + if (env) { + napi_throw_error(env, 0, FIXTURE_API_VERSION >= 10 ? "tsfn callback failed (10)" + : "tsfn callback failed (8)"); + } +} + +static napi_value ThrowFromTsfn(napi_env env, napi_callback_info info) { + napi_threadsafe_function tsfn = 0; + (void)info; + napi_create_threadsafe_function(env, 0, 0, string(env, "fixture"), 0, 1, 0, 0, 0, + tsfn_call_js, &tsfn); + napi_call_threadsafe_function(tsfn, 0, 0); + napi_release_threadsafe_function(tsfn, 0); + return 0; +} + +NAPI_EXPORT int32_t node_api_module_get_api_version_v1(void) { return FIXTURE_API_VERSION; } + +NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) { + static const struct { + const char* name; + napi_callback callback; + } methods[] = { + {"typeOf", TypeOf}, {"intTypeOf", IntTypeOf}, + {"numberStatus", NumberStatus}, {"construct", Construct}, + {"version", Version}, {"symbolFor", SymbolFor}, + {"syntaxError", SyntaxError}, {"throwSyntax", ThrowSyntax}, + {"moduleFile", ModuleFile}, {"externalLatin1", ExternalLatin1}, + {"externalUtf16", ExternalUtf16}, {"externalContract", ExternalContract}, + {"keysObject", KeysObject}, {"bufferView", BufferView}, + {"throwFromWork", ThrowFromWork}, {"throwFromTsfn", ThrowFromTsfn}, + }; + for (size_t i = 0; i < sizeof methods / sizeof methods[0]; i++) { + napi_value function = 0; + if (napi_create_function(env, methods[i].name, NAPI_AUTO_LENGTH, methods[i].callback, 0, + &function) != 0 || + napi_set_named_property(env, exports, methods[i].name, function) != 0) { + return 0; + } + } + return exports; +} diff --git a/crates/perry/tests/fixtures/node_api_host/addon_v10_expected.txt b/crates/perry/tests/fixtures/node_api_host/addon_v10_expected.txt new file mode 100644 index 0000000000..400207a837 --- /dev/null +++ b/crates/perry/tests/fixtures/node_api_host/addon_v10_expected.txt @@ -0,0 +1,31 @@ +typeof class Plain function function +typeof class Tagged function function +typeof class MyError extends Error function function +typeof class expression function function +typeof function decl function function +typeof arrow function function +typeof bound function function +typeof Map function function +typeof object object object +typeof array object object +typeof null object null +typeof undefined undefined undefined +typeof number number number +typeof string string string +typeof symbol symbol symbol +typeof bigint bigint bigint +typeof boolean boolean boolean +created int32 number,number,number,number +number status 6 0 +new instance true made +version 10 10 +symbol for true +syntax error true SyntaxError ERR_FIXTURE made syntax +throw syntax true ERR_FIXTURE_SYNTAX fixture syntax +module file current.node legacy.node +external external latin1 é utf16 😀 true +keys {"latin1é":1,"utf8":2,"utf16é":3} +buffer true 4 3,4,5,6 +shared 42 +range ERR_OUT_OF_RANGE The byte offset + length is out of range +callback exceptions uncaught: async work completion failed | uncaught: tsfn callback failed (10) | warning: DEP0168 diff --git a/crates/perry/tests/fixtures/node_api_host/addon_v10_main.js b/crates/perry/tests/fixtures/node_api_host/addon_v10_main.js new file mode 100644 index 0000000000..1da5ba8413 --- /dev/null +++ b/crates/perry/tests/fixtures/node_api_host/addon_v10_main.js @@ -0,0 +1,92 @@ +// Driver for addon_v10.c (#10456, #10461). Its stdout under Node 26 is +// addon_v10_expected.txt; the Perry executable must print the same. +const fixture = require("fixture-v10") +if (process.env.FIXTURE_LOAD_NEWER) { + // Node 26.5.1 itself crashes on this rejection, so only Perry runs it. + try { + fixture.loadNewer() + console.log("newer loaded") + } catch (error) { + console.log("newer rejected", error.code, error.message) + } + process.exit(0) +} +const { current, legacy } = fixture + +class Plain {} +class Tagged { + constructor() { + this.tag = "made" + } +} +class MyError extends Error {} +const Expr = class {} +function decl() {} + +for (const [name, value] of [ + ["class Plain", Plain], + ["class Tagged", Tagged], + ["class MyError extends Error", MyError], + ["class expression", Expr], + ["function decl", decl], + ["arrow", () => 1], + ["bound", decl.bind(null)], + ["Map", Map], + ["object", {}], + ["array", []], + ["null", null], + ["undefined", undefined], + ["number", 1.5], + ["string", "text"], + ["symbol", Symbol("s")], + ["bigint", 10n], + ["boolean", true], +]) { + console.log("typeof", name, typeof value, current.typeOf(value)) +} +console.log("created int32", [1, 2, 3, 4].map((n) => current.intTypeOf(n)).join(",")) +console.log("number status", current.numberStatus(Plain), current.numberStatus(3)) +const made = current.construct(Tagged) +console.log("new instance", made instanceof Tagged, made.tag) +console.log("version", current.version(), legacy.version()) +console.log("symbol for", current.symbolFor("perry.fixture") === Symbol.for("perry.fixture")) +const syntax = current.syntaxError("ERR_FIXTURE", "made syntax") +console.log("syntax error", syntax instanceof SyntaxError, syntax.name, syntax.code, syntax.message) +try { + current.throwSyntax() + console.log("throw syntax did not throw") +} catch (error) { + console.log("throw syntax", error instanceof SyntaxError, error.code, error.message) +} +const fileName = (url) => (url.startsWith("file:///") ? url.slice(url.lastIndexOf("/") + 1) : "bad " + url) +console.log("module file", fileName(current.moduleFile()), fileName(legacy.moduleFile())) +console.log("external", current.externalLatin1(), current.externalUtf16(), current.externalContract()) +console.log("keys", JSON.stringify(current.keysObject())) +const bytes = new ArrayBuffer(8) +new Uint8Array(bytes).set([1, 2, 3, 4, 5, 6, 7, 8]) +const view = current.bufferView(bytes, 2, 4) +console.log("buffer", Buffer.isBuffer(view), view.length, Array.from(view).join(",")) +view[0] = 42 +console.log("shared", new Uint8Array(bytes)[2]) +try { + current.bufferView(bytes, 6, 4) + console.log("range did not throw") +} catch (error) { + console.log("range", error.code, error.message) +} + +const events = [] +process.on("uncaughtException", (error) => events.push("uncaught: " + error.message)) +process.on("warning", (warning) => events.push("warning: " + warning.code)) +current.throwFromWork() +current.throwFromTsfn() +legacy.throwFromTsfn() +const started = Date.now() +const wait = () => { + if (events.length >= 3 || Date.now() - started > 10000) { + console.log("callback exceptions", events.sort().join(" | ")) + } else { + setTimeout(wait, 5) + } +} +wait() diff --git a/crates/perry/tests/node_api_host_e2e.rs b/crates/perry/tests/node_api_host_e2e.rs index a498478361..344f0b867b 100644 --- a/crates/perry/tests/node_api_host_e2e.rs +++ b/crates/perry/tests/node_api_host_e2e.rs @@ -15,6 +15,12 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; const ADDON_C: &str = include_str!("fixtures/node_api_host/addon.c"); +#[cfg(unix)] +const ADDON_V10_C: &str = include_str!("fixtures/node_api_host/addon_v10.c"); +#[cfg(unix)] +const ADDON_V10_MAIN: &str = include_str!("fixtures/node_api_host/addon_v10_main.js"); +#[cfg(unix)] +const ADDON_V10_EXPECTED: &str = include_str!("fixtures/node_api_host/addon_v10_expected.txt"); #[cfg(windows)] const ADDON_DEF: &str = include_str!("fixtures/node_api_host/addon.def"); const HOST_SYMBOLS: &str = include_str!("../../perry-runtime/src/node_api_host/symbols.txt"); @@ -388,7 +394,7 @@ console.log("node-api-cache", direct.exports === addon) std::fs::read(sidecar.join("manifest.json")).expect("read Node-API sidecar manifest"); let mut manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes).expect("parse sidecar manifest"); - assert_eq!(manifest["napi_version"], 8); + assert_eq!(manifest["napi_version"], 10); assert_eq!( manifest["addons"][0]["logical_id"], "fixture-addon/addon.node" @@ -539,6 +545,134 @@ console.log("node-api-cache", direct.exports === addon) ); } +/// #10456 / #10461: addons declaring Node-API 9 and 10 load, the version 9 +/// and 10 entry points behave like Node 26, per-module facts follow the addon +/// whose code runs, and `napi_typeof` reports compiled classes as functions. +/// The same fixture runs under Node when it provides Node-API 10, and both +/// must print the checked-in transcript. +#[cfg(unix)] +#[test] +fn node_api_10_addons_match_node() { + if !require_tool("clang") { + return; + } + + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let package = root.join("node_modules/fixture-v10"); + std::fs::create_dir_all(&package).expect("create fixture package"); + std::fs::write( + root.join("package.json"), + r#"{ + "name": "perry-node-api-v10-e2e", + "private": true, + "perry": { + "compilePackages": ["fixture-v10"], + "allow": { "compilePackages": ["fixture-v10"] }, + "nativeAddons": ["fixture-v10"] + } +}"#, + ) + .expect("write project manifest"); + std::fs::write( + package.join("package.json"), + r#"{"name":"fixture-v10","version":"1.0.0","main":"index.js"}"#, + ) + .expect("write addon manifest"); + std::fs::write( + package.join("index.js"), + r#"exports.current = require("./current.node") +exports.legacy = require("./legacy.node") +exports.loadNewer = () => require("./newer.node") +"#, + ) + .expect("write addon wrapper"); + let source = root.join("addon_v10.c"); + std::fs::write(&source, ADDON_V10_C).expect("write Node-API 10 fixture"); + for (version, name) in [(10, "current"), (8, "legacy"), (11, "newer")] { + let mut clang = Command::new("clang"); + clang.current_dir(root).arg("-shared"); + #[cfg(not(target_os = "macos"))] + clang.arg("-fPIC"); + #[cfg(target_os = "macos")] + clang.args(["-undefined", "dynamic_lookup"]); + clang + .arg(format!("-DFIXTURE_API_VERSION={version}")) + .arg("-o") + .arg(package.join(format!("{name}.node"))) + .arg(&source); + run(clang, "Node-API 10 fixture build"); + } + let entry = root.join("main.js"); + std::fs::write(&entry, ADDON_V10_MAIN).expect("write Node-API 10 entry"); + + let executable = root.join("app"); + let compile = compile_app(root, &entry, &executable); + assert!( + compile.status.success(), + "Node-API 10 compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let sidecar = root.join("app.perry-native"); + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(sidecar.join("manifest.json")).expect("read Node-API 10 manifest"), + ) + .expect("parse Node-API 10 manifest"); + assert_eq!(manifest["napi_version"], 10); + + let perry = Command::new(&executable) + .current_dir(root) + .output() + .expect("run Node-API 10 executable"); + assert_eq!( + String::from_utf8_lossy(&perry.stdout), + ADDON_V10_EXPECTED, + "Perry must print Node's Node-API 10 transcript\nstderr:\n{}", + String::from_utf8_lossy(&perry.stderr) + ); + assert!(perry.status.success(), "status: {:?}", perry.status); + + let newer = Command::new(&executable) + .current_dir(root) + .env("FIXTURE_LOAD_NEWER", "1") + .output() + .expect("run Node-API 11 rejection"); + let newer_stdout = String::from_utf8_lossy(&newer.stdout); + assert!( + newer_stdout.contains("newer rejected ERR_DLOPEN_FAILED") + && newer_stdout.contains( + "addon requests Node-API version 11, but Perry supports versions 1 through 10" + ), + "a Node-API 11 addon must be rejected before it initializes: {newer_stdout}" + ); + + let node_api_version = Command::new("node") + .args(["-p", "process.versions.napi"]) + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|version| version.trim().parse::().ok()); + match node_api_version { + Some(version) if version >= 10 => { + let node = run( + { + let mut command = Command::new("node"); + command.current_dir(root).arg(&entry); + command + }, + "Node-API 10 Node differential", + ); + assert_eq!( + String::from_utf8_lossy(&node.stdout), + ADDON_V10_EXPECTED, + "the checked-in transcript must still be Node's" + ); + } + _ => eprintln!("SKIP: Node with Node-API 10 is unavailable for the differential"), + } +} + #[test] fn bun_import_meta_require_project_addon_survives_source_removal() { if !require_tool("clang") { diff --git a/docs/src/internals/node-api-host.md b/docs/src/internals/node-api-host.md index d9bf95274b..ce253c01c9 100644 --- a/docs/src/internals/node-api-host.md +++ b/docs/src/internals/node-api-host.md @@ -7,7 +7,7 @@ contract kept alongside the implementation and its gates. ## Implementation status -The optional `perry-runtime/node-api-host` feature contains the version-8 host: +The optional `perry-runtime/node-api-host` feature contains the version-10 host: opaque handle scopes and references, GC root rewriting and weak clearing, object metadata/finalization, values and descriptors, native callbacks, buffers/views, promises, async work, threadsafe functions, cleanup hooks, and @@ -18,7 +18,7 @@ Approved addons are screened for direct libuv/V8/NAN/Node C++ imports, copied to the relocatable `.perry-native` sidecar, hashed into its manifest and build cache, and loaded only after every payload hash is verified. `require()` and `process.dlopen()` share that authorization and cache. Linker -exports come from the checked-in version-8 symbol inventory and are absent when +exports come from the checked-in version-10 symbol inventory and are absent when the addon graph is empty, preserving the zero-byte default path. The integration gate compiles and executes a direct C addon, verifies its @@ -40,8 +40,8 @@ Perry facade remains on that facade: the host never supersedes a | Area | Decision | |---|---| -| Advertised API | Node-API version 8 | -| `napi_env` | One environment per Perry agent/realm, owned by its JavaScript thread | +| Advertised API | Node-API version 10 (`napi_get_version`); modules declaring 1 through 10 load | +| `napi_env` | One environment per Perry agent/realm, owned by its JavaScript thread; per-module facts are attributed to the running addon | | `napi_value` | Opaque host token containing an index and generation for an environment-local handle slot; never a Perry heap address | | Handle roots | Open handle scopes are mutable GC roots and are rewritten after evacuation | | `napi_ref` | Strong references root their value; zero-count references use Perry's existing weak-target machinery | @@ -54,13 +54,43 @@ Perry facade remains on that facade: the host never supersedes a | Policy | Exact package-name allowlist in `package.json` under `perry.nativeAddons` | | Size gate | No addon in the graph means no host archive references, no exported Node-API symbols, and a zero-byte executable delta | -Version 8 is the baseline selected by Node's own v22 headers when an addon does -not request a newer version. It includes BigInt, dates, detach, type tags, -async cleanup, and the complete TSFN surface needed by current napi-rs and -node-addon-api packages, while avoiding a false claim for the version 9 and 10 -extras. A module whose `node_api_module_get_api_version_v1()` returns more than -8 is rejected before its initializer runs, with the requested and supported -versions in the diagnostic. +The host implements every stable entry point through Node-API version 10, the +highest version Node 26 supports (#10456). Current releases of argon2, +better-sqlite3 and sharp declare version 9 or 10 through node-addon-api, so a +version-8 ceiling rejected them before their initializers ran. Module versions +follow Node's rules: no `node_api_module_get_api_version_v1()` export, or any +version below 8, runs as version 8; 9 and 10 run as declared; a higher version, +or `NAPI_VERSION_EXPERIMENTAL` (the experimental entry points are not +exported), is rejected before the initializer runs with the requested and +supported versions in the diagnostic. + +### Module attribution and version-dependent behavior + +Node creates one `napi_env` per module and stores that module's version and +file URL on it. Perry keeps one environment per agent, so the two per-module +facts Node-API exposes are attributed instead. Every host-initiated entry into +addon code that receives an environment runs with its module marked active: +the initializer, native function callbacks (recorded when the function is +created), async-work completions, TSFN `call_js_cb` and finalize callbacks, and +object/instance-data finalizers (each recorded when created). An environment +cleanup hook receives no environment and is not attributed. + +- `node_api_get_module_file_name` returns the active module's `file://` URL of + the canonical sidecar path it was loaded from. Code outside every attributed + entry point sees the most recently loaded module. +- An exception left pending by a callback with no JavaScript caller follows + `node_napi_env__::CallbackIntoModule`: async-work completions and finalizers + report it as an uncaught exception for every module version; TSFN callbacks + report it for version 10 and later, while a module declaring a lower version + gets the `DEP0168` deprecation warning and the exception is dropped. Once the + environment is shutting down, the exception is dropped. +- `napi_create_reference` accepts any value type, the version-10 rule, for + every module. +- Node returns `napi_cannot_run_js` (version 10) or `napi_pending_exception` + when an environment can no longer run JavaScript. Perry has no such state: + JavaScript stays callable while the environment shuts down. +- Instance data (`napi_set_instance_data`) belongs to the shared environment, + not to a module. ## Environment and handle representation @@ -355,7 +385,8 @@ following: 4. reject an unresolved `uv_*`, V8, NAN, or non-Node-API Node symbol with the exact symbol and addon path in the error; 5. if present, call `node_api_module_get_api_version_v1` and reject versions - above 8; + above 10 or `NAPI_VERSION_EXPERIMENTAL`, then record the module for + attribution; 6. prefer `napi_register_module_v1(env, exports)`; otherwise use the descriptor captured by `napi_module_register` while the library constructor ran; 7. use the initializer's returned object, or the supplied exports object when @@ -374,10 +405,9 @@ flags are rejected rather than ignored. Runtime-computed paths may load only a file present in the compile-time addon manifest; the allowlist is not a general `dlopen` capability. -The environment stores the active canonical module filename during -initialization. It is the future source for the version 9 -`node_api_get_module_file_name` API, even though the version 8 host does not -export that symbol. +The initializer runs attributed to its module, which is how +`node_api_get_module_file_name` answers during initialization; see +[Module attribution](#module-attribution-and-version-dependent-behavior). ## Linking and exported symbols @@ -510,7 +540,7 @@ The inventory is pinned to Node v26.5.1's and [`node_api.h`](https://github.com/nodejs/node/blob/v26.5.1/src/node_api.h). `v1` means required before the host is usable. `later` means the declaration is newer than the advertised version or experimental and is not exported. -`never` means Perry exports the version-8 symbol when necessary for binary +`never` means Perry exports the symbol when necessary for binary resolution, but it deterministically reports the stated unsupported facility. ### `js_native_api.h`: core through version 4 @@ -520,11 +550,11 @@ resolution, but it deterministically reports the stated unsupported facility. | v1 | `napi_get_last_error_info` | Stable per-environment storage | | v1 | `napi_get_undefined`, `napi_get_null`, `napi_get_global`, `napi_get_boolean` | Singleton values receive ordinary scoped handles | | v1 | `napi_create_object`, `napi_create_array`, `napi_create_array_with_length` | Perry object/array allocators | -| v1 | `napi_create_double`, `napi_create_int32`, `napi_create_uint32`, `napi_create_int64` | Perry NaN-box conversions | +| v1 | `napi_create_double`, `napi_create_int32`, `napi_create_uint32`, `napi_create_int64` | Plain doubles, never the INT32 encoding that class refs share | | v1 | `napi_create_string_latin1`, `napi_create_string_utf8`, `napi_create_string_utf16` | Length-bounded; `NAPI_AUTO_LENGTH` supported | | v1 | `napi_create_symbol`, `napi_create_function` | Native callbacks use host records | | v1 | `napi_create_error`, `napi_create_type_error`, `napi_create_range_error` | `code` is installed when supplied | -| v1 | `napi_typeof` | Includes function, external, symbol, and bigint distinctions | +| v1 | `napi_typeof` | Classifies exactly like the `typeof` operator (class refs, class objects and callable proxies are functions), plus `null` and `external` | | v1 | `napi_get_value_double`, `napi_get_value_int32`, `napi_get_value_uint32`, `napi_get_value_int64`, `napi_get_value_bool` | Checked type/status behavior | | v1 | `napi_get_value_string_latin1`, `napi_get_value_string_utf8`, `napi_get_value_string_utf16` | Query-length and NUL-termination semantics included | | v1 | `napi_coerce_to_bool`, `napi_coerce_to_number`, `napi_coerce_to_object`, `napi_coerce_to_string` | User code is exception-trapped | @@ -544,7 +574,7 @@ resolution, but it deterministically reports the stated unsupported facility. | v1 | `napi_is_arraybuffer`, `napi_create_arraybuffer`, `napi_create_external_arraybuffer`, `napi_get_arraybuffer_info` | Stable backing pointers | | v1 | `napi_is_typedarray`, `napi_create_typedarray`, `napi_get_typedarray_info` | All eleven declared typed-array kinds | | v1 | `napi_create_dataview`, `napi_is_dataview`, `napi_get_dataview_info` | Backing identity and offsets preserved | -| v1 | `napi_get_version` | Returns 8 | +| v1 | `napi_get_version` | Returns 10 | | v1 | `napi_create_promise`, `napi_resolve_deferred`, `napi_reject_deferred`, `napi_is_promise` | Deferred records are environment-owned roots | | never | `napi_run_script` | Returns `napi_generic_failure`; arbitrary runtime source execution would violate the no-runtime-engine model | | v1 | `napi_adjust_external_memory` | Collector pressure accounting | @@ -565,11 +595,11 @@ resolution, but it deterministically reports the stated unsupported facility. ### `js_native_api.h`: version 9, version 10, and experimental -| Status | Version | Entry points | Reason | +| Status | Version | Entry points | Notes | |---|---:|---|---| -| later | 9 | `node_api_symbol_for`, `node_api_create_syntax_error`, `node_api_throw_syntax_error` | Advertise only with a complete version-9 surface | -| later | 10 | `node_api_create_external_string_latin1`, `node_api_create_external_string_utf16` | Requires external string lifetime/accounting work | -| later | 10 | `node_api_create_property_key_latin1`, `node_api_create_property_key_utf8`, `node_api_create_property_key_utf16` | Version-10 fast-path aliases | +| v1 | 9 | `node_api_symbol_for`, `node_api_create_syntax_error`, `node_api_throw_syntax_error` | Global `Symbol.for` registry; `code` is installed when supplied | +| v1 | 10 | `node_api_create_external_string_latin1`, `node_api_create_external_string_utf16` | Copied into a Perry string: `copied` is `true` and the finalizer runs before the call returns, the same path Node takes when V8 cannot adopt the storage | +| v1 | 10 | `node_api_create_property_key_latin1`, `node_api_create_property_key_utf8`, `node_api_create_property_key_utf16` | Ordinary strings; Perry has no separate internalized form | | later | experimental | `node_api_post_finalizer` | Not part of the advertised stable ABI | | later | experimental | `node_api_create_object_with_properties`, `node_api_set_prototype` | Not part of the advertised stable ABI | | later | experimental | `node_api_create_sharedarraybuffer`, `node_api_create_external_sharedarraybuffer`, `node_api_is_sharedarraybuffer` | Not part of the advertised stable ABI | @@ -589,8 +619,8 @@ resolution, but it deterministically reports the stated unsupported facility. | v1 | 4 | `napi_create_threadsafe_function`, `napi_get_threadsafe_function_context`, `napi_call_threadsafe_function` | Event-pump-backed TSFN | | v1 | 4 | `napi_acquire_threadsafe_function`, `napi_release_threadsafe_function`, `napi_unref_threadsafe_function`, `napi_ref_threadsafe_function` | Thread count and event-loop keepalive | | v1 | 8 | `napi_add_async_cleanup_hook`, `napi_remove_async_cleanup_hook` | Shutdown waits for completion | -| later | 9 | `node_api_get_module_file_name` | Environment already records the future value | -| later | 10 | `node_api_create_buffer_from_arraybuffer` | Advertise with version 10 | +| v1 | 9 | `node_api_get_module_file_name` | `file://` URL of the attributed module's sidecar path | +| v1 | 10 | `node_api_create_buffer_from_arraybuffer` | Buffer view sharing the ArrayBuffer's storage; out-of-range windows throw `ERR_OUT_OF_RANGE` | The addon-side initializer exports `node_api_module_get_api_version_v1` and `napi_register_module_v1`; these are